Compare commits
313 commits
Author | SHA1 | Date | |
---|---|---|---|
9037f8e610 | |||
05bb77a793 | |||
2f0f3da2fa | |||
31f85c3e01 | |||
84a822022a | |||
94c83914a4 | |||
a461e2c55f | |||
99c9f85eaf | |||
848ad5220e | |||
![]() |
7577466978 | ||
![]() |
dfcaf4d35a | ||
![]() |
171394056f | ||
![]() |
60211545e1 | ||
![]() |
edbf9744f5 | ||
![]() |
d272c82454 | ||
![]() |
20b4f10b62 | ||
![]() |
3db0478be2 | ||
![]() |
c9fd71056f | ||
![]() |
ca2d2c2026 | ||
![]() |
2e775b3906 | ||
![]() |
ea3cb3c063 | ||
![]() |
340ed6a6d9 | ||
![]() |
5e0f0b59d8 | ||
![]() |
a0163001bd | ||
![]() |
1222a711e0 | ||
![]() |
8c3d0f1b83 | ||
![]() |
43cb2cd101 | ||
![]() |
81a2b84c9f | ||
![]() |
bae6d2b7c4 | ||
![]() |
9a377ed7bc | ||
![]() |
1119fa1407 | ||
![]() |
7b0f72d6ee | ||
![]() |
6f9a581d99 | ||
![]() |
b6faf36671 | ||
![]() |
17f3ec437c | ||
![]() |
3ec5b96bc9 | ||
![]() |
540fbbc2b4 | ||
![]() |
f355efefc1 | ||
![]() |
3bcaab9a4b | ||
![]() |
ef091b9932 | ||
![]() |
33667e7e6e | ||
![]() |
2a3cce422b | ||
![]() |
e0af21f098 | ||
![]() |
8636e4b73e | ||
![]() |
0ad4b2f16f | ||
![]() |
df63f06897 | ||
![]() |
09df269ee0 | ||
![]() |
39d7587ac9 | ||
![]() |
0f6a8230d8 | ||
![]() |
a6c1fd52a6 | ||
![]() |
eaa4fe3317 | ||
![]() |
5358a38bd8 | ||
![]() |
244c1a293d | ||
![]() |
2ef03f1592 | ||
![]() |
f2ef5c6f32 | ||
![]() |
b582dbb3b2 | ||
![]() |
b864a4dae3 | ||
![]() |
2b01fb41e2 | ||
![]() |
244217417b | ||
![]() |
651b5926dc | ||
![]() |
27879a900d | ||
![]() |
4b87717cd2 | ||
![]() |
91cfe01af3 | ||
![]() |
cb6ccab6ca | ||
![]() |
f04c55e901 | ||
![]() |
8e0c69cd66 | ||
![]() |
c31b64535d | ||
![]() |
89dee8d508 | ||
![]() |
93c2c7d34d | ||
![]() |
573b7c4593 | ||
![]() |
f9f21fbe76 | ||
![]() |
9cfb4436fa | ||
![]() |
925998b709 | ||
![]() |
a8bb2eb69f | ||
![]() |
bb6c2c341b | ||
![]() |
cbaaf2fd87 | ||
![]() |
e1fad28411 | ||
![]() |
bb26b18017 | ||
![]() |
3a6ad33ea1 | ||
![]() |
75421faf46 | ||
![]() |
4df4738dd3 | ||
![]() |
e8eae7e9db | ||
![]() |
f47cd57692 | ||
![]() |
bb332d5adf | ||
![]() |
7f54befb72 | ||
![]() |
af4ca96da8 | ||
![]() |
90d0256bf7 | ||
![]() |
353cde0ee8 | ||
![]() |
63130de577 | ||
![]() |
ad5a9c848d | ||
![]() |
be75d5a284 | ||
![]() |
0142ed7f4f | ||
![]() |
6c2db18af2 | ||
![]() |
93e2b1d310 | ||
![]() |
9baa4a0441 | ||
![]() |
f123faeee8 | ||
![]() |
e3ece7425f | ||
![]() |
ff4c49718e | ||
![]() |
5598b0a69d | ||
![]() |
dac91a2d1d | ||
![]() |
759f666085 | ||
![]() |
c89441bb78 | ||
![]() |
969187444b | ||
![]() |
1fd3d77bf9 | ||
![]() |
32202cc603 | ||
![]() |
bce7009ab0 | ||
![]() |
b00b17a473 | ||
![]() |
8be5b8d9c5 | ||
![]() |
503757da0e | ||
![]() |
85a1b33e83 | ||
![]() |
022ad0334e | ||
![]() |
e09b3e4440 | ||
![]() |
409d80df4e | ||
![]() |
a6ae2fd4cb | ||
![]() |
59aa75e46c | ||
![]() |
1df2154f85 | ||
![]() |
326e74c04c | ||
![]() |
e1aee40f0c | ||
![]() |
8193c48234 | ||
![]() |
9c641e0f49 | ||
![]() |
988d9b2c8d | ||
![]() |
305dcb4528 | ||
![]() |
a13da39af2 | ||
![]() |
9d7e6bb9ff | ||
![]() |
509c402227 | ||
![]() |
77443202aa | ||
![]() |
508e5ae739 | ||
![]() |
f38178ab7e | ||
![]() |
a670b683e5 | ||
![]() |
9a4c567c2b | ||
![]() |
672b6fd2dc | ||
![]() |
b8e9786f4d | ||
![]() |
cb067aa1ac | ||
![]() |
cf74e75d58 | ||
![]() |
9e33db5b4d | ||
![]() |
8e6a995c4e | ||
![]() |
62d666fb34 | ||
![]() |
403ccac5c2 | ||
![]() |
dcd42b7048 | ||
![]() |
f646639055 | ||
![]() |
a32c0cf0f0 | ||
![]() |
3f6ef0cdaf | ||
![]() |
32bda0d9a7 | ||
![]() |
4e10d882c9 | ||
![]() |
9eadf61109 | ||
![]() |
7a84a80736 | ||
![]() |
efc6ed2a01 | ||
![]() |
967e192b4c | ||
![]() |
a3ce840d02 | ||
![]() |
ee748d8920 | ||
![]() |
15fe378107 | ||
![]() |
2325501f3f | ||
![]() |
f02108fbcd | ||
![]() |
b224709c5d | ||
![]() |
152b942f57 | ||
![]() |
c1a99d9be5 | ||
![]() |
d4791df333 | ||
![]() |
1f95506abe | ||
![]() |
f850178afd | ||
![]() |
5805bb2b5b | ||
![]() |
731e82028c | ||
![]() |
fe53d488b5 | ||
![]() |
eed280e840 | ||
![]() |
f0580d8724 | ||
![]() |
83f24374e5 | ||
![]() |
6c055dd24c | ||
![]() |
bf99f751bb | ||
![]() |
8577eba448 | ||
![]() |
12672984d7 | ||
![]() |
1d2aedf4c3 | ||
![]() |
bb401e3410 | ||
![]() |
699c56e2f5 | ||
![]() |
64cc59eae9 | ||
![]() |
76a9cf3cb3 | ||
![]() |
5c94d2eece | ||
![]() |
6c7dc7a0e3 | ||
![]() |
b52d2242a4 | ||
![]() |
f02363592f | ||
![]() |
a3f6cd7a32 | ||
![]() |
a608e122b1 | ||
![]() |
3424631f5e | ||
![]() |
5c297c1daf | ||
![]() |
8fe267d345 | ||
![]() |
f4108c244b | ||
![]() |
5769593799 | ||
![]() |
ec9cb234a8 | ||
![]() |
5f26cfbbf3 | ||
![]() |
9280b4a6a7 | ||
![]() |
728b8018c4 | ||
![]() |
98537339bd | ||
![]() |
ae9e2977b4 | ||
![]() |
61de0ffc4c | ||
![]() |
421e5d1522 | ||
![]() |
5a505344ef | ||
![]() |
7b25d37616 | ||
![]() |
8641de4d5b | ||
![]() |
af0b79b07d | ||
![]() |
983f50a814 | ||
![]() |
79840b098f | ||
![]() |
95e0272afb | ||
![]() |
b89db3bc9b | ||
![]() |
8353064945 | ||
![]() |
de0cfb1431 | ||
![]() |
026cfebd49 | ||
![]() |
e2e0ee706f | ||
![]() |
d11bd21d89 | ||
![]() |
62952de907 | ||
![]() |
881882a117 | ||
![]() |
578abe3d5a | ||
![]() |
fd9a118bb0 | ||
![]() |
b7b57ca15b | ||
![]() |
670b4b1830 | ||
![]() |
f143f86732 | ||
![]() |
71b37a9d77 | ||
![]() |
22f984bb72 | ||
![]() |
bad11564c6 | ||
![]() |
bbd819df19 | ||
![]() |
34e22cd486 | ||
![]() |
4b4d444884 | ||
![]() |
cdfd7808dd | ||
![]() |
ffa23f445d | ||
![]() |
b2e4c291e8 | ||
![]() |
377601422e | ||
![]() |
eeb2f912b5 | ||
![]() |
e266dc3c28 | ||
![]() |
e14a1a0bee | ||
![]() |
598b705b36 | ||
![]() |
0a3a792a7e | ||
![]() |
16da0ec3f5 | ||
![]() |
892e64ef28 | ||
![]() |
b54ed21c93 | ||
![]() |
8ef2c09856 | ||
![]() |
0301269171 | ||
![]() |
a99f4877ce | ||
![]() |
405794467b | ||
![]() |
2610de09ae | ||
![]() |
2be1b8b538 | ||
![]() |
aea76c6c1a | ||
![]() |
dbbef98ac3 | ||
![]() |
43b832680c | ||
![]() |
b282d0a250 | ||
![]() |
0575a6cce6 | ||
![]() |
8e5d971a6f | ||
![]() |
a0f7af96e0 | ||
![]() |
2d65efdcb4 | ||
![]() |
456486b846 | ||
![]() |
1b404e0ee8 | ||
![]() |
aad89c2255 | ||
![]() |
9faa9b7c26 | ||
![]() |
cfe87422aa | ||
![]() |
8b7a14d916 | ||
![]() |
e1934510b5 | ||
![]() |
4067d89075 | ||
![]() |
41ab5d8f90 | ||
![]() |
6a5b0770e0 | ||
![]() |
f108103a4b | ||
![]() |
4d50f258e4 | ||
![]() |
bb5bbcc069 | ||
![]() |
8c11a7bbd4 | ||
![]() |
dc01b46fd0 | ||
![]() |
43521e6268 | ||
![]() |
550c72e994 | ||
![]() |
07f253d587 | ||
![]() |
162900091e | ||
![]() |
ecbbd3b459 | ||
![]() |
0685bc2a95 | ||
![]() |
d1ab33e64d | ||
![]() |
b87dff0fb0 | ||
![]() |
2d1fbbd4d3 | ||
![]() |
9bdb3171d9 | ||
![]() |
43a42ad1dc | ||
![]() |
799c263202 | ||
![]() |
54b5d5d377 | ||
![]() |
68a285b7fa | ||
![]() |
cfb546f056 | ||
![]() |
89f177539e | ||
![]() |
73b3c96fe2 | ||
![]() |
487b364c09 | ||
![]() |
dd36ecb7e1 | ||
![]() |
ef21280721 | ||
![]() |
e80f6645f1 | ||
![]() |
67bdc50758 | ||
![]() |
4685708d49 | ||
![]() |
784ee82b35 | ||
![]() |
17c2c9c044 | ||
![]() |
4bd8ebed79 | ||
![]() |
e30e65a2cc | ||
![]() |
bb37664df0 | ||
![]() |
939895599d | ||
![]() |
b027e9a9a8 | ||
![]() |
96d43a4ab0 | ||
![]() |
14e8f702ea | ||
![]() |
9b71593743 | ||
![]() |
c0a821a44f | ||
![]() |
29d5fab00d | ||
![]() |
202273b669 | ||
![]() |
7b375bb6a8 | ||
![]() |
966275923c | ||
![]() |
4e0677512f | ||
![]() |
2f5831cf4a | ||
![]() |
c15595887a | ||
![]() |
2d70dde7cd | ||
![]() |
ebb00923bf | ||
![]() |
d2a27e979b | ||
![]() |
07f6419bb5 | ||
![]() |
bbfbc509c6 | ||
![]() |
4d823acded | ||
![]() |
303425e699 | ||
![]() |
76f0764d27 | ||
![]() |
d02e6cc693 | ||
![]() |
cdd7aca33c | ||
![]() |
066bff3faf | ||
![]() |
801cdf2897 |
951 changed files with 25457 additions and 9816 deletions
2
.flutter
2
.flutter
|
@ -1 +1 @@
|
||||||
Subproject commit dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
|
Subproject commit d8a9f9a52e5af486f80d932e838ee93861ffd863
|
4
.github/workflows/dependency-review.yml
vendored
4
.github/workflows/dependency-review.yml
vendored
|
@ -17,11 +17,11 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- name: 'Checkout Repository'
|
- name: 'Checkout Repository'
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- name: 'Dependency Review'
|
- name: 'Dependency Review'
|
||||||
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
|
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
||||||
|
|
15
.github/workflows/quality-check.yml
vendored
15
.github/workflows/quality-check.yml
vendored
|
@ -18,7 +18,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
|
@ -26,7 +26,10 @@ jobs:
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Get Flutter packages
|
- name: Get Flutter packages
|
||||||
run: scripts/pub_get_all.sh
|
run: ./flutterw pub get
|
||||||
|
|
||||||
|
- name: Generate app localizations
|
||||||
|
run: ./flutterw gen-l10n
|
||||||
|
|
||||||
- name: Static analysis.
|
- name: Static analysis.
|
||||||
run: ./flutterw analyze
|
run: ./flutterw analyze
|
||||||
|
@ -52,14 +55,14 @@ jobs:
|
||||||
build-mode: manual
|
build-mode: manual
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
# Building relies on the Android Gradle plugin,
|
# Building relies on the Android Gradle plugin,
|
||||||
# which requires a modern Java version (not the default one).
|
# which requires a modern Java version (not the default one).
|
||||||
- name: Set up JDK for Android Gradle plugin
|
- name: Set up JDK for Android Gradle plugin
|
||||||
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
|
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
@ -69,7 +72,7 @@ jobs:
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
|
uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: ${{ matrix.build-mode }}
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
@ -83,6 +86,6 @@ jobs:
|
||||||
./flutterw build apk --profile -t lib/main_play.dart --flavor play
|
./flutterw build apk --profile -t lib/main_play.dart --flavor play
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
|
uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||||
with:
|
with:
|
||||||
category: "/language:${{matrix.language}}"
|
category: "/language:${{matrix.language}}"
|
||||||
|
|
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
|
@ -18,14 +18,14 @@ jobs:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
# Building relies on the Android Gradle plugin,
|
# Building relies on the Android Gradle plugin,
|
||||||
# which requires a modern Java version (not the default one).
|
# which requires a modern Java version (not the default one).
|
||||||
- name: Set up JDK for Android Gradle plugin
|
- name: Set up JDK for Android Gradle plugin
|
||||||
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
|
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
@ -34,7 +34,10 @@ jobs:
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Get Flutter packages
|
- name: Get Flutter packages
|
||||||
run: scripts/pub_get_all.sh
|
run: ./flutterw pub get
|
||||||
|
|
||||||
|
- name: Generate app localizations
|
||||||
|
run: ./flutterw gen-l10n
|
||||||
|
|
||||||
- name: Update Flutter version file
|
- name: Update Flutter version file
|
||||||
run: scripts/update_flutter_version.sh
|
run: scripts/update_flutter_version.sh
|
||||||
|
@ -75,19 +78,19 @@ jobs:
|
||||||
AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }}
|
AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }}
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
- name: Generate artifact attestation
|
||||||
uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1.4.4
|
uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0
|
||||||
with:
|
with:
|
||||||
subject-path: 'outputs/*'
|
subject-path: 'outputs/*'
|
||||||
|
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0
|
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
|
||||||
with:
|
with:
|
||||||
artifacts: "outputs/*"
|
artifacts: "outputs/*"
|
||||||
body: "[Changelog](https://github.com/${{ github.repository }}/blob/develop/CHANGELOG.md#${{ github.ref_name }})"
|
body: "[Changelog](https://github.com/${{ github.repository }}/blob/develop/CHANGELOG.md#${{ github.ref_name }})"
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Upload app bundle
|
- name: Upload app bundle
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: appbundle
|
name: appbundle
|
||||||
path: outputs/app-play-release.aab
|
path: outputs/app-play-release.aab
|
||||||
|
@ -98,7 +101,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
|
@ -106,7 +109,7 @@ jobs:
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Get appbundle from artifacts
|
- name: Get appbundle from artifacts
|
||||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
with:
|
with:
|
||||||
name: appbundle
|
name: appbundle
|
||||||
|
|
||||||
|
|
8
.github/workflows/scorecards.yml
vendored
8
.github/workflows/scorecards.yml
vendored
|
@ -31,7 +31,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
|
@ -41,7 +41,7 @@ jobs:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: "Run analysis"
|
- name: "Run analysis"
|
||||||
uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0
|
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
|
||||||
with:
|
with:
|
||||||
results_file: results.sarif
|
results_file: results.sarif
|
||||||
results_format: 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
|
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||||
# format to the repository Actions tab.
|
# format to the repository Actions tab.
|
||||||
- name: "Upload artifact"
|
- name: "Upload artifact"
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: SARIF file
|
name: SARIF file
|
||||||
path: results.sarif
|
path: results.sarif
|
||||||
|
@ -71,6 +71,6 @@ jobs:
|
||||||
|
|
||||||
# Upload the results to GitHub's code scanning dashboard.
|
# Upload the results to GitHub's code scanning dashboard.
|
||||||
- name: "Upload to code-scanning"
|
- name: "Upload to code-scanning"
|
||||||
uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
|
uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -5,9 +5,11 @@
|
||||||
*.swp
|
*.swp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.atom/
|
.atom/
|
||||||
|
.build/
|
||||||
.buildlog/
|
.buildlog/
|
||||||
.history
|
.history
|
||||||
.svn/
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
|
@ -27,7 +29,6 @@ migrate_working_dir/
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.flutter-plugins
|
.flutter-plugins
|
||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
.packages
|
|
||||||
.pub-cache/
|
.pub-cache/
|
||||||
.pub/
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
|
@ -46,3 +47,6 @@ app.*.map.json
|
||||||
# screenshot generation
|
# screenshot generation
|
||||||
/test_driver/assets/screenshots/
|
/test_driver/assets/screenshots/
|
||||||
/screenshots/
|
/screenshots/
|
||||||
|
|
||||||
|
# generated files
|
||||||
|
/lib/l10ngen/app_localizations*
|
||||||
|
|
29
.vscode/launch.json
vendored
Normal file
29
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
194
CHANGELOG.md
194
CHANGELOG.md
|
@ -4,6 +4,200 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## <a id="unreleased"></a>[Unreleased]
|
## <a id="unreleased"></a>[Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Info: show matching dynamic albums
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- crash when decoding some large thumbnails
|
||||||
|
|
||||||
|
## <a id="v1.13.2"></a>[v1.13.2] - 2025-06-02
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- downgraded Flutter to stable v3.27.4
|
||||||
|
- prevent display orientation flip when device rotation is locked
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- moved file losing its extension and no longer being detected as media in some cases
|
||||||
|
- opening home when launching app as media picker
|
||||||
|
- removing groups with obsolete albums
|
||||||
|
- loading group custom covers
|
||||||
|
- crash when parsing some large media with trailing thumbnail
|
||||||
|
|
||||||
|
## <a id="v1.13.1"></a>[v1.13.1] - 2025-05-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- albums: show groups to move/copy/export items
|
||||||
|
- albums: hide grouped albums containing hidden items only
|
||||||
|
|
||||||
|
## <a id="v1.13.0"></a>[v1.13.0] - 2025-05-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Albums: groups
|
||||||
|
- Collection: sort by storage path
|
||||||
|
- Search: week day filters
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- revert to Skia rendering engine
|
||||||
|
|
||||||
|
## <a id="v1.12.10"></a>[v1.12.10] - 2025-04-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Search: format filters
|
||||||
|
- Albums: sort by path
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- upgraded Flutter to stable v3.29.3
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- region decoding failing to access decoder pool
|
||||||
|
|
||||||
|
## <a id="v1.12.9"></a>[v1.12.9] - 2025-04-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Kannada translation (thanks Chethan, Prasannakumar T Bhat)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- enable Impeller rendering engine
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- memory pressure during browsing
|
||||||
|
|
||||||
|
## <a id="v1.12.8"></a>[v1.12.8] - 2025-03-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- swiping images for some combinations of screen size, device pixel ratio, and image size
|
||||||
|
|
||||||
|
## <a id="v1.12.7"></a>[v1.12.7] - 2025-03-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- handle launch error to report and export DB
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- DB post-upgrade sanitization
|
||||||
|
- upgraded Flutter to stable v3.29.2
|
||||||
|
|
||||||
|
## <a id="v1.12.6"></a>[v1.12.6] - 2025-03-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- data loss when editing metadata of items with incorrect mime types
|
||||||
|
- metadata inconsistency in the DB due to v1.12.4 upgrade
|
||||||
|
|
||||||
|
## <a id="v1.12.5"></a>[v1.12.5] - 2025-03-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- support for Samsung HEIC motion photos embedding video in sefd box
|
||||||
|
- Cataloguing: identify video location from Apple QuickTime metadata, and 3GPP `loci` atom
|
||||||
|
- Collection: stack RAW and HEIC with same file names
|
||||||
|
- display home tile in side drawer when customized
|
||||||
|
- Galician translation (thanks Rubén Castiñeiras Lorenzo)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- increased precision of file modified date to milliseconds
|
||||||
|
- upgraded Flutter to stable v3.29.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- opening motion photo embedded video when video track is not the first one
|
||||||
|
- some SVG rendering issues
|
||||||
|
- decoding of SVG containing references to namespaces in !ATTLIST
|
||||||
|
- fallback decoding of images packed in RGBA_1010102 config
|
||||||
|
|
||||||
|
## <a id="v1.12.4"></a>[v1.12.4] - 2025-03-05 [YANKED]
|
||||||
|
|
||||||
|
## <a id="v1.12.3"></a>[v1.12.3] - 2025-02-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Metadata: edit location via GPX
|
||||||
|
- Metadata: toggle for all types in removal dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Viewer: improved subsampling and filter quality strategy
|
||||||
|
- Collection: ignore moving an item to its current directory
|
||||||
|
- Collection: keep selection when action on several items is interrupted before processing
|
||||||
|
- Collection: preserve favourite status when converting items
|
||||||
|
- upgraded Flutter to stable v3.27.4
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- editing TIFF metadata increasing file size
|
||||||
|
- region decoding for some RAW files
|
||||||
|
- incorrect video size or orientation as reported by Media Store
|
||||||
|
- corrupting image when removing video from motion photo with incorrect metadata
|
||||||
|
|
||||||
|
## <a id="v1.12.2"></a>[v1.12.2] - 2025-01-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- DDM coordinate format option
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Video: use `media-kit` instead of `ffmpeg-kit` for metadata fetch
|
||||||
|
- Info: show video chapters
|
||||||
|
- Accessibility: apply system "touch and hold delay" setting
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- crash when cataloguing some videos
|
||||||
|
- switching to PiP for any inactive app state
|
||||||
|
|
||||||
|
## <a id="v1.12.1"></a>[v1.12.1] - 2025-01-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- dynamic album decompose action
|
||||||
|
- Danish translation (thanks Grooty12, Victor M, cat)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- analysis service not triggering because of uninitialized app lifecycle
|
||||||
|
- Viewer: displaying neighbour items when the initial item of a view intent is a new one
|
||||||
|
- Search: dynamic album name filtering
|
||||||
|
|
||||||
|
## <a id="v1.12.0"></a>[v1.12.0] - 2024-12-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Countries: show states for Mexico
|
||||||
|
- Estonian translation (thanks Priit Jõerüüt)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- upgraded Flutter to stable v3.27.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- crash when loading many new items on low memory devices
|
||||||
|
|
||||||
|
## <a id="v1.11.20"></a>[v1.11.20] - 2024-12-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Albums: dynamic albums from filter sets
|
||||||
|
- Bulgarian translation (thanks Petrov)
|
||||||
|
- Tamil translation (thanks தமிழ்நேரம்)
|
||||||
|
|
||||||
## <a id="v1.11.19"></a>[v1.11.19] - 2024-11-24
|
## <a id="v1.11.19"></a>[v1.11.19] - 2024-11-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
87
README.md
87
README.md
|
@ -35,7 +35,7 @@ It scans your media collection to identify **motion photos**, **panoramas** (aka
|
||||||
|
|
||||||
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
|
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
|
||||||
|
|
||||||
Aves integrates with Android (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**.
|
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**.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
@ -111,17 +111,96 @@ Some users have expressed the wish to financially support the project. Thanks!
|
||||||
|
|
||||||
## Project Setup
|
## Project Setup
|
||||||
|
|
||||||
|
### Install dependencies
|
||||||
|
|
||||||
Before running or building the app, update the dependencies for the desired flavor:
|
Before running or building the app, update the dependencies for the desired flavor:
|
||||||
```
|
```
|
||||||
# scripts/apply_flavor_play.sh
|
scripts/apply_flavor_play.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
To build the project, create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys.
|
To build the project, create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys.
|
||||||
|
|
||||||
To run the app:
|
### To run the app:
|
||||||
```
|
```
|
||||||
# ./flutterw run -t lib/main_play.dart --flavor play
|
./flutterw run -t lib/main_play.dart --flavor play
|
||||||
|
```
|
||||||
|
### To build the app:
|
||||||
|
|
||||||
|
creare file con le tue credenziali file.keystore
|
||||||
|
|
||||||
|
dove YOUR_ALIAS_NAME è il tuo unico alias name
|
||||||
|
|
||||||
|
e YOUR_ALIAS_PWD è la password del tuo alias
|
||||||
|
```sh
|
||||||
|
keytool -genkey -v -keystore file.keystore -alias YOUR_ALIAS_NAME -storepass YOUR_ALIAS_PWD -keypass YOUR_ALIAS_PWD -keyalg RSA -validity 36500
|
||||||
|
```
|
||||||
|
in questo caso ho inserito
|
||||||
|
```sh
|
||||||
|
cd android
|
||||||
|
keytool -genkey -v -keystore file.keystore -alias FabioMich66 -storepass Master66 -keypass Master66 -keyalg RSA -validity 36500
|
||||||
|
```
|
||||||
|
se non puoi eseguire keytool perchè non è nel path di sistema cercalo usando
|
||||||
|
```sh
|
||||||
|
cd /
|
||||||
|
sudo find -name keytool
|
||||||
|
```
|
||||||
|
compilare il file `<app dir>/android/key.properties`
|
||||||
|
```
|
||||||
|
nano android/key.properties
|
||||||
|
```
|
||||||
|
questi i miei dati utilizzando il format key_template.properties
|
||||||
|
```
|
||||||
|
storeFile=/Users/fabio/flutter_apps/aves/android/file.keystore
|
||||||
|
storePassword=Master66
|
||||||
|
keyAlias=FabioMich66
|
||||||
|
keyPassword=Master66
|
||||||
|
googleApiKey=<GOOGLE_API_KEY>
|
||||||
|
```
|
||||||
|
infine compilare l'apk
|
||||||
|
```
|
||||||
|
./flutterw build apk -t lib/main_play.dart --flavor play
|
||||||
```
|
```
|
||||||
|
|
||||||
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
|
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
|
||||||
[Build badge]: https://img.shields.io/github/actions/workflow/status/deckerst/aves/quality-check.yml?branch=develop
|
[Build badge]: https://img.shields.io/github/actions/workflow/status/deckerst/aves/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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,11 @@ analyzer:
|
||||||
# implicit-casts: false
|
# implicit-casts: false
|
||||||
# implicit-dynamic: false
|
# implicit-dynamic: false
|
||||||
|
|
||||||
|
# cf https://github.com/dart-lang/dart_style/wiki/Configuration
|
||||||
|
formatter:
|
||||||
|
page_width: 240
|
||||||
|
trailing_commas: preserve
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
rules:
|
rules:
|
||||||
# from 'flutter_lints', excluded
|
# from 'flutter_lints', excluded
|
||||||
|
|
5
android/.gitignore
vendored
5
android/.gitignore
vendored
|
@ -5,9 +5,12 @@ gradle-wrapper.jar
|
||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
/local.properties
|
/local.properties
|
||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
|
.cxx/
|
||||||
|
.kotlin/
|
||||||
|
/build/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
|
# See https://flutter.dev/to/reference-keystore
|
||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
|
|
@ -33,15 +33,13 @@ kotlin {
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace 'deckers.thibault.aves'
|
namespace = 'deckers.thibault.aves'
|
||||||
compileSdk 35
|
compileSdk = 36
|
||||||
// cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp
|
|
||||||
ndkVersion '27.0.12077973'
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId packageName
|
applicationId packageName
|
||||||
minSdk flutter.minSdkVersion
|
minSdk flutter.minSdkVersion
|
||||||
targetSdk 35
|
targetSdk 36
|
||||||
versionCode flutter.versionCode
|
versionCode flutter.versionCode
|
||||||
versionName flutter.versionName
|
versionName flutter.versionName
|
||||||
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>"]
|
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>"]
|
||||||
|
@ -136,14 +134,14 @@ flutter {
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
maven {
|
maven {
|
||||||
url 'https://jitpack.io'
|
url = 'https://jitpack.io'
|
||||||
content {
|
content {
|
||||||
includeGroup "com.github.deckerst"
|
includeGroup "com.github.deckerst"
|
||||||
includeGroup "com.github.deckerst.mp4parser"
|
includeGroup "com.github.deckerst.mp4parser"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
maven {
|
maven {
|
||||||
url 'https://s3.amazonaws.com/repo.commonsware.com'
|
url = 'https://s3.amazonaws.com/repo.commonsware.com'
|
||||||
content {
|
content {
|
||||||
excludeGroupByRegex "com\\.github\\.deckerst.*"
|
excludeGroupByRegex "com\\.github\\.deckerst.*"
|
||||||
}
|
}
|
||||||
|
@ -151,35 +149,36 @@ repositories {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
|
||||||
|
|
||||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
implementation "androidx.appcompat:appcompat:1.7.1"
|
||||||
implementation 'androidx.core:core-ktx:1.15.0'
|
implementation 'androidx.core:core-ktx:1.16.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.7'
|
implementation 'androidx.lifecycle:lifecycle-process:2.9.1'
|
||||||
implementation 'androidx.media:media:1.7.0'
|
implementation 'androidx.media:media:1.7.0'
|
||||||
implementation 'androidx.multidex:multidex:2.0.1'
|
implementation 'androidx.multidex:multidex:2.0.1'
|
||||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
implementation 'androidx.security:security-crypto:1.1.0-beta01'
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.10.0'
|
implementation 'androidx.work:work-runtime-ktx:2.10.1'
|
||||||
|
|
||||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
|
||||||
implementation 'com.commonsware.cwac:document:0.5.0'
|
implementation 'com.commonsware.cwac:document:0.5.0'
|
||||||
implementation 'com.drewnoakes:metadata-extractor:2.19.0'
|
implementation 'com.drewnoakes:metadata-extractor:2.19.0'
|
||||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
// SLF4J implementation for `mp4parser`
|
// SLF4J implementation for `mp4parser`
|
||||||
implementation 'org.slf4j:slf4j-simple:2.0.14'
|
implementation 'org.slf4j:slf4j-simple:2.0.17'
|
||||||
|
|
||||||
// forked, built by JitPack:
|
// forked, built by JitPack:
|
||||||
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||||
|
// - https://jitpack.io/p/deckerst/androidsvg
|
||||||
// - https://jitpack.io/p/deckerst/mp4parser
|
// - https://jitpack.io/p/deckerst/mp4parser
|
||||||
// - https://jitpack.io/p/deckerst/pixymeta-android
|
// - https://jitpack.io/p/deckerst/pixymeta-android
|
||||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:90c06eebf4'
|
implementation 'com.github.deckerst:Android-TiffBitmapFactory:d6b2b0aa4f'
|
||||||
implementation 'com.github.deckerst.mp4parser:isoparser:d5caf7a3dd'
|
implementation 'com.github.deckerst:androidsvg:67db933051'
|
||||||
implementation 'com.github.deckerst.mp4parser:muxer:d5caf7a3dd'
|
implementation 'com.github.deckerst.mp4parser:isoparser:c2898f1832'
|
||||||
implementation 'com.github.deckerst:pixymeta-android:9ec7097f17'
|
implementation 'com.github.deckerst.mp4parser:muxer:c2898f1832'
|
||||||
|
implementation 'com.github.deckerst:pixymeta-android:cb1cdc932e'
|
||||||
implementation project(':exifinterface')
|
implementation project(':exifinterface')
|
||||||
|
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.3'
|
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.13.1'
|
||||||
|
|
||||||
kapt 'androidx.annotation:annotation:1.9.1'
|
kapt 'androidx.annotation:annotation:1.9.1'
|
||||||
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
||||||
|
|
|
@ -329,13 +329,6 @@
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
<!--
|
|
||||||
Impeller is not supported by `media_kit` v1.1.10+1:
|
|
||||||
https://github.com/media-kit/media-kit/issues/707
|
|
||||||
|
|
||||||
Screenshot driver scenario is not supported by Impeller:
|
|
||||||
"Compressed screenshots not supported for Impeller"
|
|
||||||
-->
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||||
android:value="false" />
|
android:value="false" />
|
||||||
|
|
|
@ -14,6 +14,7 @@ import androidx.work.ForegroundInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import app.loup.streams_channel.StreamsChannel
|
import app.loup.streams_channel.StreamsChannel
|
||||||
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
import deckers.thibault.aves.channel.calls.DeviceHandler
|
import deckers.thibault.aves.channel.calls.DeviceHandler
|
||||||
import deckers.thibault.aves.channel.calls.GeocodingHandler
|
import deckers.thibault.aves.channel.calls.GeocodingHandler
|
||||||
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
|
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
|
||||||
|
@ -44,11 +45,12 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
||||||
private var backgroundChannel: MethodChannel? = null
|
private var backgroundChannel: MethodChannel? = null
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
|
Log.i(LOG_TAG, "Start analysis worker $id")
|
||||||
defaultScope.launch {
|
defaultScope.launch {
|
||||||
// prevent ANR triggered by slow operations in main thread
|
// prevent ANR triggered by slow operations in main thread
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
setForeground(createForegroundInfo())
|
setForeground(createForegroundInfo())
|
||||||
}
|
}.join()
|
||||||
suspendCoroutine { cont ->
|
suspendCoroutine { cont ->
|
||||||
workCont = cont
|
workCont = cont
|
||||||
onStart()
|
onStart()
|
||||||
|
@ -68,7 +70,6 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onStart() {
|
private fun onStart() {
|
||||||
Log.i(LOG_TAG, "Start analysis worker $id")
|
|
||||||
runBlocking {
|
runBlocking {
|
||||||
FlutterUtils.initFlutterEngine(applicationContext, SHARED_PREFERENCES_KEY, PREF_CALLBACK_HANDLE_KEY) {
|
FlutterUtils.initFlutterEngine(applicationContext, SHARED_PREFERENCES_KEY, PREF_CALLBACK_HANDLE_KEY) {
|
||||||
flutterEngine = it
|
flutterEngine = it
|
||||||
|
@ -132,12 +133,7 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
"updateNotification" -> {
|
"updateNotification" -> defaultScope.launch { safeSuspend(call, result, ::updateNotification) }
|
||||||
val title = call.argument<String>("title")
|
|
||||||
val message = call.argument<String>("message")
|
|
||||||
setForegroundAsync(createForegroundInfo(title, message))
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
"stop" -> {
|
"stop" -> {
|
||||||
workCont?.resume(null)
|
workCont?.resume(null)
|
||||||
|
@ -180,17 +176,22 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
||||||
.setContentIntent(openAppIntent)
|
.setContentIntent(openAppIntent)
|
||||||
.addAction(stopAction)
|
.addAction(stopAction)
|
||||||
.build()
|
.build()
|
||||||
return if (Build.VERSION.SDK_INT == 34) {
|
// from Android 14 (API 34), foreground service type is mandatory for long-running workers:
|
||||||
// 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
|
||||||
// https://developer.android.com/guide/background/persistent/how-to/long-running
|
return when {
|
||||||
ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
Build.VERSION.SDK_INT >= 35 -> ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING)
|
||||||
} else if (Build.VERSION.SDK_INT >= 35) {
|
Build.VERSION.SDK_INT == 34 -> ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING)
|
else -> ForegroundInfo(NOTIFICATION_ID, notification)
|
||||||
} else {
|
|
||||||
ForegroundInfo(NOTIFICATION_ID, notification)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun updateNotification(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val title = call.argument<String>("title")
|
||||||
|
val message = call.argument<String>("message")
|
||||||
|
setForeground(createForegroundInfo(title, message))
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<AnalysisWorker>()
|
private val LOG_TAG = LogUtils.createTag<AnalysisWorker>()
|
||||||
private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/analysis_service_background"
|
private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/analysis_service_background"
|
||||||
|
|
|
@ -8,7 +8,6 @@ import android.content.Intent
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
|
@ -16,6 +15,8 @@ import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.SizeF
|
import android.util.SizeF
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import androidx.core.net.toUri
|
||||||
import app.loup.streams_channel.StreamsChannel
|
import app.loup.streams_channel.StreamsChannel
|
||||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||||
import deckers.thibault.aves.channel.calls.DeviceHandler
|
import deckers.thibault.aves.channel.calls.DeviceHandler
|
||||||
|
@ -83,7 +84,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
|
|
||||||
private fun getDevicePixelRatio(): Float = Resources.getSystem().displayMetrics.density
|
private fun getDevicePixelRatio(): Float = Resources.getSystem().displayMetrics.density
|
||||||
|
|
||||||
private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List<FieldMap> {
|
private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List<SizeF> {
|
||||||
var sizes: List<SizeF>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
var sizes: List<SizeF>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java)
|
widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java)
|
||||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
@ -102,7 +103,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
sizes = listOf(SizeF(widthDip.toFloat(), heightDip.toFloat()))
|
sizes = listOf(SizeF(widthDip.toFloat(), heightDip.toFloat()))
|
||||||
}
|
}
|
||||||
|
|
||||||
return sizes.map { size -> hashMapOf("widthDip" to size.width, "heightDip" to size.height) }
|
return sizes
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getProps(
|
private suspend fun getProps(
|
||||||
|
@ -116,13 +117,14 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
if (sizesDip.isEmpty()) return null
|
if (sizesDip.isEmpty()) return null
|
||||||
|
|
||||||
val sizeDip = sizesDip.first()
|
val sizeDip = sizesDip.first()
|
||||||
if (sizeDip["widthDip"] == 0 || sizeDip["heightDip"] == 0) return null
|
if (sizeDip.width == 0f || sizeDip.height == 0f) return null
|
||||||
|
|
||||||
|
val sizesDipMap = sizesDip.map { size -> hashMapOf("widthDip" to size.width, "heightDip" to size.height) }
|
||||||
val isNightModeOn = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
val isNightModeOn = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
|
||||||
val params = hashMapOf(
|
val params = hashMapOf(
|
||||||
"widgetId" to widgetId,
|
"widgetId" to widgetId,
|
||||||
"sizesDip" to sizesDip,
|
"sizesDip" to sizesDipMap,
|
||||||
"devicePixelRatio" to getDevicePixelRatio(),
|
"devicePixelRatio" to getDevicePixelRatio(),
|
||||||
"drawEntryImage" to drawEntryImage,
|
"drawEntryImage" to drawEntryImage,
|
||||||
"reuseEntry" to reuseEntry,
|
"reuseEntry" to reuseEntry,
|
||||||
|
@ -217,7 +219,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
val heightPx = (sizeDip.height * devicePixelRatio).roundToInt()
|
val heightPx = (sizeDip.height * devicePixelRatio).roundToInt()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888).also {
|
val bitmap = createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888).also {
|
||||||
bitmaps.add(it)
|
bitmaps.add(it)
|
||||||
it.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
|
it.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
|
||||||
}
|
}
|
||||||
|
@ -259,7 +261,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildUpdateIntent(context: Context, widgetId: Int): PendingIntent {
|
private fun buildUpdateIntent(context: Context, widgetId: Int): PendingIntent {
|
||||||
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, Uri.parse("widget://$widgetId"), context, HomeWidgetProvider::class.java)
|
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, "widget://$widgetId".toUri(), context, HomeWidgetProvider::class.java)
|
||||||
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId))
|
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId))
|
||||||
|
|
||||||
return PendingIntent.getBroadcast(
|
return PendingIntent.getBroadcast(
|
||||||
|
@ -276,7 +278,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
|
|
||||||
private fun buildOpenAppIntent(context: Context, widgetId: Int): PendingIntent {
|
private fun buildOpenAppIntent(context: Context, widgetId: Int): PendingIntent {
|
||||||
// set a unique URI to prevent the intent (and its extras) from being shared by different widgets
|
// set a unique URI to prevent the intent (and its extras) from being shared by different widgets
|
||||||
val intent = Intent(MainActivity.INTENT_ACTION_WIDGET_OPEN, Uri.parse("widget://$widgetId"), context, MainActivity::class.java)
|
val intent = Intent(MainActivity.INTENT_ACTION_WIDGET_OPEN, "widget://$widgetId".toUri(), context, MainActivity::class.java)
|
||||||
.putExtra(MainActivity.EXTRA_KEY_WIDGET_ID, widgetId)
|
.putExtra(MainActivity.EXTRA_KEY_WIDGET_ID, widgetId)
|
||||||
|
|
||||||
return PendingIntent.getActivity(
|
return PendingIntent.getActivity(
|
||||||
|
|
|
@ -19,6 +19,7 @@ import androidx.annotation.RequiresApi
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat
|
import androidx.core.content.pm.ShortcutInfoCompat
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import app.loup.streams_channel.StreamsChannel
|
import app.loup.streams_channel.StreamsChannel
|
||||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||||
import deckers.thibault.aves.channel.calls.AccessibilityHandler
|
import deckers.thibault.aves.channel.calls.AccessibilityHandler
|
||||||
|
@ -442,7 +443,7 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this@MainActivity, Uri.parse(uriString)) }
|
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this@MainActivity, uriString.toUri()) }
|
||||||
val intent = Intent().apply {
|
val intent = Intent().apply {
|
||||||
val firstUri = toUri(pickedUris.first())
|
val firstUri = toUri(pickedUris.first())
|
||||||
if (pickedUris.size == 1) {
|
if (pickedUris.size == 1) {
|
||||||
|
|
|
@ -5,7 +5,15 @@ import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import app.loup.streams_channel.StreamsChannel
|
import app.loup.streams_channel.StreamsChannel
|
||||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||||
import deckers.thibault.aves.channel.calls.*
|
import deckers.thibault.aves.channel.calls.AccessibilityHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.DeviceHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.EmbeddedDataHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MediaFetchBytesHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MediaSessionHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MediaStoreHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.StorageHandler
|
||||||
import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler
|
import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler
|
||||||
import deckers.thibault.aves.channel.calls.window.WindowHandler
|
import deckers.thibault.aves.channel.calls.window.WindowHandler
|
||||||
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
||||||
|
|
|
@ -16,8 +16,12 @@ import deckers.thibault.aves.utils.FlutterUtils
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import java.util.*
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
|
@ -2,6 +2,7 @@ package deckers.thibault.aves
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
||||||
|
@ -39,7 +40,7 @@ class WallpaperActivity : MainActivity() {
|
||||||
if (originalIntent != null) {
|
if (originalIntent != null) {
|
||||||
val pickedUris = call.argument<List<String>>("uris")
|
val pickedUris = call.argument<List<String>>("uris")
|
||||||
if (!pickedUris.isNullOrEmpty()) {
|
if (!pickedUris.isNullOrEmpty()) {
|
||||||
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) }
|
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, uriString.toUri()) }
|
||||||
onNewIntent(Intent().apply {
|
onNewIntent(Intent().apply {
|
||||||
action = originalIntent
|
action = originalIntent
|
||||||
data = toUri(pickedUris.first())
|
data = toUri(pickedUris.first())
|
||||||
|
|
|
@ -21,27 +21,28 @@ class AvesByteSendingMethodCodec private constructor() : MethodCodec {
|
||||||
return STANDARD.encodeMethodCall(methodCall)
|
return STANDARD.encodeMethodCall(methodCall)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun encodeErrorEnvelope(errorCode: String, errorMessage: String?, errorDetails: Any?): ByteBuffer {
|
||||||
|
return STANDARD.encodeErrorEnvelope(errorCode, errorMessage, errorDetails)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun encodeErrorEnvelopeWithStacktrace(errorCode: String, errorMessage: String?, errorDetails: Any?, errorStacktrace: String?): ByteBuffer {
|
||||||
|
return STANDARD.encodeErrorEnvelopeWithStacktrace(errorCode, errorMessage, errorDetails, errorStacktrace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `StandardMethodCodec` writes the result to a `ByteArrayOutputStream`, then writes the stream to a `ByteBuffer`.
|
||||||
|
// Here we only handle `ByteArray` results, but we avoid the intermediate stream.
|
||||||
override fun encodeSuccessEnvelope(result: Any?): ByteBuffer {
|
override fun encodeSuccessEnvelope(result: Any?): ByteBuffer {
|
||||||
if (result is ByteArray) {
|
if (result is ByteArray) {
|
||||||
val size = result.size
|
return ByteBuffer.allocateDirect(1 + result.size).apply {
|
||||||
return ByteBuffer.allocateDirect(4 + size).apply {
|
// following `StandardMethodCodec`:
|
||||||
|
// First byte is zero in success case, and non-zero otherwise.
|
||||||
put(0)
|
put(0)
|
||||||
put(result)
|
put(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.e(LOG_TAG, "encodeSuccessEnvelope failed with result=$result")
|
Log.e(LOG_TAG, "encodeSuccessEnvelope failed with result=$result")
|
||||||
return ByteBuffer.allocateDirect(0)
|
return encodeErrorEnvelope("invalid-result-type", "Called success with a result which is not a `ByteArray`, type=${result?.javaClass}", null)
|
||||||
}
|
|
||||||
|
|
||||||
override fun encodeErrorEnvelope(errorCode: String, errorMessage: String?, errorDetails: Any?): ByteBuffer {
|
|
||||||
Log.e(LOG_TAG, "encodeErrorEnvelope failed with errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails")
|
|
||||||
return ByteBuffer.allocateDirect(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun encodeErrorEnvelopeWithStacktrace(errorCode: String, errorMessage: String?, errorDetails: Any?, errorStacktrace: String?): ByteBuffer {
|
|
||||||
Log.e(LOG_TAG, "encodeErrorEnvelopeWithStacktrace failed with errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails, errorStacktrace=$errorStacktrace")
|
|
||||||
return ByteBuffer.allocateDirect(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.content.res.Configuration
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.ViewConfiguration
|
||||||
import android.view.accessibility.AccessibilityManager
|
import android.view.accessibility.AccessibilityManager
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -17,6 +18,7 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
|
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
|
||||||
|
"getLongPressTimeout" -> safe(call, result, ::getLongPressTimeout)
|
||||||
"hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts)
|
"hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts)
|
||||||
"getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis)
|
"getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis)
|
||||||
"shouldUseBoldFont" -> safe(call, result, ::shouldUseBoldFont)
|
"shouldUseBoldFont" -> safe(call, result, ::shouldUseBoldFont)
|
||||||
|
@ -34,6 +36,10 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC
|
||||||
result.success(removed)
|
result.success(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getLongPressTimeout(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
result.success(ViewConfiguration.getLongPressTimeout())
|
||||||
|
}
|
||||||
|
|
||||||
private fun hasRecommendedTimeouts(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
private fun hasRecommendedTimeouts(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.app.ActivityManager
|
import android.app.ActivityManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
|
@ -18,7 +19,6 @@ import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
|
||||||
class AnalysisHandler(private val activity: FlutterFragmentActivity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler {
|
class AnalysisHandler(private val activity: FlutterFragmentActivity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler {
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
@ -38,9 +38,8 @@ class AnalysisHandler(private val activity: FlutterFragmentActivity, private val
|
||||||
}
|
}
|
||||||
|
|
||||||
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||||
with(preferences.edit()) {
|
preferences.edit {
|
||||||
putLong(AnalysisWorker.PREF_CALLBACK_HANDLE_KEY, callbackHandle)
|
putLong(AnalysisWorker.PREF_CALLBACK_HANDLE_KEY, callbackHandle)
|
||||||
apply()
|
|
||||||
}
|
}
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
|
@ -69,9 +68,8 @@ class AnalysisHandler(private val activity: FlutterFragmentActivity, private val
|
||||||
// work `Data` cannot occupy more than 10240 bytes when serialized
|
// work `Data` cannot occupy more than 10240 bytes when serialized
|
||||||
// so we save the possibly long list of entry IDs to shared preferences
|
// so we save the possibly long list of entry IDs to shared preferences
|
||||||
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||||
with(preferences.edit()) {
|
preferences.edit {
|
||||||
putStringSet(AnalysisWorker.PREF_ENTRY_IDS_KEY, allEntryIds?.map { it.toString() }?.toSet())
|
putStringSet(AnalysisWorker.PREF_ENTRY_IDS_KEY, allEntryIds?.map { it.toString() }?.toSet())
|
||||||
apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val workData = workDataOf(
|
val workData = workDataOf(
|
||||||
|
|
|
@ -19,6 +19,7 @@ import androidx.core.content.FileProvider
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat
|
import androidx.core.content.pm.ShortcutInfoCompat
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
@ -37,7 +38,6 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.anyCauseIs
|
import deckers.thibault.aves.utils.anyCauseIs
|
||||||
import deckers.thibault.aves.utils.getApplicationInfoCompat
|
import deckers.thibault.aves.utils.getApplicationInfoCompat
|
||||||
|
@ -153,7 +153,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
|
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
|
||||||
val density = context.resources.displayMetrics.density
|
val density = context.resources.displayMetrics.density
|
||||||
val size = (sizeDip * density).roundToInt()
|
val size = (sizeDip * density).roundToInt()
|
||||||
var data: ByteArray? = null
|
var bytes: ByteArray? = null
|
||||||
try {
|
try {
|
||||||
val iconResourceId = context.packageManager.getApplicationInfoCompat(packageName, 0).icon
|
val iconResourceId = context.packageManager.getApplicationInfoCompat(packageName, 0).icon
|
||||||
if (iconResourceId != Resources.ID_NULL) {
|
if (iconResourceId != Resources.ID_NULL) {
|
||||||
|
@ -174,7 +174,9 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val bitmap = withContext(Dispatchers.IO) { target.get() }
|
val bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||||
data = bitmap?.getBytes(canHaveAlpha = true, recycle = false)
|
// do not recycle bitmaps fetched from `ContentResolver` as their lifecycle is unknown
|
||||||
|
val recycle = false
|
||||||
|
bytes = BitmapUtils.getRawBytes(bitmap, recycle = recycle)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
|
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
|
||||||
}
|
}
|
||||||
|
@ -184,15 +186,15 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
Log.w(LOG_TAG, "failed to get app info for packageName=$packageName", e)
|
Log.w(LOG_TAG, "failed to get app info for packageName=$packageName", e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (data != null) {
|
if (bytes != null) {
|
||||||
result.success(data)
|
result.success(bytes)
|
||||||
} else {
|
} else {
|
||||||
result.error("getAppIcon-null", "failed to get icon for packageName=$packageName", null)
|
result.error("getAppIcon-null", "failed to get icon for packageName=$packageName", null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
|
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val label = call.argument<String>("label")
|
val label = call.argument<String>("label")
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("copyToClipboard-args", "missing arguments", null)
|
result.error("copyToClipboard-args", "missing arguments", null)
|
||||||
|
@ -219,7 +221,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun open(call: MethodCall, result: MethodChannel.Result) {
|
private fun open(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val title = call.argument<String>("title")
|
val title = call.argument<String>("title")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val forceChooser = call.argument<Boolean>("forceChooser")
|
val forceChooser = call.argument<Boolean>("forceChooser")
|
||||||
if (uri == null || forceChooser == null) {
|
if (uri == null || forceChooser == null) {
|
||||||
|
@ -236,7 +238,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openMap(call: MethodCall, result: MethodChannel.Result) {
|
private fun openMap(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) }
|
val geoUri = call.argument<String>("geoUri")?.toUri()
|
||||||
if (geoUri == null) {
|
if (geoUri == null) {
|
||||||
result.error("openMap-args", "missing arguments", null)
|
result.error("openMap-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -250,7 +252,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun setAs(call: MethodCall, result: MethodChannel.Result) {
|
private fun setAs(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val title = call.argument<String>("title")
|
val title = call.argument<String>("title")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("setAs-args", "missing arguments", null)
|
result.error("setAs-args", "missing arguments", null)
|
||||||
|
@ -273,7 +275,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { getShareableUri(context, Uri.parse(it)) })
|
val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { getShareableUri(context, it.toUri()) })
|
||||||
val mimeTypes = urisByMimeType.keys.toTypedArray()
|
val mimeTypes = urisByMimeType.keys.toTypedArray()
|
||||||
|
|
||||||
// simplify share intent for a single item, as some apps can handle one item but not more
|
// simplify share intent for a single item, as some apps can handle one item but not more
|
||||||
|
@ -366,8 +368,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
// route dependent arguments
|
// route dependent arguments
|
||||||
val filters = call.argument<List<String>>("filters")
|
val filters = call.argument<List<String>>("filters")
|
||||||
val explorerPath = call.argument<String>("path")
|
val explorerPath = call.argument<String>("path")
|
||||||
val viewUri = call.argument<String>("viewUri")?.let { Uri.parse(it) }
|
val viewUri = call.argument<String>("viewUri")?.toUri()
|
||||||
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) }
|
val geoUri = call.argument<String>("geoUri")?.toUri()
|
||||||
|
|
||||||
if (label == null || route == null) {
|
if (label == null || route == null) {
|
||||||
result.error("pin-args", "missing arguments", null)
|
result.error("pin-args", "missing arguments", null)
|
||||||
|
|
|
@ -12,7 +12,7 @@ import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
import androidx.core.net.toUri
|
||||||
import com.drew.metadata.file.FileTypeDirectory
|
import com.drew.metadata.file.FileTypeDirectory
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
|
@ -44,6 +44,7 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
import org.mp4parser.IsoFile
|
import org.mp4parser.IsoFile
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
|
||||||
class DebugHandler(private val context: Context) : MethodCallHandler {
|
class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
@ -127,7 +128,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
|
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getBitmapDecoderInfo-args", "missing arguments", null)
|
result.error("getBitmapDecoderInfo-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -156,7 +157,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getContentResolverMetadata-args", "missing arguments", null)
|
result.error("getContentResolverMetadata-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -212,7 +213,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getExifInterfaceMetadata-args", "missing arguments", null)
|
result.error("getExifInterfaceMetadata-args", "missing arguments", null)
|
||||||
|
@ -239,7 +240,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getMediaMetadataRetrieverMetadata-args", "missing arguments", null)
|
result.error("getMediaMetadataRetrieverMetadata-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -264,7 +265,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
|
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getMetadataExtractorSummary-args", "missing arguments", null)
|
result.error("getMetadataExtractorSummary-args", "missing arguments", null)
|
||||||
|
@ -308,14 +309,14 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) {
|
private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getMp4ParserDump-args", "missing arguments", null)
|
result.error("getMp4ParserDump-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
if (mimeType == MimeTypes.MP4) {
|
if (mimeType == MimeTypes.MP4 || MimeTypes.isHeic(mimeType)) {
|
||||||
try {
|
try {
|
||||||
// we can skip uninteresting boxes with a seekable data source
|
// we can skip uninteresting boxes with a seekable data source
|
||||||
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||||
|
@ -338,7 +339,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getPixyMetadata-args", "missing arguments", null)
|
result.error("getPixyMetadata-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -359,7 +360,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) {
|
private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getTiffStructure-args", "missing arguments", null)
|
result.error("getTiffStructure-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,17 +1,19 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.LocaleConfig
|
import android.app.LocaleConfig
|
||||||
import android.app.LocaleManager
|
import android.app.LocaleManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.location.Geocoder
|
import android.location.Geocoder
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.LocaleList
|
import android.os.LocaleList
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -24,7 +26,6 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
|
||||||
|
|
||||||
class DeviceHandler(private val context: Context) : MethodCallHandler {
|
class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
@ -62,10 +63,17 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
"isDynamicColorAvailable" to DynamicColors.isDynamicColorAvailable(),
|
"isDynamicColorAvailable" to DynamicColors.isDynamicColorAvailable(),
|
||||||
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
|
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
|
||||||
"supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q),
|
"supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q),
|
||||||
|
"supportPictureInPicture" to supportPictureInPicture(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun supportPictureInPicture(): Boolean {
|
||||||
|
// minimum version for `PictureInPictureParams.Builder#setAutoEnterEnabled`
|
||||||
|
val supportPipOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
return supportPipOnLeave && context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||||
|
}
|
||||||
|
|
||||||
private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
fun toMap(locale: Locale): FieldMap = hashMapOf(
|
fun toMap(locale: Locale): FieldMap = hashMapOf(
|
||||||
"language" to locale.language,
|
"language" to locale.language,
|
||||||
|
@ -95,6 +103,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
@SuppressLint("WrongConstant")
|
||||||
val lm = context.getSystemService(Context.LOCALE_SERVICE) as? LocaleManager
|
val lm = context.getSystemService(Context.LOCALE_SERVICE) as? LocaleManager
|
||||||
lm?.overrideLocaleConfig = LocaleConfig(LocaleList.forLanguageTags(locales.joinToString(",")))
|
lm?.overrideLocaleConfig = LocaleConfig(LocaleList.forLanguageTags(locales.joinToString(",")))
|
||||||
}
|
}
|
||||||
|
@ -130,7 +139,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, Uri.parse("package:${context.packageName}"))
|
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, "package:${context.packageName}".toUri())
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,15 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
import com.adobe.internal.xmp.XMPUtils
|
import com.adobe.internal.xmp.XMPUtils
|
||||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||||
import com.drew.metadata.xmp.XmpDirectory
|
import com.drew.metadata.xmp.XmpDirectory
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
import deckers.thibault.aves.metadata.MultiPage
|
import deckers.thibault.aves.metadata.MultiPage
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
|
@ -18,11 +17,11 @@ import deckers.thibault.aves.metadata.xmp.GoogleDeviceContainer
|
||||||
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
||||||
import deckers.thibault.aves.metadata.xmp.XMP.getSafeStructField
|
import deckers.thibault.aves.metadata.xmp.XMP.getSafeStructField
|
||||||
import deckers.thibault.aves.metadata.xmp.XMPPropName
|
import deckers.thibault.aves.metadata.xmp.XMPPropName
|
||||||
|
import deckers.thibault.aves.model.EntryFields
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider
|
import deckers.thibault.aves.model.provider.ImageProvider
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
|
||||||
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
@ -46,7 +45,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) }
|
"getExifThumbnails" -> ioScope.launch { safe(call, result, ::getExifThumbnails) }
|
||||||
"extractGoogleDeviceItem" -> ioScope.launch { safe(call, result, ::extractGoogleDeviceItem) }
|
"extractGoogleDeviceItem" -> ioScope.launch { safe(call, result, ::extractGoogleDeviceItem) }
|
||||||
"extractJpegMpfItem" -> ioScope.launch { safe(call, result, ::extractJpegMpfItem) }
|
"extractJpegMpfItem" -> ioScope.launch { safe(call, result, ::extractJpegMpfItem) }
|
||||||
"extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) }
|
"extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) }
|
||||||
|
@ -57,9 +56,9 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getExifThumbnails-args", "missing arguments", null)
|
result.error("getExifThumbnails-args", "missing arguments", null)
|
||||||
|
@ -74,7 +73,9 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||||
exif.thumbnailBitmap?.let { bitmap ->
|
exif.thumbnailBitmap?.let { bitmap ->
|
||||||
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
||||||
it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) }
|
// 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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -88,7 +89,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractGoogleDeviceItem(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractGoogleDeviceItem(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
val dataUri = call.argument<String>("dataUri")
|
val dataUri = call.argument<String>("dataUri")
|
||||||
|
@ -143,7 +144,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractJpegMpfItem(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractJpegMpfItem(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
val id = call.argument<Int>("id")
|
val id = call.argument<Int>("id")
|
||||||
|
@ -177,7 +178,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
if (mimeType == null || uri == null || sizeBytes == null) {
|
if (mimeType == null || uri == null || sizeBytes == null) {
|
||||||
|
@ -185,7 +186,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
MultiPage.getTrailerVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||||
val imageSizeBytes = sizeBytes - videoSizeBytes
|
val imageSizeBytes = sizeBytes - videoSizeBytes
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
copyEmbeddedBytes(result, mimeType, displayName, input, imageSizeBytes)
|
copyEmbeddedBytes(result, mimeType, displayName, input, imageSizeBytes)
|
||||||
|
@ -198,7 +199,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
if (mimeType == null || uri == null || sizeBytes == null) {
|
if (mimeType == null || uri == null || sizeBytes == null) {
|
||||||
|
@ -206,11 +207,10 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
MultiPage.getMotionPhotoVideoSizing(context, uri, mimeType, sizeBytes)?.let { (videoOffset, videoSize) ->
|
||||||
val videoStartOffset = sizeBytes - videoSizeBytes
|
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
input.skip(videoStartOffset)
|
input.skip(videoOffset)
|
||||||
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input, videoSizeBytes)
|
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input, videoSize)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -219,7 +219,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("extractVideoEmbeddedPicture-args", "missing arguments", null)
|
result.error("extractVideoEmbeddedPicture-args", "missing arguments", null)
|
||||||
|
@ -251,7 +251,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
val dataProp = call.argument<List<Any>>("propPath")
|
val dataProp = call.argument<List<Any>>("propPath")
|
||||||
|
@ -311,7 +311,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
embeddedByteStream: InputStream,
|
embeddedByteStream: InputStream,
|
||||||
embeddedByteLength: Long,
|
embeddedByteLength: Long,
|
||||||
) {
|
) {
|
||||||
val extension = extensionFor(mimeType)
|
val extension = extensionFor(mimeType, defaultExtension = null)
|
||||||
val targetFile = StorageUtils.createTempFile(context, extension).apply {
|
val targetFile = StorageUtils.createTempFile(context, extension).apply {
|
||||||
transferFrom(embeddedByteStream, embeddedByteLength)
|
transferFrom(embeddedByteStream, embeddedByteLength)
|
||||||
}
|
}
|
||||||
|
@ -319,7 +319,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
val authority = "${context.applicationContext.packageName}.file_provider"
|
val authority = "${context.applicationContext.packageName}.file_provider"
|
||||||
val uri = if (displayName != null) {
|
val uri = if (displayName != null) {
|
||||||
// add extension to ease type identification when sharing this content
|
// add extension to ease type identification when sharing this content
|
||||||
val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) {
|
val displayNameWithExtension = if (displayName.endsWith(extension, ignoreCase = true)) {
|
||||||
displayName
|
displayName
|
||||||
} else {
|
} else {
|
||||||
"$displayName$extension"
|
"$displayName$extension"
|
||||||
|
@ -329,8 +329,8 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
FileProvider.getUriForFile(context, authority, targetFile)
|
FileProvider.getUriForFile(context, authority, targetFile)
|
||||||
}
|
}
|
||||||
val resultFields: FieldMap = hashMapOf(
|
val resultFields: FieldMap = hashMapOf(
|
||||||
"uri" to uri.toString(),
|
EntryFields.URI to uri.toString(),
|
||||||
"mimeType" to mimeType,
|
EntryFields.MIME_TYPE to mimeType,
|
||||||
)
|
)
|
||||||
if (isImage(mimeType) || isVideo(mimeType)) {
|
if (isImage(mimeType) || isVideo(mimeType)) {
|
||||||
val provider = getProvider(context, uri)
|
val provider = getProvider(context, uri)
|
||||||
|
|
|
@ -31,7 +31,7 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
|
||||||
private fun getAddress(call: MethodCall, result: MethodChannel.Result) {
|
private fun getAddress(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val latitude = call.argument<Number>("latitude")?.toDouble()
|
val latitude = call.argument<Number>("latitude")?.toDouble()
|
||||||
val longitude = call.argument<Number>("longitude")?.toDouble()
|
val longitude = call.argument<Number>("longitude")?.toDouble()
|
||||||
val localeString = call.argument<String>("locale")
|
val localeLanguageTag = call.argument<String>("localeLanguageTag")
|
||||||
val maxResults = call.argument<Int>("maxResults") ?: 1
|
val maxResults = call.argument<Int>("maxResults") ?: 1
|
||||||
if (latitude == null || longitude == null) {
|
if (latitude == null || longitude == null) {
|
||||||
result.error("getAddress-args", "missing arguments", null)
|
result.error("getAddress-args", "missing arguments", null)
|
||||||
|
@ -43,11 +43,8 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
geocoder = geocoder ?: if (localeString != null) {
|
geocoder = geocoder ?: if (localeLanguageTag != null) {
|
||||||
val split = localeString.split("_")
|
Geocoder(context, Locale.forLanguageTag(localeLanguageTag))
|
||||||
val language = split[0]
|
|
||||||
val country = if (split.size > 1) split[1] else ""
|
|
||||||
Geocoder(context, Locale(language, country))
|
|
||||||
} else {
|
} else {
|
||||||
Geocoder(context)
|
Geocoder(context)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
import deckers.thibault.aves.SearchSuggestionsProvider
|
import deckers.thibault.aves.SearchSuggestionsProvider
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
@ -29,9 +30,8 @@ class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
val preferences = context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
val preferences = context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||||
with(preferences.edit()) {
|
preferences.edit {
|
||||||
putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle)
|
putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle)
|
||||||
apply()
|
|
||||||
}
|
}
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -44,7 +44,7 @@ class MediaEditHandler(private val contextWrapper: ContextWrapper) : MethodCallH
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
|
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val desiredName = call.argument<String>("desiredName")
|
val desiredName = call.argument<String>("desiredName")
|
||||||
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
|
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
|
||||||
val bytes = call.argument<ByteArray>("bytes")
|
val bytes = call.argument<ByteArray>("bytes")
|
||||||
|
|
|
@ -2,12 +2,13 @@ package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.net.Uri
|
import androidx.core.net.toUri
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
|
||||||
|
import deckers.thibault.aves.model.EntryFields
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
@ -27,18 +28,18 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getThumbnail" -> ioScope.launch { safeSuspend(call, result, ::getThumbnail) }
|
"getThumbnail" -> ioScope.launch { safe(call, result, ::getThumbnail) }
|
||||||
"getRegion" -> ioScope.launch { safeSuspend(call, result, ::getRegion) }
|
"getRegion" -> ioScope.launch { safe(call, result, ::getRegion) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
|
private fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")
|
val uri = call.argument<String>(EntryFields.URI)
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>(EntryFields.MIME_TYPE)
|
||||||
val dateModifiedSecs = call.argument<Number>("dateModifiedSecs")?.toLong()
|
val dateModifiedMillis = call.argument<Number>(EntryFields.DATE_MODIFIED_MILLIS)?.toLong()
|
||||||
val rotationDegrees = call.argument<Int>("rotationDegrees")
|
val rotationDegrees = call.argument<Int>(EntryFields.ROTATION_DEGREES)
|
||||||
val isFlipped = call.argument<Boolean>("isFlipped")
|
val isFlipped = call.argument<Boolean>(EntryFields.IS_FLIPPED)
|
||||||
val widthDip = call.argument<Number>("widthDip")?.toDouble()
|
val widthDip = call.argument<Number>("widthDip")?.toDouble()
|
||||||
val heightDip = call.argument<Number>("heightDip")?.toDouble()
|
val heightDip = call.argument<Number>("heightDip")?.toDouble()
|
||||||
val pageId = call.argument<Int>("pageId")
|
val pageId = call.argument<Int>("pageId")
|
||||||
|
@ -55,7 +56,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
context = context,
|
context = context,
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
dateModifiedSecs = dateModifiedSecs ?: (Date().time / 1000),
|
dateModifiedMillis = dateModifiedMillis ?: (Date().time),
|
||||||
rotationDegrees = rotationDegrees,
|
rotationDegrees = rotationDegrees,
|
||||||
isFlipped = isFlipped,
|
isFlipped = isFlipped,
|
||||||
width = (widthDip * density).roundToInt(),
|
width = (widthDip * density).roundToInt(),
|
||||||
|
@ -67,8 +68,8 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
).fetch()
|
).fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getRegion(call: MethodCall, result: MethodChannel.Result) {
|
private fun getRegion(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val pageId = call.argument<Int>("pageId")
|
val pageId = call.argument<Int>("pageId")
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
|
@ -96,6 +97,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
imageHeight = imageHeight,
|
imageHeight = imageHeight,
|
||||||
result = result,
|
result = result,
|
||||||
)
|
)
|
||||||
|
|
||||||
MimeTypes.TIFF -> TiffRegionFetcher(context).fetch(
|
MimeTypes.TIFF -> TiffRegionFetcher(context).fetch(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
page = pageId ?: 0,
|
page = pageId ?: 0,
|
||||||
|
@ -103,6 +105,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
regionRect = regionRect,
|
regionRect = regionRect,
|
||||||
result = result,
|
result = result,
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> regionFetcher.fetch(
|
else -> regionFetcher.fetch(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -21,14 +23,15 @@ class MediaFetchObjectHandler(private val context: Context) : MethodCallHandler
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getEntry" -> ioScope.launch { safe(call, result, ::getEntry) }
|
"getEntry" -> ioScope.launch { safe(call, result, ::getEntry) }
|
||||||
"clearSizedThumbnailDiskCache" -> ioScope.launch { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
"clearImageDiskCache" -> ioScope.launch { safe(call, result, ::clearImageDiskCache) }
|
||||||
|
"clearImageMemoryCache" -> ioScope.launch { safe(call, result, ::clearImageMemoryCache) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getEntry(call: MethodCall, result: MethodChannel.Result) {
|
private fun getEntry(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType") // MIME type is optional
|
val mimeType = call.argument<String>("mimeType") // MIME type is optional
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val allowUnsized = call.argument<Boolean>("allowUnsized") ?: false
|
val allowUnsized = call.argument<Boolean>("allowUnsized") ?: false
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getEntry-args", "missing arguments", null)
|
result.error("getEntry-args", "missing arguments", null)
|
||||||
|
@ -47,11 +50,18 @@ class MediaFetchObjectHandler(private val context: Context) : MethodCallHandler
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearSizedThumbnailDiskCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
private fun clearImageDiskCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
Glide.get(context).clearDiskCache()
|
Glide.get(context).clearDiskCache()
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearImageMemoryCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
Glide.get(context).clearMemory()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL = "deckers.thibault/aves/media_fetch_object"
|
const val CHANNEL = "deckers.thibault/aves/media_fetch_object"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.*
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import android.net.Uri
|
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.media.session.MediaButtonReceiver
|
import androidx.media.session.MediaButtonReceiver
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
|
@ -59,7 +63,7 @@ class MediaSessionHandler(private val context: Context, private val mediaCommand
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateSession(call: MethodCall, result: MethodChannel.Result) {
|
private suspend fun updateSession(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val title = call.argument<String>("title") ?: uri?.toString()
|
val title = call.argument<String>("title") ?: uri?.toString()
|
||||||
val durationMillis = call.argument<Number>("durationMillis")?.toLong()
|
val durationMillis = call.argument<Number>("durationMillis")?.toLong()
|
||||||
val stateString = call.argument<String>("state")
|
val stateString = call.argument<String>("state")
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import android.net.Uri
|
import androidx.core.net.toUri
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.metadata.Mp4TooLargeException
|
import deckers.thibault.aves.metadata.Mp4TooLargeException
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
|
@ -54,7 +54,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
@ -82,7 +82,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
@ -109,7 +109,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
@ -134,7 +134,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
@ -160,7 +160,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
import com.adobe.internal.xmp.XMPMeta
|
import com.adobe.internal.xmp.XMPMeta
|
||||||
import com.adobe.internal.xmp.XMPMetaFactory
|
import com.adobe.internal.xmp.XMPMetaFactory
|
||||||
|
@ -22,6 +23,7 @@ import com.drew.metadata.exif.GpsDirectory
|
||||||
import com.drew.metadata.file.FileTypeDirectory
|
import com.drew.metadata.file.FileTypeDirectory
|
||||||
import com.drew.metadata.gif.GifAnimationDirectory
|
import com.drew.metadata.gif.GifAnimationDirectory
|
||||||
import com.drew.metadata.iptc.IptcDirectory
|
import com.drew.metadata.iptc.IptcDirectory
|
||||||
|
import com.drew.metadata.mov.metadata.QuickTimeMetadataDirectory
|
||||||
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
||||||
import com.drew.metadata.png.PngDirectory
|
import com.drew.metadata.png.PngDirectory
|
||||||
import com.drew.metadata.webp.WebpDirectory
|
import com.drew.metadata.webp.WebpDirectory
|
||||||
|
@ -100,6 +102,8 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.mp4parser.boxes.threegpp.ts26244.LocationInformationBox
|
||||||
|
import org.mp4parser.tools.Path
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
import java.text.ParseException
|
import java.text.ParseException
|
||||||
|
@ -131,7 +135,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getAllMetadata-args", "missing arguments", null)
|
result.error("getAllMetadata-args", "missing arguments", null)
|
||||||
|
@ -448,9 +452,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
if (isVideo(mimeType)) {
|
if (isVideo(mimeType)) {
|
||||||
// `metadata-extractor` do not extract custom tags in user data box
|
// `metadata-extractor` do not extract custom tags in user data box
|
||||||
val userDataDir = Mp4ParserHelper.getUserData(context, mimeType, uri)
|
Mp4ParserHelper.getUserDataBox(context, mimeType, uri)?.let { box ->
|
||||||
if (userDataDir.isNotEmpty()) {
|
metadataMap[Metadata.DIR_MP4_USER_DATA] = Mp4ParserHelper.extractBoxFields(box)
|
||||||
metadataMap[Metadata.DIR_MP4_USER_DATA] = userDataDir
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is used as fallback when the video metadata cannot be found on the Dart side
|
// this is used as fallback when the video metadata cannot be found on the Dart side
|
||||||
|
@ -469,6 +472,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
// Android's `MediaExtractor` and `MediaPlayer` cannot be used for details
|
// Android's `MediaExtractor` and `MediaPlayer` cannot be used for details
|
||||||
// about embedded images as they do not list them as separate tracks
|
// about embedded images as they do not list them as separate tracks
|
||||||
// and only identify at most one
|
// and only identify at most one
|
||||||
|
} else if (isHeic(mimeType)) {
|
||||||
|
Mp4ParserHelper.getSamsungSefd(context, uri)?.let { (_, bytes) ->
|
||||||
|
metadataMap[Mp4ParserHelper.SAMSUNG_MAKERNOTE_BOX_TYPE] = hashMapOf(
|
||||||
|
"Size" to bytes.size.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metadataMap.isNotEmpty()) {
|
if (metadataMap.isNotEmpty()) {
|
||||||
|
@ -516,7 +525,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
// - XMP / MicrosoftPhoto:Rating
|
// - XMP / MicrosoftPhoto:Rating
|
||||||
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val path = call.argument<String>("path")
|
val path = call.argument<String>("path")
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
|
@ -526,8 +535,33 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
val metadataMap = HashMap<String, Any>()
|
val metadataMap = HashMap<String, Any>()
|
||||||
getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap)
|
getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap)
|
||||||
|
|
||||||
if (isVideo(mimeType) || isHeic(mimeType)) {
|
if (isVideo(mimeType) || isHeic(mimeType)) {
|
||||||
getMultimediaCatalogMetadataByMediaMetadataRetriever(mimeType, uri, metadataMap)
|
getMultimediaCatalogMetadataByMediaMetadataRetriever(mimeType, uri, metadataMap)
|
||||||
|
|
||||||
|
// fallback to MP4 `loci` box for location
|
||||||
|
if (!metadataMap.contains(KEY_LATITUDE) || !metadataMap.contains(KEY_LONGITUDE)) {
|
||||||
|
try {
|
||||||
|
Mp4ParserHelper.getUserDataBox(context, mimeType, uri)?.let { userDataBox ->
|
||||||
|
Path.getPath<LocationInformationBox>(userDataBox, LocationInformationBox.TYPE)?.let { locationBox ->
|
||||||
|
if (!locationBox.isParsed) {
|
||||||
|
locationBox.parseDetails()
|
||||||
|
}
|
||||||
|
metadataMap[KEY_LATITUDE] = locationBox.latitude
|
||||||
|
metadataMap[KEY_LONGITUDE] = locationBox.longitude
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to get Location Information box by MP4 parser for mimeType=$mimeType uri=$uri", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHeic(mimeType)) {
|
||||||
|
val flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
|
||||||
|
if ((flags and MASK_IS_MOTION_PHOTO == 0) && MultiPage.isHeicSefdMotionPhoto(context, uri)) {
|
||||||
|
metadataMap[KEY_FLAGS] = flags or MASK_IS_MULTIPAGE or MASK_IS_MOTION_PHOTO
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// report success even when empty
|
// report success even when empty
|
||||||
|
@ -685,6 +719,22 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!metadataMap.containsKey(KEY_LATITUDE) || !metadataMap.containsKey(KEY_LONGITUDE)) {
|
||||||
|
for (dir in metadata.getDirectoriesOfType(QuickTimeMetadataDirectory::class.java)) {
|
||||||
|
dir.getSafeString(QuickTimeMetadataDirectory.TAG_LOCATION_ISO6709) { locationString ->
|
||||||
|
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||||
|
if (matcher.find() && matcher.groupCount() >= 2) {
|
||||||
|
val latitude = matcher.group(1)?.toDoubleOrNull()
|
||||||
|
val longitude = matcher.group(2)?.toDoubleOrNull()
|
||||||
|
if (latitude != null && longitude != null) {
|
||||||
|
metadataMap[KEY_LATITUDE] = latitude
|
||||||
|
metadataMap[KEY_LONGITUDE] = longitude
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when (mimeType) {
|
when (mimeType) {
|
||||||
MimeTypes.PNG -> {
|
MimeTypes.PNG -> {
|
||||||
// date fallback to PNG time chunk
|
// date fallback to PNG time chunk
|
||||||
|
@ -829,7 +879,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
|
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metadataMap.containsKey(KEY_LATITUDE)) {
|
if (!metadataMap.containsKey(KEY_LATITUDE) || !metadataMap.containsKey(KEY_LONGITUDE)) {
|
||||||
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
||||||
if (locationString != null) {
|
if (locationString != null) {
|
||||||
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||||
|
@ -869,7 +919,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val fields = call.argument<List<String>>("fields")
|
val fields = call.argument<List<String>>("fields")
|
||||||
if (mimeType == null || uri == null || fields == null) {
|
if (mimeType == null || uri == null || fields == null) {
|
||||||
|
@ -957,7 +1007,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(metadataMap)
|
result.success(metadataMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// return description from these fields (by precedence):
|
// returns description from these fields (by precedence):
|
||||||
// - XMP / dc:description
|
// - XMP / dc:description
|
||||||
// - IPTC / caption-abstract
|
// - IPTC / caption-abstract
|
||||||
// - Exif / UserComment
|
// - Exif / UserComment
|
||||||
|
@ -1000,7 +1050,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getGeoTiffInfo(call: MethodCall, result: MethodChannel.Result) {
|
private fun getGeoTiffInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getGeoTiffInfo-args", "missing arguments", null)
|
result.error("getGeoTiffInfo-args", "missing arguments", null)
|
||||||
|
@ -1041,7 +1091,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) {
|
private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val isMotionPhoto = call.argument<Boolean>("isMotionPhoto")
|
val isMotionPhoto = call.argument<Boolean>("isMotionPhoto")
|
||||||
if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) {
|
if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) {
|
||||||
|
@ -1068,7 +1118,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) {
|
private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getPanoramaInfo-args", "missing arguments", null)
|
result.error("getPanoramaInfo-args", "missing arguments", null)
|
||||||
|
@ -1120,7 +1170,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
|
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getIptc-args", "missing arguments", null)
|
result.error("getIptc-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -1142,11 +1192,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// return XMP components
|
// returns XMP components
|
||||||
// return an empty list if there is no XMP
|
// returns an empty list if there is no XMP
|
||||||
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
|
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getXmp-args", "missing arguments", null)
|
result.error("getXmp-args", "missing arguments", null)
|
||||||
|
@ -1218,7 +1268,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getContentPropValue(call: MethodCall, result: MethodChannel.Result) {
|
private fun getContentPropValue(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val prop = call.argument<String>("prop")
|
val prop = call.argument<String>("prop")
|
||||||
if (mimeType == null || uri == null || prop == null) {
|
if (mimeType == null || uri == null || prop == null) {
|
||||||
result.error("getContentPropValue-args", "missing arguments", null)
|
result.error("getContentPropValue-args", "missing arguments", null)
|
||||||
|
@ -1235,7 +1285,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
|
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val field = call.argument<String>("field")
|
val field = call.argument<String>("field")
|
||||||
if (mimeType == null || uri == null || field == null) {
|
if (mimeType == null || uri == null || field == null) {
|
||||||
|
@ -1304,7 +1354,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getFields(call: MethodCall, result: MethodChannel.Result) {
|
private fun getFields(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val fields = call.argument<List<String>>("fields")
|
val fields = call.argument<List<String>>("fields")
|
||||||
if (mimeType == null || uri == null || fields == null) {
|
if (mimeType == null || uri == null || fields == null) {
|
||||||
|
|
|
@ -2,6 +2,7 @@ package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
|
@ -45,7 +46,7 @@ class SecurityHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
val preferences = getStore()
|
val preferences = getStore()
|
||||||
with(preferences.edit()) {
|
preferences.edit {
|
||||||
when (value) {
|
when (value) {
|
||||||
is Boolean -> putBoolean(key, value)
|
is Boolean -> putBoolean(key, value)
|
||||||
is Float -> putFloat(key, value)
|
is Float -> putFloat(key, value)
|
||||||
|
@ -58,7 +59,6 @@ class SecurityHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
apply()
|
|
||||||
}
|
}
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,21 +4,24 @@ import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.BitmapRegionDecoder
|
import android.graphics.BitmapRegionDecoder
|
||||||
|
import android.graphics.ColorSpace
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|
||||||
import com.bumptech.glide.request.RequestOptions
|
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
import deckers.thibault.aves.decoder.MultiPageImage
|
||||||
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
|
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MathUtils
|
import deckers.thibault.aves.utils.MathUtils
|
||||||
import deckers.thibault.aves.utils.MemoryUtils
|
import deckers.thibault.aves.utils.MemoryUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@ -28,16 +31,10 @@ import kotlin.math.roundToInt
|
||||||
class RegionFetcher internal constructor(
|
class RegionFetcher internal constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
) {
|
) {
|
||||||
private var lastDecoderRef: LastDecoderRef? = null
|
// returns decoded bytes in ARGB_8888, with trailer bytes:
|
||||||
|
// - width (int32)
|
||||||
private val pageTempUris = HashMap<Pair<Uri, Int>, Uri>()
|
// - height (int32)
|
||||||
|
fun fetch(
|
||||||
private val multiTrackGlideOptions = RequestOptions()
|
|
||||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
||||||
.skipMemoryCache(true)
|
|
||||||
|
|
||||||
suspend fun fetch(
|
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
pageId: Int?,
|
pageId: Int?,
|
||||||
|
@ -45,41 +42,31 @@ class RegionFetcher internal constructor(
|
||||||
regionRect: Rect,
|
regionRect: Rect,
|
||||||
imageWidth: Int,
|
imageWidth: Int,
|
||||||
imageHeight: Int,
|
imageHeight: Int,
|
||||||
|
requestKey: Pair<Uri, Int?> = Pair(uri, pageId),
|
||||||
result: MethodChannel.Result,
|
result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
||||||
val id = Pair(uri, pageId)
|
// use JPEG export for requested page
|
||||||
fetch(
|
fetch(
|
||||||
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, mimeType, pageId) },
|
uri = exportUris.getOrPut(requestKey) { createTemporaryJpegExport(uri, mimeType, pageId) },
|
||||||
mimeType = MimeTypes.JPEG,
|
mimeType = MimeTypes.JPEG,
|
||||||
pageId = null,
|
pageId = null,
|
||||||
sampleSize = sampleSize,
|
sampleSize = sampleSize,
|
||||||
regionRect = regionRect,
|
regionRect = regionRect,
|
||||||
imageWidth = imageWidth,
|
imageWidth = imageWidth,
|
||||||
imageHeight = imageHeight,
|
imageHeight = imageHeight,
|
||||||
|
requestKey = requestKey,
|
||||||
result = result,
|
result = result,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentDecoderRef = lastDecoderRef
|
|
||||||
if (currentDecoderRef != null && currentDecoderRef.uri != uri) {
|
|
||||||
currentDecoderRef = null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (currentDecoderRef == null) {
|
val decoder = getOrCreateDecoder(context, uri, requestKey)
|
||||||
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
|
if (decoder == null) {
|
||||||
BitmapRegionDecoderCompat.newInstance(input)
|
result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
|
||||||
}
|
return
|
||||||
if (newDecoder == null) {
|
|
||||||
result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
currentDecoderRef = LastDecoderRef(uri, newDecoder)
|
|
||||||
}
|
}
|
||||||
val decoder = currentDecoderRef.decoder
|
|
||||||
lastDecoderRef = currentDecoderRef
|
|
||||||
|
|
||||||
// with raw images, the known image size may not match the decoded image size
|
// with raw images, the known image size may not match the decoded image size
|
||||||
// so we scale the requested region accordingly
|
// so we scale the requested region accordingly
|
||||||
|
@ -101,34 +88,71 @@ class RegionFetcher internal constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// use `Long` as rect size could be unexpectedly large and go beyond `Int` max
|
val options = BitmapFactory.Options().apply {
|
||||||
val targetBitmapSizeBytes: Long = ARGB_8888_BYTE_SIZE.toLong() * effectiveRect.width() * effectiveRect.height() / effectiveSampleSize
|
inSampleSize = effectiveSampleSize
|
||||||
|
// Specifying preferred config and color space avoids the need for conversion afterwards,
|
||||||
|
// but may prevent decoding (e.g. from RGBA_1010102 to ARGB_8888 on some devices).
|
||||||
|
inPreferredConfig = PREFERRED_CONFIG
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
inPreferredColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pixelCount = effectiveRect.width() * effectiveRect.height() / effectiveSampleSize
|
||||||
|
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), options.inPreferredConfig)
|
||||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||||
// decoding a region that large would yield an OOM when creating the bitmap
|
// decoding a region that large would yield an OOM when creating the bitmap
|
||||||
result.error("fetch-large-region", "Region too large for uri=$uri regionRect=$regionRect", null)
|
result.error("fetch-large-region", "Region too large for uri=$uri regionRect=$regionRect", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val options = BitmapFactory.Options().apply {
|
var bitmap = decoder.decodeRegion(effectiveRect, options)
|
||||||
inSampleSize = effectiveSampleSize
|
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 bitmap = decoder.decodeRegion(effectiveRect, options)
|
|
||||||
if (bitmap != null) {
|
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = true)
|
||||||
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = true))
|
if (bytes != null) {
|
||||||
|
result.success(bytes)
|
||||||
} else {
|
} else {
|
||||||
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
|
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (mimeType != MimeTypes.JPEG) {
|
||||||
|
// retry with JPEG export on failure,
|
||||||
|
// as some formats are not fully supported by `BitmapRegionDecoder`
|
||||||
|
fetch(
|
||||||
|
uri = exportUris.getOrPut(requestKey) { createTemporaryJpegExport(uri, mimeType, pageId) },
|
||||||
|
mimeType = MimeTypes.JPEG,
|
||||||
|
pageId = null,
|
||||||
|
sampleSize = sampleSize,
|
||||||
|
regionRect = regionRect,
|
||||||
|
imageWidth = imageWidth,
|
||||||
|
imageHeight = imageHeight,
|
||||||
|
requestKey = requestKey,
|
||||||
|
result = result,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createJpegForPage(sourceUri: Uri, mimeType: String, pageId: Int): Uri {
|
private fun createTemporaryJpegExport(uri: Uri, mimeType: String, pageId: Int?): Uri {
|
||||||
|
Log.d(LOG_TAG, "create JPEG export for uri=$uri mimeType=$mimeType pageId=$pageId")
|
||||||
val target = Glide.with(context)
|
val target = Glide.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(multiTrackGlideOptions)
|
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
||||||
.load(MultiPageImage(context, sourceUri, mimeType, pageId))
|
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
|
||||||
.submit()
|
.submit()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val bitmap = target.get()
|
val bitmap = target.get()
|
||||||
val tempFile = StorageUtils.createTempFile(context).apply {
|
val tempFile = StorageUtils.createTempFile(context).apply {
|
||||||
|
@ -142,8 +166,40 @@ class RegionFetcher internal constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class LastDecoderRef(
|
private data class DecoderRef(
|
||||||
val uri: Uri,
|
val requestKey: Pair<Uri, Int?>,
|
||||||
val decoder: BitmapRegionDecoder,
|
val decoder: BitmapRegionDecoder,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LOG_TAG = LogUtils.createTag<RegionFetcher>()
|
||||||
|
private val PREFERRED_CONFIG = Bitmap.Config.ARGB_8888
|
||||||
|
private const val DECODER_POOL_SIZE = 3
|
||||||
|
private val decoderPool = ArrayList<DecoderRef>()
|
||||||
|
private val exportUris = HashMap<Pair<Uri, Int?>, Uri>()
|
||||||
|
|
||||||
|
private val poolLock = ReentrantLock()
|
||||||
|
|
||||||
|
private fun getOrCreateDecoder(context: Context, uri: Uri, requestKey: Pair<Uri, Int?>): BitmapRegionDecoder? {
|
||||||
|
poolLock.withLock {
|
||||||
|
var decoderRef = decoderPool.firstOrNull { it.requestKey == requestKey }
|
||||||
|
if (decoderRef == null) {
|
||||||
|
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
|
BitmapRegionDecoderCompat.newInstance(input)
|
||||||
|
}
|
||||||
|
if (newDecoder == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
decoderRef = DecoderRef(requestKey, newDecoder)
|
||||||
|
} else {
|
||||||
|
decoderPool.remove(decoderRef)
|
||||||
|
}
|
||||||
|
decoderPool.add(0, decoderRef)
|
||||||
|
while (decoderPool.size > DECODER_POOL_SIZE) {
|
||||||
|
decoderPool.removeAt(decoderPool.size - 1)
|
||||||
|
}
|
||||||
|
return decoderRef.decoder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,25 +6,25 @@ import android.graphics.Canvas
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
import com.caverock.androidsvg.PreserveAspectRatio
|
import com.caverock.androidsvg.PreserveAspectRatio
|
||||||
import com.caverock.androidsvg.RenderOptions
|
import com.caverock.androidsvg.RenderOptions
|
||||||
import com.caverock.androidsvg.SVG
|
import com.caverock.androidsvg.SVG
|
||||||
import com.caverock.androidsvg.SVGParseException
|
import com.caverock.androidsvg.SVGParseException
|
||||||
import deckers.thibault.aves.metadata.SVGParserBufferedInputStream
|
import deckers.thibault.aves.metadata.SVGParserBufferedInputStream
|
||||||
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
|
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
|
||||||
import deckers.thibault.aves.utils.MemoryUtils
|
import deckers.thibault.aves.utils.MemoryUtils
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
|
|
||||||
class SvgRegionFetcher internal constructor(
|
class SvgRegionFetcher internal constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
) {
|
) {
|
||||||
private var lastSvgRef: LastSvgRef? = null
|
fun fetch(
|
||||||
|
|
||||||
suspend fun fetch(
|
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
sizeBytes: Long?,
|
sizeBytes: Long?,
|
||||||
scale: Int,
|
scale: Int,
|
||||||
|
@ -39,32 +39,12 @@ class SvgRegionFetcher internal constructor(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentSvgRef = lastSvgRef
|
|
||||||
if (currentSvgRef != null && currentSvgRef.uri != uri) {
|
|
||||||
currentSvgRef = null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (currentSvgRef == null) {
|
val svg = getOrCreateDecoder(context, uri)
|
||||||
val newSvg = StorageUtils.openInputStream(context, uri)?.use { input ->
|
if (svg == null) {
|
||||||
try {
|
result.error("fetch-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
|
||||||
SVG.getFromInputStream(SVGParserBufferedInputStream(input))
|
return
|
||||||
} catch (ex: SVGParseException) {
|
|
||||||
result.error("fetch-parse", "failed to parse SVG for uri=$uri regionRect=$regionRect", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newSvg == null) {
|
|
||||||
result.error("fetch-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newSvg.normalizeSize()
|
|
||||||
currentSvgRef = LastSvgRef(uri, newSvg)
|
|
||||||
}
|
}
|
||||||
val svg = currentSvgRef.svg
|
|
||||||
lastSvgRef = currentSvgRef
|
|
||||||
|
|
||||||
// we scale the requested region accordingly to the viewbox size
|
// we scale the requested region accordingly to the viewbox size
|
||||||
val viewBox = svg.documentViewBox
|
val viewBox = svg.documentViewBox
|
||||||
|
@ -91,32 +71,65 @@ class SvgRegionFetcher internal constructor(
|
||||||
|
|
||||||
val targetBitmapWidth = regionRect.width()
|
val targetBitmapWidth = regionRect.width()
|
||||||
val targetBitmapHeight = regionRect.height()
|
val targetBitmapHeight = regionRect.height()
|
||||||
|
val canvasWidth = targetBitmapWidth + bleedX * 2
|
||||||
|
val canvasHeight = targetBitmapHeight + bleedY * 2
|
||||||
|
|
||||||
// use `Long` as rect size could be unexpectedly large and go beyond `Int` max
|
val config = PREFERRED_CONFIG
|
||||||
val targetBitmapSizeBytes: Long = ARGB_8888_BYTE_SIZE.toLong() * targetBitmapWidth * targetBitmapHeight
|
val pixelCount = canvasWidth * canvasHeight
|
||||||
|
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), config)
|
||||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||||
// decoding a region that large would yield an OOM when creating the bitmap
|
// decoding a region that large would yield an OOM when creating the bitmap
|
||||||
result.error("fetch-read-large-region", "SVG region too large for uri=$uri regionRect=$regionRect", null)
|
result.error("fetch-read-large-region", "SVG region too large for uri=$uri regionRect=$regionRect", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var bitmap = Bitmap.createBitmap(
|
var bitmap = createBitmap(canvasWidth, canvasHeight, config)
|
||||||
targetBitmapWidth + bleedX * 2,
|
|
||||||
targetBitmapHeight + bleedY * 2,
|
|
||||||
Bitmap.Config.ARGB_8888
|
|
||||||
)
|
|
||||||
val canvas = Canvas(bitmap)
|
val canvas = Canvas(bitmap)
|
||||||
svg.renderToCanvas(canvas, renderOptions)
|
svg.renderToCanvas(canvas, renderOptions)
|
||||||
|
|
||||||
bitmap = Bitmap.createBitmap(bitmap, bleedX, bleedY, targetBitmapWidth, targetBitmapHeight)
|
bitmap = Bitmap.createBitmap(bitmap, bleedX, bleedY, targetBitmapWidth, targetBitmapHeight)
|
||||||
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
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)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class LastSvgRef(
|
private data class DecoderRef(
|
||||||
val uri: Uri,
|
val uri: Uri,
|
||||||
val svg: SVG,
|
val decoder: SVG,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val PREFERRED_CONFIG = Bitmap.Config.ARGB_8888
|
||||||
|
private const val DECODER_POOL_SIZE = 3
|
||||||
|
private val decoderPool = ArrayList<DecoderRef>()
|
||||||
|
|
||||||
|
private val poolLock = ReentrantLock()
|
||||||
|
|
||||||
|
private fun getOrCreateDecoder(context: Context, uri: Uri): SVG? {
|
||||||
|
poolLock.withLock {
|
||||||
|
var decoderRef = decoderPool.firstOrNull { it.uri == uri }
|
||||||
|
if (decoderRef == null) {
|
||||||
|
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
|
SVG.getFromInputStream(SVGParserBufferedInputStream(input))
|
||||||
|
}
|
||||||
|
if (newDecoder == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
newDecoder.normalizeSize()
|
||||||
|
decoderRef = DecoderRef(uri, newDecoder)
|
||||||
|
} else {
|
||||||
|
decoderPool.remove(decoderRef)
|
||||||
|
}
|
||||||
|
decoderPool.add(0, decoderRef)
|
||||||
|
while (decoderPool.size > DECODER_POOL_SIZE) {
|
||||||
|
decoderPool.removeAt(decoderPool.size - 1)
|
||||||
|
}
|
||||||
|
return decoderRef.decoder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,19 +5,21 @@ import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import com.bumptech.glide.signature.ObjectKey
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
|
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
import deckers.thibault.aves.decoder.MultiPageImage
|
||||||
import deckers.thibault.aves.decoder.SvgImage
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.decoder.TiffImage
|
|
||||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.MimeTypes.SVG
|
import deckers.thibault.aves.utils.MimeTypes.SVG
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
|
@ -26,12 +28,14 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class ThumbnailFetcher internal constructor(
|
class ThumbnailFetcher internal constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
uri: String,
|
uri: String,
|
||||||
private val mimeType: String,
|
private val mimeType: String,
|
||||||
private val dateModifiedSecs: Long,
|
private val dateModifiedMillis: Long,
|
||||||
private val rotationDegrees: Int,
|
private val rotationDegrees: Int,
|
||||||
private val isFlipped: Boolean,
|
private val isFlipped: Boolean,
|
||||||
width: Int?,
|
width: Int?,
|
||||||
|
@ -41,7 +45,7 @@ class ThumbnailFetcher internal constructor(
|
||||||
private val quality: Int,
|
private val quality: Int,
|
||||||
private val result: MethodChannel.Result,
|
private val result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
private val uri: Uri = Uri.parse(uri)
|
private val uri: Uri = uri.toUri()
|
||||||
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
||||||
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
||||||
private val svgFetch = mimeType == SVG
|
private val svgFetch = mimeType == SVG
|
||||||
|
@ -49,7 +53,7 @@ class ThumbnailFetcher internal constructor(
|
||||||
private val multiPageFetch = pageId != null && MultiPageImage.isSupported(mimeType)
|
private val multiPageFetch = pageId != null && MultiPageImage.isSupported(mimeType)
|
||||||
private val customFetch = svgFetch || tiffFetch || multiPageFetch
|
private val customFetch = svgFetch || tiffFetch || multiPageFetch
|
||||||
|
|
||||||
suspend fun fetch() {
|
fun fetch() {
|
||||||
var bitmap: Bitmap? = null
|
var bitmap: Bitmap? = null
|
||||||
var exception: Exception? = null
|
var exception: Exception? = null
|
||||||
|
|
||||||
|
@ -79,7 +83,33 @@ class ThumbnailFetcher internal constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false, quality = quality))
|
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)
|
||||||
} else {
|
} else {
|
||||||
var errorDetails: String? = exception?.message
|
var errorDetails: String? = exception?.message
|
||||||
if (errorDetails?.isNotEmpty() == true) {
|
if (errorDetails?.isNotEmpty() == true) {
|
||||||
|
@ -120,30 +150,18 @@ class ThumbnailFetcher internal constructor(
|
||||||
// add signature to ignore cache for images which got modified but kept the same URI
|
// add signature to ignore cache for images which got modified but kept the same URI
|
||||||
var options = RequestOptions()
|
var options = RequestOptions()
|
||||||
.format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565)
|
.format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565)
|
||||||
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId"))
|
.signature(ObjectKey("$dateModifiedMillis-$rotationDegrees-$isFlipped-$width-$pageId"))
|
||||||
.override(width, height)
|
.override(width, height)
|
||||||
|
if (isVideo(mimeType)) {
|
||||||
val target = if (isVideo(mimeType)) {
|
|
||||||
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||||
Glide.with(context)
|
|
||||||
.asBitmap()
|
|
||||||
.apply(options)
|
|
||||||
.load(VideoThumbnail(context, uri))
|
|
||||||
.submit(width, height)
|
|
||||||
} else {
|
|
||||||
val model: Any = when {
|
|
||||||
svgFetch -> SvgImage(context, uri)
|
|
||||||
tiffFetch -> TiffImage(context, uri, pageId)
|
|
||||||
multiPageFetch -> MultiPageImage(context, uri, mimeType, pageId)
|
|
||||||
else -> StorageUtils.getGlideSafeUri(context, uri, mimeType)
|
|
||||||
}
|
|
||||||
Glide.with(context)
|
|
||||||
.asBitmap()
|
|
||||||
.apply(options)
|
|
||||||
.load(model)
|
|
||||||
.submit(width, height)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val target = Glide.with(context)
|
||||||
|
.asBitmap()
|
||||||
|
.apply(options)
|
||||||
|
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
|
||||||
|
.submit(width, height)
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
var bitmap = target.get()
|
var bitmap = target.get()
|
||||||
if (needRotationAfterGlide(mimeType, pageId)) {
|
if (needRotationAfterGlide(mimeType, pageId)) {
|
||||||
|
@ -154,4 +172,9 @@ class ThumbnailFetcher internal constructor(
|
||||||
Glide.with(context).clear(target)
|
Glide.with(context).clear(target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LOG_TAG = LogUtils.createTag<ThumbnailFetcher>()
|
||||||
|
private const val BITMAP_SIZE_DANGER_THRESHOLD = 20 * (1 shl 20) // MB
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,9 +1,10 @@
|
||||||
package deckers.thibault.aves.channel.calls.fetchers
|
package deckers.thibault.aves.channel.calls.fetchers
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import org.beyka.tiffbitmapfactory.DecodeArea
|
import org.beyka.tiffbitmapfactory.DecodeArea
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
|
@ -11,7 +12,7 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
class TiffRegionFetcher internal constructor(
|
class TiffRegionFetcher internal constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
) {
|
) {
|
||||||
suspend fun fetch(
|
fun fetch(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
page: Int,
|
page: Int,
|
||||||
sampleSize: Int,
|
sampleSize: Int,
|
||||||
|
@ -31,9 +32,10 @@ class TiffRegionFetcher internal constructor(
|
||||||
inSampleSize = sampleSize
|
inSampleSize = sampleSize
|
||||||
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
|
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
|
||||||
}
|
}
|
||||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
val bitmap: Bitmap? = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
if (bitmap != null) {
|
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = true)
|
||||||
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
if (bytes != null) {
|
||||||
|
result.success(bytes)
|
||||||
} else {
|
} else {
|
||||||
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
|
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,19 +77,30 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun supportsHdr(call: MethodCall, result: MethodChannel.Result) {
|
override fun supportsWideGamut(call: MethodCall, result: MethodChannel.Result) {
|
||||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity.getDisplayCompat()?.isHdr ?: false)
|
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity.resources.configuration.isScreenWideColorGamut)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setHdrColorMode(call: MethodCall, result: MethodChannel.Result) {
|
override fun supportsHdr(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val on = call.argument<Boolean>("on")
|
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity.resources.configuration.isScreenHdr)
|
||||||
if (on == null) {
|
}
|
||||||
result.error("setHdrColorMode-args", "missing arguments", null)
|
|
||||||
|
override fun setColorMode(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val wideColorGamut = call.argument<Boolean>("wideColorGamut")
|
||||||
|
val hdr = call.argument<Boolean>("hdr")
|
||||||
|
if (wideColorGamut == null || hdr == null) {
|
||||||
|
result.error("setColorMode-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
activity.window.colorMode = if (on) ActivityInfo.COLOR_MODE_HDR else ActivityInfo.COLOR_MODE_DEFAULT
|
activity.window.colorMode = if (hdr) {
|
||||||
|
ActivityInfo.COLOR_MODE_HDR
|
||||||
|
} else if (wideColorGamut) {
|
||||||
|
ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
|
||||||
|
} else {
|
||||||
|
ActivityInfo.COLOR_MODE_DEFAULT
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,11 +29,15 @@ class ServiceWindowHandler(service: Service) : WindowHandler(service) {
|
||||||
result.success(HashMap<String, Any>())
|
result.success(HashMap<String, Any>())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun supportsWideGamut(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
result.success(false)
|
||||||
|
}
|
||||||
|
|
||||||
override fun supportsHdr(call: MethodCall, result: MethodChannel.Result) {
|
override fun supportsHdr(call: MethodCall, result: MethodChannel.Result) {
|
||||||
result.success(false)
|
result.success(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setHdrColorMode(call: MethodCall, result: MethodChannel.Result) {
|
override fun setColorMode(call: MethodCall, result: MethodChannel.Result) {
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -18,8 +18,9 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
|
||||||
"requestOrientation" -> Coresult.safe(call, result, ::requestOrientation)
|
"requestOrientation" -> Coresult.safe(call, result, ::requestOrientation)
|
||||||
"isCutoutAware" -> Coresult.safe(call, result, ::isCutoutAware)
|
"isCutoutAware" -> Coresult.safe(call, result, ::isCutoutAware)
|
||||||
"getCutoutInsets" -> Coresult.safe(call, result, ::getCutoutInsets)
|
"getCutoutInsets" -> Coresult.safe(call, result, ::getCutoutInsets)
|
||||||
|
"supportsWideGamut" -> Coresult.safe(call, result, ::supportsWideGamut)
|
||||||
"supportsHdr" -> Coresult.safe(call, result, ::supportsHdr)
|
"supportsHdr" -> Coresult.safe(call, result, ::supportsHdr)
|
||||||
"setHdrColorMode" -> Coresult.safe(call, result, ::setHdrColorMode)
|
"setColorMode" -> Coresult.safe(call, result, ::setColorMode)
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -46,9 +47,11 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
|
||||||
|
|
||||||
abstract fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result)
|
abstract fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result)
|
||||||
|
|
||||||
|
abstract fun supportsWideGamut(call: MethodCall, result: MethodChannel.Result)
|
||||||
|
|
||||||
abstract fun supportsHdr(call: MethodCall, result: MethodChannel.Result)
|
abstract fun supportsHdr(call: MethodCall, result: MethodChannel.Result)
|
||||||
|
|
||||||
abstract fun setHdrColorMode(call: MethodCall, result: MethodChannel.Result)
|
abstract fun setColorMode(call: MethodCall, result: MethodChannel.Result)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<WindowHandler>()
|
private val LOG_TAG = LogUtils.createTag<WindowHandler>()
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
import deckers.thibault.aves.MainActivity
|
import deckers.thibault.aves.MainActivity
|
||||||
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
||||||
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
||||||
|
@ -48,6 +49,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
||||||
"requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() }
|
"requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() }
|
||||||
"createFile" -> ioScope.launch { createFile() }
|
"createFile" -> ioScope.launch { createFile() }
|
||||||
"openFile" -> ioScope.launch { openFile() }
|
"openFile" -> ioScope.launch { openFile() }
|
||||||
|
"copyFile" -> ioScope.launch { copyFile() }
|
||||||
"edit" -> edit()
|
"edit" -> edit()
|
||||||
"pickCollectionFilters" -> pickCollectionFilters()
|
"pickCollectionFilters" -> pickCollectionFilters()
|
||||||
else -> endOfStream()
|
else -> endOfStream()
|
||||||
|
@ -71,7 +73,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestMediaFileAccess() {
|
private fun requestMediaFileAccess() {
|
||||||
val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) Uri.parse(it) else null }
|
val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) it.toUri() else null }
|
||||||
val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null }
|
val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null }
|
||||||
if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
|
if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
|
||||||
error("requestMediaFileAccess-args", "missing arguments", null)
|
error("requestMediaFileAccess-args", "missing arguments", null)
|
||||||
|
@ -180,6 +182,49 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
||||||
safeStartActivityForStorageAccessResult(intent, MainActivity.OPEN_FILE_REQUEST, ::onGranted, ::onDenied)
|
safeStartActivityForStorageAccessResult(intent, MainActivity.OPEN_FILE_REQUEST, ::onGranted, ::onDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun copyFile() {
|
||||||
|
val name = args["name"] as String?
|
||||||
|
val mimeType = args["mimeType"] as String?
|
||||||
|
val sourceUri = (args["sourceUri"] as String?)?.toUri()
|
||||||
|
if (name == null || mimeType == null || sourceUri == null) {
|
||||||
|
error("copyFile-args", "missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onGranted(uri: Uri) {
|
||||||
|
ioScope.launch {
|
||||||
|
try {
|
||||||
|
StorageUtils.openInputStream(activity, sourceUri)?.use { input ->
|
||||||
|
// truncate is necessary when overwriting a longer file
|
||||||
|
activity.contentResolver.openOutputStream(uri, "wt")?.use { output ->
|
||||||
|
val buffer = ByteArray(BUFFER_SIZE)
|
||||||
|
var len: Int
|
||||||
|
while (input.read(buffer).also { len = it } != -1) {
|
||||||
|
output.write(buffer, 0, len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
success(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error("copyFile-write", "failed to copy file from sourceUri=$sourceUri to uri=$uri", e.message)
|
||||||
|
}
|
||||||
|
endOfStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDenied() {
|
||||||
|
success(null)
|
||||||
|
endOfStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
type = mimeType
|
||||||
|
putExtra(Intent.EXTRA_TITLE, name)
|
||||||
|
}
|
||||||
|
safeStartActivityForStorageAccessResult(intent, MainActivity.CREATE_FILE_REQUEST, ::onGranted, ::onDenied)
|
||||||
|
}
|
||||||
|
|
||||||
private fun edit() {
|
private fun edit() {
|
||||||
val uri = args["uri"] as String?
|
val uri = args["uri"] as String?
|
||||||
val mimeType = args["mimeType"] as String? // optional
|
val mimeType = args["mimeType"] as String? // optional
|
||||||
|
@ -190,7 +235,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_EDIT)
|
val intent = Intent(Intent.ACTION_EDIT)
|
||||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
.setDataAndType(AppAdapterHandler.getShareableUri(activity, Uri.parse(uri)), mimeType)
|
.setDataAndType(AppAdapterHandler.getShareableUri(activity, uri.toUri()), mimeType)
|
||||||
|
|
||||||
if (intent.resolveActivity(activity.packageManager) == null) {
|
if (intent.resolveActivity(activity.packageManager) == null) {
|
||||||
error("edit-resolve", "cannot resolve activity for this intent for uri=$uri mimeType=$mimeType", null)
|
error("edit-resolve", "cannot resolve activity for this intent for uri=$uri mimeType=$mimeType", null)
|
||||||
|
|
|
@ -5,15 +5,11 @@ import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import com.bumptech.glide.request.RequestOptions
|
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
|
||||||
import deckers.thibault.aves.decoder.TiffImage
|
|
||||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MemoryUtils
|
import deckers.thibault.aves.utils.MemoryUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
@ -28,6 +24,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
|
class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
|
||||||
|
@ -84,11 +81,13 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val decoded = arguments["decoded"] as Boolean
|
||||||
val mimeType = arguments["mimeType"] as String?
|
val mimeType = arguments["mimeType"] as String?
|
||||||
val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (arguments["uri"] as String?)?.toUri()
|
||||||
val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
|
val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
|
||||||
val rotationDegrees = arguments["rotationDegrees"] as Int
|
val rotationDegrees = arguments["rotationDegrees"] as Int
|
||||||
val isFlipped = arguments["isFlipped"] as Boolean
|
val isFlipped = arguments["isFlipped"] as Boolean
|
||||||
|
val isAnimated = arguments["isAnimated"] as Boolean
|
||||||
val pageId = arguments["pageId"] as Int?
|
val pageId = arguments["pageId"] as Int?
|
||||||
|
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
|
@ -97,19 +96,31 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVideo(mimeType)) {
|
if (canDecodeWithFlutter(mimeType, isAnimated) && !decoded) {
|
||||||
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 {
|
|
||||||
// to be decoded by Flutter
|
// to be decoded by Flutter
|
||||||
streamImageAsIs(uri, mimeType, sizeBytes)
|
streamOriginalEncodedBytes(uri, mimeType, sizeBytes)
|
||||||
|
} else if (isVideo(mimeType)) {
|
||||||
|
streamVideoByGlide(
|
||||||
|
uri = uri,
|
||||||
|
mimeType = mimeType,
|
||||||
|
sizeBytes = sizeBytes,
|
||||||
|
decoded = decoded,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
streamImageByGlide(
|
||||||
|
uri = uri,
|
||||||
|
pageId = pageId,
|
||||||
|
mimeType = mimeType,
|
||||||
|
sizeBytes = sizeBytes,
|
||||||
|
rotationDegrees = rotationDegrees,
|
||||||
|
isFlipped = isFlipped,
|
||||||
|
decoded = decoded,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
endOfStream()
|
endOfStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun streamImageAsIs(uri: Uri, mimeType: String, sizeBytes: Long?) {
|
private fun streamOriginalEncodedBytes(uri: Uri, mimeType: String, sizeBytes: Long?) {
|
||||||
if (!MemoryUtils.canAllocate(sizeBytes)) {
|
if (!MemoryUtils.canAllocate(sizeBytes)) {
|
||||||
error("streamImage-image-read-large", "original image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
error("streamImage-image-read-large", "original image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
||||||
return
|
return
|
||||||
|
@ -129,19 +140,12 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
sizeBytes: Long?,
|
sizeBytes: Long?,
|
||||||
rotationDegrees: Int,
|
rotationDegrees: Int,
|
||||||
isFlipped: Boolean,
|
isFlipped: Boolean,
|
||||||
|
decoded: Boolean,
|
||||||
) {
|
) {
|
||||||
val model: Any = if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
|
||||||
MultiPageImage(context, uri, mimeType, pageId)
|
|
||||||
} else if (mimeType == MimeTypes.TIFF) {
|
|
||||||
TiffImage(context, uri, pageId)
|
|
||||||
} else {
|
|
||||||
StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
val target = Glide.with(context)
|
val target = Glide.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(glideOptions)
|
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
||||||
.load(model)
|
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId, sizeBytes))
|
||||||
.submit()
|
.submit()
|
||||||
try {
|
try {
|
||||||
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||||
|
@ -149,9 +153,16 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
||||||
}
|
}
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
val bytes = bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false)
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
if (MemoryUtils.canAllocate(sizeBytes)) {
|
if (MemoryUtils.canAllocate(sizeBytes)) {
|
||||||
success(bytes)
|
streamBytes(ByteArrayInputStream(bytes))
|
||||||
} else {
|
} else {
|
||||||
error("streamImage-image-decode-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
error("streamImage-image-decode-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
||||||
}
|
}
|
||||||
|
@ -159,24 +170,31 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
|
error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e))
|
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri", toErrorDetails(e))
|
||||||
} finally {
|
} finally {
|
||||||
Glide.with(context).clear(target)
|
Glide.with(context).clear(target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?) {
|
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?, decoded: Boolean) {
|
||||||
val target = Glide.with(context)
|
val target = Glide.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(glideOptions)
|
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
||||||
.load(VideoThumbnail(context, uri))
|
.load(AvesAppGlideModule.getModel(context, uri, mimeType, null, sizeBytes))
|
||||||
.submit()
|
.submit()
|
||||||
try {
|
try {
|
||||||
val bitmap = withContext(Dispatchers.IO) { target.get() }
|
val bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
val bytes = bitmap.getBytes(canHaveAlpha = false, recycle = false)
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
if (MemoryUtils.canAllocate(sizeBytes)) {
|
if (MemoryUtils.canAllocate(sizeBytes)) {
|
||||||
success(bytes)
|
streamBytes(ByteArrayInputStream(bytes))
|
||||||
} else {
|
} else {
|
||||||
error("streamImage-video-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
error("streamImage-video-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
||||||
}
|
}
|
||||||
|
@ -218,11 +236,5 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
const val CHANNEL = "deckers.thibault/aves/media_byte_stream"
|
const val CHANNEL = "deckers.thibault/aves/media_byte_stream"
|
||||||
|
|
||||||
private const val BUFFER_SIZE = 2 shl 17 // 256kB
|
private const val BUFFER_SIZE = 2 shl 17 // 256kB
|
||||||
|
|
||||||
// request a fresh image with the highest quality format
|
|
||||||
private val glideOptions = RequestOptions()
|
|
||||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
||||||
.skipMemoryCache(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
package deckers.thibault.aves.channel.streams
|
package deckers.thibault.aves.channel.streams
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
|
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -141,7 +141,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
||||||
|
|
||||||
// assume same provider for all entries
|
// assume same provider for all entries
|
||||||
val firstEntry = entryMapList.first()
|
val firstEntry = entryMapList.first()
|
||||||
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(activity, it) }
|
val provider = (firstEntry["uri"] as String?)?.toUri()?.let { getProvider(activity, it) }
|
||||||
if (provider == null) {
|
if (provider == null) {
|
||||||
error("convert-provider", "failed to find provider for entry=$firstEntry", null)
|
error("convert-provider", "failed to find provider for entry=$firstEntry", null)
|
||||||
return
|
return
|
||||||
|
|
|
@ -31,9 +31,15 @@ class MediaStoreChangeStreamHandler(private val context: Context) : EventChannel
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Log.i(LOG_TAG, "start listening to Media Store")
|
Log.i(LOG_TAG, "start listening to Media Store")
|
||||||
context.contentResolver.apply {
|
try {
|
||||||
registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
context.contentResolver.apply {
|
||||||
registerContentObserver(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,12 +19,13 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
||||||
private lateinit var eventSink: EventSink
|
private lateinit var eventSink: EventSink
|
||||||
private lateinit var handler: Handler
|
private lateinit var handler: Handler
|
||||||
|
|
||||||
private var knownEntries: Map<Long?, Int?>? = null
|
// knownEntries: map of contentId -> dateModifiedMillis
|
||||||
|
private var knownEntries: Map<Long?, Long?>? = null
|
||||||
private var directory: String? = null
|
private var directory: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (arguments is Map<*, *>) {
|
if (arguments is Map<*, *>) {
|
||||||
knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to it.value as Int? }?.toMap()
|
knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to (it.value as Number?)?.toLong() }?.toMap()
|
||||||
directory = arguments["directory"] as String?
|
directory = arguments["directory"] as String?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.ViewConfiguration
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
@ -21,6 +22,7 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
||||||
private val contentObserver = object : ContentObserver(null) {
|
private val contentObserver = object : ContentObserver(null) {
|
||||||
private var accelerometerRotation: Int = 0
|
private var accelerometerRotation: Int = 0
|
||||||
private var transitionAnimationScale: Float = 1f
|
private var transitionAnimationScale: Float = 1f
|
||||||
|
private var longPressTimeoutMillis: Int = 0
|
||||||
|
|
||||||
init {
|
init {
|
||||||
update()
|
update()
|
||||||
|
@ -36,6 +38,7 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
||||||
hashMapOf(
|
hashMapOf(
|
||||||
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
|
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
|
||||||
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
|
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
|
||||||
|
KEY_LONG_PRESS_TIMEOUT_MILLIS to longPressTimeoutMillis,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -54,6 +57,11 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
||||||
transitionAnimationScale = newTransitionAnimationScale
|
transitionAnimationScale = newTransitionAnimationScale
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
val newLongPressTimeout = ViewConfiguration.getLongPressTimeout()
|
||||||
|
if (longPressTimeoutMillis != newLongPressTimeout) {
|
||||||
|
longPressTimeoutMillis = newLongPressTimeout
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
||||||
}
|
}
|
||||||
|
@ -93,5 +101,8 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<SettingsChangeStreamHandler>()
|
private val LOG_TAG = LogUtils.createTag<SettingsChangeStreamHandler>()
|
||||||
const val CHANNEL = "deckers.thibault/aves/settings_change"
|
const val CHANNEL = "deckers.thibault/aves/settings_change"
|
||||||
|
|
||||||
|
// cf `Settings.Secure.LONG_PRESS_TIMEOUT`
|
||||||
|
const val KEY_LONG_PRESS_TIMEOUT_MILLIS = "long_press_timeout"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,14 +1,30 @@
|
||||||
package deckers.thibault.aves.decoder
|
package deckers.thibault.aves.decoder
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.text.format.Formatter
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.GlideBuilder
|
import com.bumptech.glide.GlideBuilder
|
||||||
import com.bumptech.glide.Registry
|
import com.bumptech.glide.Registry
|
||||||
import com.bumptech.glide.annotation.GlideModule
|
import com.bumptech.glide.annotation.GlideModule
|
||||||
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.load.ImageHeaderParser
|
import com.bumptech.glide.load.ImageHeaderParser
|
||||||
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPoolAdapter
|
||||||
|
import com.bumptech.glide.load.engine.bitmap_recycle.LruArrayPool
|
||||||
|
import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool
|
||||||
|
import com.bumptech.glide.load.engine.cache.DiskCache
|
||||||
|
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
|
||||||
|
import com.bumptech.glide.load.engine.cache.LruResourceCache
|
||||||
|
import com.bumptech.glide.load.engine.cache.MemorySizeCalculator
|
||||||
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
|
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
|
||||||
import com.bumptech.glide.module.AppGlideModule
|
import com.bumptech.glide.module.AppGlideModule
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.compatRemoveIf
|
import deckers.thibault.aves.utils.compatRemoveIf
|
||||||
|
|
||||||
@GlideModule
|
@GlideModule
|
||||||
|
@ -16,6 +32,30 @@ class AvesAppGlideModule : AppGlideModule() {
|
||||||
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
||||||
// hide noisy warning (e.g. for images that can't be decoded)
|
// hide noisy warning (e.g. for images that can't be decoded)
|
||||||
builder.setLogLevel(Log.ERROR)
|
builder.setLogLevel(Log.ERROR)
|
||||||
|
|
||||||
|
// sizing
|
||||||
|
val memorySizeCalculator = MemorySizeCalculator.Builder(context).build()
|
||||||
|
builder.setMemorySizeCalculator(memorySizeCalculator)
|
||||||
|
val size: Int = memorySizeCalculator.bitmapPoolSize
|
||||||
|
if (size > 0) {
|
||||||
|
builder.setBitmapPool(LruBitmapPool(size.toLong()))
|
||||||
|
} else {
|
||||||
|
builder.setBitmapPool(BitmapPoolAdapter())
|
||||||
|
}
|
||||||
|
builder.setArrayPool(LruArrayPool(memorySizeCalculator.arrayPoolSizeInBytes))
|
||||||
|
builder.setMemoryCache(LruResourceCache(memorySizeCalculator.memoryCacheSize.toLong()))
|
||||||
|
|
||||||
|
val diskCacheSize = DiskCache.Factory.DEFAULT_DISK_CACHE_SIZE
|
||||||
|
val internalCacheDiskCacheFactory = InternalCacheDiskCacheFactory(context, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR, diskCacheSize.toLong())
|
||||||
|
builder.setDiskCache(internalCacheDiskCacheFactory)
|
||||||
|
|
||||||
|
fun toMb(bytes: Int) = Formatter.formatFileSize(context, bytes.toLong())
|
||||||
|
Log.d(
|
||||||
|
LOG_TAG, "Glide disk cache size=${toMb(diskCacheSize)}" +
|
||||||
|
", memory cache size=${toMb(memorySizeCalculator.memoryCacheSize)}" +
|
||||||
|
", bitmap pool size=${toMb(memorySizeCalculator.bitmapPoolSize)}" +
|
||||||
|
", array pool size=${toMb(memorySizeCalculator.arrayPoolSizeInBytes)}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||||
|
@ -25,4 +65,28 @@ class AvesAppGlideModule : AppGlideModule() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isManifestParsingEnabled(): Boolean = false
|
override fun isManifestParsingEnabled(): Boolean = false
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LOG_TAG = LogUtils.createTag<AvesAppGlideModule>()
|
||||||
|
|
||||||
|
// request a fresh image with the highest quality format
|
||||||
|
val uncachedFullImageOptions = RequestOptions()
|
||||||
|
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
|
.skipMemoryCache(true)
|
||||||
|
|
||||||
|
fun getModel(context: Context, uri: Uri, mimeType: String, pageId: Int?, sizeBytes: Long? = null): Any {
|
||||||
|
return if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
||||||
|
MultiPageImage(context, uri, mimeType, pageId)
|
||||||
|
} else if (mimeType == MimeTypes.TIFF) {
|
||||||
|
TiffImage(context, uri, pageId)
|
||||||
|
} else if (mimeType == MimeTypes.SVG) {
|
||||||
|
SvgImage(context, uri)
|
||||||
|
} else if (isVideo(mimeType)) {
|
||||||
|
VideoThumbnail(context, uri)
|
||||||
|
} else {
|
||||||
|
StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.Priority
|
import com.bumptech.glide.Priority
|
||||||
import com.bumptech.glide.Registry
|
import com.bumptech.glide.Registry
|
||||||
|
@ -68,7 +69,7 @@ internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int)
|
||||||
bitmapWidth = width
|
bitmapWidth = width
|
||||||
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
|
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
|
||||||
}
|
}
|
||||||
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
|
val bitmap = createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
|
||||||
|
|
||||||
val canvas = Canvas(bitmap)
|
val canvas = Canvas(bitmap)
|
||||||
svg.renderToCanvas(canvas)
|
svg.renderToCanvas(canvas)
|
||||||
|
|
|
@ -3,6 +3,7 @@ package deckers.thibault.aves.decoder
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.graphics.scale
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.Priority
|
import com.bumptech.glide.Priority
|
||||||
import com.bumptech.glide.Registry
|
import com.bumptech.glide.Registry
|
||||||
|
@ -82,7 +83,9 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
|
||||||
inSampleSize = sampleSize
|
inSampleSize = sampleSize
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
val bitmap: Bitmap? = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
|
// calling `TiffBitmapFactory.closeFd(fd)` after decoding yields a segmentation fault
|
||||||
|
|
||||||
if (bitmap == null) {
|
if (bitmap == null) {
|
||||||
callback.onLoadFailed(Exception("Decoding full TIFF yielded null bitmap"))
|
callback.onLoadFailed(Exception("Decoding full TIFF yielded null bitmap"))
|
||||||
} else if (customSize) {
|
} else if (customSize) {
|
||||||
|
@ -96,7 +99,7 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
|
||||||
dstWidth = width
|
dstWidth = width
|
||||||
dstHeight = (width / aspectRatio).toInt()
|
dstHeight = (width / aspectRatio).toInt()
|
||||||
}
|
}
|
||||||
callback.onDataReady(Bitmap.createScaledBitmap(bitmap, dstWidth, dstHeight, true))
|
callback.onDataReady(bitmap.scale(dstWidth, dstHeight))
|
||||||
} else {
|
} else {
|
||||||
callback.onDataReady(bitmap)
|
callback.onDataReady(bitmap)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package deckers.thibault.aves.decoder
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
@ -20,7 +21,6 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||||
import com.bumptech.glide.module.LibraryGlideModule
|
import com.bumptech.glide.module.LibraryGlideModule
|
||||||
import com.bumptech.glide.signature.ObjectKey
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
|
||||||
import deckers.thibault.aves.utils.MemoryUtils
|
import deckers.thibault.aves.utils.MemoryUtils
|
||||||
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
|
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
@ -28,45 +28,54 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.IOException
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@GlideModule
|
@GlideModule
|
||||||
class VideoThumbnailGlideModule : LibraryGlideModule() {
|
class VideoThumbnailGlideModule : LibraryGlideModule() {
|
||||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||||
registry.append(VideoThumbnail::class.java, InputStream::class.java, VideoThumbnailLoader.Factory())
|
registry.append(VideoThumbnail::class.java, Bitmap::class.java, VideoThumbnailLoader.Factory())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class VideoThumbnail(val context: Context, val uri: Uri)
|
class VideoThumbnail(val context: Context, val uri: Uri)
|
||||||
|
|
||||||
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
|
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, Bitmap> {
|
||||||
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
|
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
|
||||||
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
|
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handles(model: VideoThumbnail): Boolean = true
|
override fun handles(model: VideoThumbnail): Boolean = true
|
||||||
|
|
||||||
internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> {
|
internal class Factory : ModelLoaderFactory<VideoThumbnail, Bitmap> {
|
||||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader()
|
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, Bitmap> = VideoThumbnailLoader()
|
||||||
|
|
||||||
override fun teardown() {}
|
override fun teardown() {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<InputStream> {
|
internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<Bitmap> {
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
|
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
|
||||||
ioScope.launch {
|
ioScope.launch {
|
||||||
val retriever = openMetadataRetriever(model.context, model.uri)
|
val retriever = openMetadataRetriever(model.context, model.uri)
|
||||||
if (retriever == null) {
|
if (retriever == null) {
|
||||||
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))
|
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
var bytes = retriever.embeddedPicture
|
var bitmap: Bitmap? = null
|
||||||
if (bytes == null) {
|
|
||||||
|
retriever.embeddedPicture?.let { bytes ->
|
||||||
|
try {
|
||||||
|
bitmap = BitmapFactory.decodeStream(ByteArrayInputStream(bytes))
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitmap == null) {
|
||||||
// there is no consistent strategy across devices to match
|
// there is no consistent strategy across devices to match
|
||||||
// the thumbnails returned by the content resolver / Media Store
|
// the thumbnails returned by the content resolver / Media Store
|
||||||
// so we derive one in an arbitrary way
|
// so we derive one in an arbitrary way
|
||||||
|
@ -111,8 +120,9 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
}
|
}
|
||||||
|
|
||||||
// the returned frame is already rotated according to the video metadata
|
// the returned frame is already rotated according to the video metadata
|
||||||
val frame = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
bitmap = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
val targetBitmapSizeBytes: Long = FORMAT_BYTE_SIZE.toLong() * dstWidth * dstHeight
|
val pixelCount = dstWidth * dstHeight
|
||||||
|
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
|
||||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||||
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the scaled frame at $dstWidth x $dstHeight")
|
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the scaled frame at $dstWidth x $dstHeight")
|
||||||
}
|
}
|
||||||
|
@ -122,7 +132,8 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight)
|
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val targetBitmapSizeBytes: Long = (FORMAT_BYTE_SIZE.toLong() * videoWidth * videoHeight).toLong()
|
val pixelCount = videoWidth * videoHeight
|
||||||
|
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
|
||||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||||
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the full frame at $videoWidth x $videoHeight")
|
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the full frame at $videoWidth x $videoHeight")
|
||||||
}
|
}
|
||||||
|
@ -132,13 +143,12 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
retriever.getFrameAtTime(timeMicros, option)
|
retriever.getFrameAtTime(timeMicros, option)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bytes = frame?.getBytes(canHaveAlpha = false, recycle = false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bytes != null) {
|
if (bitmap == null) {
|
||||||
callback.onDataReady(ByteArrayInputStream(bytes))
|
callback.onLoadFailed(Exception("failed to get embedded picture or any frame for uri=${model.uri}"))
|
||||||
} else {
|
} else {
|
||||||
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
|
callback.onDataReady(bitmap)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
callback.onLoadFailed(e)
|
callback.onLoadFailed(e)
|
||||||
|
@ -151,8 +161,14 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.P)
|
@RequiresApi(Build.VERSION_CODES.P)
|
||||||
private fun getBitmapParams() = MediaMetadataRetriever.BitmapParams().apply {
|
private fun getBitmapParams(): MediaMetadataRetriever.BitmapParams {
|
||||||
preferredConfig = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
val params = MediaMetadataRetriever.BitmapParams()
|
||||||
|
params.preferredConfig = this.getPreferredConfig()
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPreferredConfig(): Bitmap.Config {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
// improved precision with the same memory cost as `ARGB_8888` (4 bytes per pixel)
|
// improved precision with the same memory cost as `ARGB_8888` (4 bytes per pixel)
|
||||||
// for wide-gamut and HDR content which does not require alpha blending
|
// for wide-gamut and HDR content which does not require alpha blending
|
||||||
Bitmap.Config.RGBA_1010102
|
Bitmap.Config.RGBA_1010102
|
||||||
|
@ -167,12 +183,7 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
// cannot cancel
|
// cannot cancel
|
||||||
override fun cancel() {}
|
override fun cancel() {}
|
||||||
|
|
||||||
override fun getDataClass(): Class<InputStream> = InputStream::class.java
|
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
|
||||||
|
|
||||||
override fun getDataSource(): DataSource = DataSource.LOCAL
|
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||||
|
|
||||||
companion object {
|
|
||||||
// same for either `ARGB_8888` or `RGBA_1010102`
|
|
||||||
private const val FORMAT_BYTE_SIZE = BitmapUtils.ARGB_8888_BYTE_SIZE
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
package deckers.thibault.aves.metadata
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
import com.drew.lang.Rational
|
import com.drew.lang.Rational
|
||||||
import com.drew.metadata.Directory
|
import com.drew.metadata.Directory
|
||||||
import com.drew.metadata.exif.ExifDirectoryBase
|
import com.drew.metadata.exif.ExifDirectoryBase
|
||||||
|
@ -19,6 +18,7 @@ import java.util.Locale
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
|
||||||
object ExifInterfaceHelper {
|
object ExifInterfaceHelper {
|
||||||
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
||||||
|
|
|
@ -111,20 +111,25 @@ object MediaMetadataRetrieverHelper {
|
||||||
// format
|
// format
|
||||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
|
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
|
||||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH,
|
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH,
|
||||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
|
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
|
||||||
val bitrate = value.toLongOrNull() ?: 0
|
val bitrate = value.toLongOrNull() ?: 0
|
||||||
if (bitrate > 0) formatBitrate(bitrate) else null
|
if (bitrate > 0) formatBitrate(bitrate) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
|
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
|
||||||
val framerate = value.toDoubleOrNull() ?: 0.0
|
val framerate = value.toDoubleOrNull() ?: 0.0
|
||||||
if (framerate > 0.0) "$framerate" else null
|
if (framerate > 0.0) "$framerate" else null
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_DURATION -> {
|
MediaMetadataRetriever.METADATA_KEY_DURATION -> {
|
||||||
val dateMillis = value.toLongOrNull() ?: 0
|
val dateMillis = value.toLongOrNull() ?: 0
|
||||||
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
|
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
|
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
|
||||||
when (value.toIntOrNull()) {
|
when (value.toIntOrNull()) {
|
||||||
MediaFormat.COLOR_RANGE_FULL -> "Full"
|
MediaFormat.COLOR_RANGE_FULL -> "Full"
|
||||||
|
@ -132,6 +137,7 @@ object MediaMetadataRetrieverHelper {
|
||||||
else -> value
|
else -> value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
|
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
|
||||||
when (value.toIntOrNull()) {
|
when (value.toIntOrNull()) {
|
||||||
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
|
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
|
||||||
|
@ -141,6 +147,7 @@ object MediaMetadataRetrieverHelper {
|
||||||
else -> value
|
else -> value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
|
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
|
||||||
when (value.toIntOrNull()) {
|
when (value.toIntOrNull()) {
|
||||||
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
|
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
|
||||||
|
@ -154,6 +161,7 @@ object MediaMetadataRetrieverHelper {
|
||||||
MediaMetadataRetriever.METADATA_KEY_COMPILATION,
|
MediaMetadataRetriever.METADATA_KEY_COMPILATION,
|
||||||
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
|
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
|
||||||
MediaMetadataRetriever.METADATA_KEY_YEAR -> if (value != "0") value else null
|
MediaMetadataRetriever.METADATA_KEY_YEAR -> if (value != "0") value else null
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER -> if (value != "0/0") value else null
|
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER -> if (value != "0/0") value else null
|
||||||
MediaMetadataRetriever.METADATA_KEY_DATE -> {
|
MediaMetadataRetriever.METADATA_KEY_DATE -> {
|
||||||
val dateMillis = Metadata.parseVideoMetadataDate(value)
|
val dateMillis = Metadata.parseVideoMetadataDate(value)
|
||||||
|
@ -168,4 +176,12 @@ object MediaMetadataRetrieverHelper {
|
||||||
}?.let { save(it) }
|
}?.let { save(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
||||||
|
if (this.containsKey(key)) save(this.getInteger(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
||||||
|
if (this.containsKey(key)) save(this.getLong(key))
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -9,7 +9,11 @@ import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.toByteArray
|
import deckers.thibault.aves.utils.toByteArray
|
||||||
import deckers.thibault.aves.utils.toHex
|
import deckers.thibault.aves.utils.toHex
|
||||||
import org.mp4parser.*
|
import org.mp4parser.BasicContainer
|
||||||
|
import org.mp4parser.Box
|
||||||
|
import org.mp4parser.Container
|
||||||
|
import org.mp4parser.IsoFile
|
||||||
|
import org.mp4parser.PropertyBoxParserImpl
|
||||||
import org.mp4parser.boxes.UnknownBox
|
import org.mp4parser.boxes.UnknownBox
|
||||||
import org.mp4parser.boxes.UserBox
|
import org.mp4parser.boxes.UserBox
|
||||||
import org.mp4parser.boxes.apple.AppleCoverBox
|
import org.mp4parser.boxes.apple.AppleCoverBox
|
||||||
|
@ -17,8 +21,18 @@ import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox
|
||||||
import org.mp4parser.boxes.apple.AppleItemListBox
|
import org.mp4parser.boxes.apple.AppleItemListBox
|
||||||
import org.mp4parser.boxes.apple.AppleVariableSignedIntegerBox
|
import org.mp4parser.boxes.apple.AppleVariableSignedIntegerBox
|
||||||
import org.mp4parser.boxes.apple.Utf8AppleDataBox
|
import org.mp4parser.boxes.apple.Utf8AppleDataBox
|
||||||
import org.mp4parser.boxes.iso14496.part12.*
|
import org.mp4parser.boxes.iso14496.part12.FreeBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.HandlerBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.MediaDataBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.MetaBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.MovieBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.MovieFragmentBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.SampleTableBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.SegmentIndexBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.TrackHeaderBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.UserDataBox
|
||||||
import org.mp4parser.boxes.threegpp.ts26244.AuthorBox
|
import org.mp4parser.boxes.threegpp.ts26244.AuthorBox
|
||||||
|
import org.mp4parser.boxes.threegpp.ts26244.LocationInformationBox
|
||||||
import org.mp4parser.support.AbstractBox
|
import org.mp4parser.support.AbstractBox
|
||||||
import org.mp4parser.support.Matrix
|
import org.mp4parser.support.Matrix
|
||||||
import org.mp4parser.tools.Path
|
import org.mp4parser.tools.Path
|
||||||
|
@ -32,6 +46,15 @@ object Mp4ParserHelper {
|
||||||
// arbitrary size to detect boxes that may yield an OOM
|
// arbitrary size to detect boxes that may yield an OOM
|
||||||
private const val BOX_SIZE_DANGER_THRESHOLD = 3 * (1 shl 20) // MB
|
private const val BOX_SIZE_DANGER_THRESHOLD = 3 * (1 shl 20) // MB
|
||||||
|
|
||||||
|
const val SAMSUNG_MAKERNOTE_BOX_TYPE = "sefd"
|
||||||
|
const val SEFD_MOTION_PHOTO_NAME = "MotionPhoto_Data"
|
||||||
|
|
||||||
|
private val largerTypeWhitelist = listOf(
|
||||||
|
// HEIC motion photo may contain Samsung maker notes in `sefd` box,
|
||||||
|
// including a video larger than the danger threshold
|
||||||
|
SAMSUNG_MAKERNOTE_BOX_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List<Pair<Long, ByteArray>> {
|
fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List<Pair<Long, ByteArray>> {
|
||||||
// we can skip uninteresting boxes with a seekable data source
|
// we can skip uninteresting boxes with a seekable data source
|
||||||
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||||
|
@ -120,6 +143,35 @@ object Mp4ParserHelper {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// returns the offset and data of the Samsung maker notes box
|
||||||
|
fun getSamsungSefd(context: Context, uri: Uri): Pair<Long, ByteArray>? {
|
||||||
|
try {
|
||||||
|
// we can skip uninteresting boxes with a seekable data source
|
||||||
|
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||||
|
pfd.use {
|
||||||
|
FileInputStream(it.fileDescriptor).use { stream ->
|
||||||
|
stream.channel.use { channel ->
|
||||||
|
IsoFile(channel, metadataBoxParser()).use { isoFile ->
|
||||||
|
var offset = 0L
|
||||||
|
for (box in isoFile.boxes) {
|
||||||
|
if (box is UnknownBox && box.type == SAMSUNG_MAKERNOTE_BOX_TYPE) {
|
||||||
|
if (!box.isParsed) {
|
||||||
|
box.parseDetails()
|
||||||
|
}
|
||||||
|
return Pair(offset + 8, box.data.toByteArray()) // skip 8 bytes for box header
|
||||||
|
}
|
||||||
|
offset += box.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to read sefd box", e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// extensions
|
// extensions
|
||||||
|
|
||||||
fun IsoFile.updateLocation(locationIso6709: String?) {
|
fun IsoFile.updateLocation(locationIso6709: String?) {
|
||||||
|
@ -259,18 +311,18 @@ object Mp4ParserHelper {
|
||||||
)
|
)
|
||||||
setBoxSkipper { type, size ->
|
setBoxSkipper { type, size ->
|
||||||
if (skippedTypes.contains(type)) return@setBoxSkipper true
|
if (skippedTypes.contains(type)) return@setBoxSkipper true
|
||||||
if (size > BOX_SIZE_DANGER_THRESHOLD) throw Exception("box (type=$type size=$size) is too large")
|
if (size > BOX_SIZE_DANGER_THRESHOLD && !largerTypeWhitelist.contains(type)) throw Exception("box (type=$type size=$size) is too large")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUserData(
|
fun getUserDataBox(
|
||||||
context: Context,
|
context: Context,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
): MutableMap<String, String> {
|
): UserDataBox? {
|
||||||
val fields = HashMap<String, String>()
|
if (mimeType != MimeTypes.MP4) return null
|
||||||
if (mimeType != MimeTypes.MP4) return fields
|
|
||||||
try {
|
try {
|
||||||
// we can skip uninteresting boxes with a seekable data source
|
// we can skip uninteresting boxes with a seekable data source
|
||||||
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||||
|
@ -279,10 +331,7 @@ object Mp4ParserHelper {
|
||||||
stream.channel.use { channel ->
|
stream.channel.use { channel ->
|
||||||
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
|
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
|
||||||
IsoFile(channel, metadataBoxParser()).use { isoFile ->
|
IsoFile(channel, metadataBoxParser()).use { isoFile ->
|
||||||
val userDataBox = Path.getPath<UserDataBox>(isoFile.movieBox, UserDataBox.TYPE)
|
return Path.getPath(isoFile.movieBox, UserDataBox.TYPE)
|
||||||
if (userDataBox != null) {
|
|
||||||
fields.putAll(extractBoxFields(userDataBox))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -292,10 +341,10 @@ object Mp4ParserHelper {
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to get User Data box by MP4 parser for mimeType=$mimeType uri=$uri", e)
|
Log.w(LOG_TAG, "failed to get User Data box by MP4 parser for mimeType=$mimeType uri=$uri", e)
|
||||||
}
|
}
|
||||||
return fields
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractBoxFields(container: Container): HashMap<String, String> {
|
fun extractBoxFields(container: Container): HashMap<String, String> {
|
||||||
val fields = HashMap<String, String>()
|
val fields = HashMap<String, String>()
|
||||||
for (box in container.boxes) {
|
for (box in container.boxes) {
|
||||||
if (box is AbstractBox && !box.isParsed) {
|
if (box is AbstractBox && !box.isParsed) {
|
||||||
|
@ -309,9 +358,20 @@ object Mp4ParserHelper {
|
||||||
is AppleGPSCoordinatesBox -> fields[key] = box.value
|
is AppleGPSCoordinatesBox -> fields[key] = box.value
|
||||||
is AppleItemListBox -> fields.putAll(extractBoxFields(box))
|
is AppleItemListBox -> fields.putAll(extractBoxFields(box))
|
||||||
is AppleVariableSignedIntegerBox -> fields[key] = box.value.toString()
|
is AppleVariableSignedIntegerBox -> fields[key] = box.value.toString()
|
||||||
is Utf8AppleDataBox -> fields[key] = box.value
|
|
||||||
|
|
||||||
is HandlerBox -> {}
|
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 MetaBox -> {
|
is MetaBox -> {
|
||||||
val handlerBox = Path.getPath<HandlerBox>(box, HandlerBox.TYPE).apply { parseDetails() }
|
val handlerBox = Path.getPath<HandlerBox>(box, HandlerBox.TYPE).apply { parseDetails() }
|
||||||
when (val handlerType = handlerBox?.handlerType ?: MetaBox.TYPE) {
|
when (val handlerType = handlerBox?.handlerType ?: MetaBox.TYPE) {
|
||||||
|
@ -336,6 +396,8 @@ object Mp4ParserHelper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is Utf8AppleDataBox -> fields[key] = box.value
|
||||||
|
|
||||||
else -> fields[key] = box.toString()
|
else -> fields[key] = box.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -348,6 +410,7 @@ object Mp4ParserHelper {
|
||||||
"catg" -> "Category"
|
"catg" -> "Category"
|
||||||
"covr" -> "Cover Art"
|
"covr" -> "Cover Art"
|
||||||
"keyw" -> "Keyword"
|
"keyw" -> "Keyword"
|
||||||
|
"loci" -> "Location"
|
||||||
"mcvr" -> "Preview Image"
|
"mcvr" -> "Preview Image"
|
||||||
"pcst" -> "Podcast"
|
"pcst" -> "Podcast"
|
||||||
"SDLN" -> "Play Mode"
|
"SDLN" -> "Play Mode"
|
||||||
|
|
|
@ -15,6 +15,8 @@ import com.drew.metadata.exif.ExifDirectoryBase
|
||||||
import com.drew.metadata.exif.ExifIFD0Directory
|
import com.drew.metadata.exif.ExifIFD0Directory
|
||||||
import com.drew.metadata.xmp.XmpDirectory
|
import com.drew.metadata.xmp.XmpDirectory
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
||||||
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
|
||||||
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
|
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
|
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
|
||||||
|
@ -35,6 +37,8 @@ import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
object MultiPage {
|
object MultiPage {
|
||||||
private val LOG_TAG = LogUtils.createTag<MultiPage>()
|
private val LOG_TAG = LogUtils.createTag<MultiPage>()
|
||||||
|
|
||||||
|
// TODO TLAD more generic support, (e.g. 0x00000014 + `ftyp` + `qt `)
|
||||||
|
// atom length (variable, e.g. `0x00000018`) + atom type (`ftyp`) + type (variable, e.g. `mp42`, `qt`)
|
||||||
private val heicMotionPhotoVideoStartIndicator = byteArrayOf(0x00, 0x00, 0x00, 0x18) + "ftypmp42".toByteArray()
|
private val heicMotionPhotoVideoStartIndicator = byteArrayOf(0x00, 0x00, 0x00, 0x18) + "ftypmp42".toByteArray()
|
||||||
|
|
||||||
// page info
|
// page info
|
||||||
|
@ -47,14 +51,6 @@ object MultiPage {
|
||||||
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
|
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
|
||||||
|
|
||||||
fun getHeicTracks(context: Context, uri: Uri): ArrayList<FieldMap> {
|
fun getHeicTracks(context: Context, uri: Uri): ArrayList<FieldMap> {
|
||||||
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
|
||||||
if (this.containsKey(key)) save(this.getInteger(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
|
||||||
if (this.containsKey(key)) save(this.getLong(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
val tracks = ArrayList<FieldMap>()
|
val tracks = ArrayList<FieldMap>()
|
||||||
val extractor = MediaExtractor()
|
val extractor = MediaExtractor()
|
||||||
extractor.setDataSource(context, uri, null)
|
extractor.setDataSource(context, uri, null)
|
||||||
|
@ -90,6 +86,26 @@ object MultiPage {
|
||||||
return tracks
|
return tracks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isHeicSefdMotionPhoto(context: Context, uri: Uri): Boolean {
|
||||||
|
return getHeicSefdMotionPhotoVideoSizing(context, uri) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getHeicSefdMotionPhotoVideoSizing(context: Context, uri: Uri): Pair<Long, Long>? {
|
||||||
|
Mp4ParserHelper.getSamsungSefd(context, uri)?.let { (sefdOffset, sefdBytes) ->
|
||||||
|
// we could properly parse each tag until we find the "embedded video" tag (0x0a30)
|
||||||
|
// but it seems that decoding the SEFT trailer is necessary for this,
|
||||||
|
// so we simply search for the "MotionPhoto_Data" sequence instead
|
||||||
|
val name = Mp4ParserHelper.SEFD_MOTION_PHOTO_NAME
|
||||||
|
val index = sefdBytes.indexOfBytes(name.toByteArray(Charsets.UTF_8))
|
||||||
|
if (index != -1) {
|
||||||
|
val videoOffset = sefdOffset + index + name.length
|
||||||
|
val videoSize = sefdBytes.size - (videoOffset - sefdOffset)
|
||||||
|
return Pair(videoOffset, videoSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private fun getJpegMpfPrimaryRotation(context: Context, uri: Uri, sizeBytes: Long): Int {
|
private fun getJpegMpfPrimaryRotation(context: Context, uri: Uri, sizeBytes: Long): Int {
|
||||||
val mimeType = MimeTypes.JPEG
|
val mimeType = MimeTypes.JPEG
|
||||||
var rotationDegrees = 0
|
var rotationDegrees = 0
|
||||||
|
@ -250,70 +266,39 @@ object MultiPage {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList<FieldMap> {
|
fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList<FieldMap> {
|
||||||
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
|
||||||
if (this.containsKey(key)) save(this.getInteger(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
|
||||||
if (this.containsKey(key)) save(this.getLong(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
val pages = ArrayList<FieldMap>()
|
val pages = ArrayList<FieldMap>()
|
||||||
val extractor = MediaExtractor()
|
getMotionPhotoVideoInfo(context, uri, mimeType, sizeBytes)?.let { videoInfo ->
|
||||||
var pfd: ParcelFileDescriptor? = null
|
// set the original image as the first and default track
|
||||||
try {
|
var pageIndex = 0
|
||||||
getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
pages.add(
|
||||||
val videoStartOffset = sizeBytes - videoSizeBytes
|
hashMapOf(
|
||||||
pfd = context.contentResolver.openFileDescriptor(uri, "r")
|
KEY_PAGE to pageIndex++,
|
||||||
pfd?.fileDescriptor?.let { fd ->
|
KEY_MIME_TYPE to mimeType,
|
||||||
extractor.setDataSource(fd, videoStartOffset, videoSizeBytes)
|
KEY_IS_DEFAULT to true,
|
||||||
// set the original image as the first and default track
|
)
|
||||||
var pageIndex = 0
|
)
|
||||||
pages.add(
|
// add video tracks from the appended video
|
||||||
hashMapOf(
|
videoInfo.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
||||||
KEY_PAGE to pageIndex++,
|
if (MimeTypes.isVideo(mime)) {
|
||||||
KEY_MIME_TYPE to mimeType,
|
val page: FieldMap = hashMapOf(
|
||||||
KEY_IS_DEFAULT to true,
|
KEY_PAGE to pageIndex++,
|
||||||
)
|
KEY_MIME_TYPE to MimeTypes.MP4,
|
||||||
|
KEY_IS_DEFAULT to false,
|
||||||
)
|
)
|
||||||
// add video tracks from the appended video
|
videoInfo.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
|
||||||
if (extractor.trackCount > 0) {
|
videoInfo.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
|
||||||
// only consider the first track to represent the appended video
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
val trackIndex = 0
|
videoInfo.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it }
|
||||||
try {
|
|
||||||
val format = extractor.getTrackFormat(trackIndex)
|
|
||||||
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
|
||||||
if (MimeTypes.isVideo(mime)) {
|
|
||||||
val page: FieldMap = hashMapOf(
|
|
||||||
KEY_PAGE to pageIndex++,
|
|
||||||
KEY_MIME_TYPE to MimeTypes.MP4,
|
|
||||||
KEY_IS_DEFAULT to false,
|
|
||||||
)
|
|
||||||
format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
|
|
||||||
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
format.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it }
|
|
||||||
}
|
|
||||||
format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
|
|
||||||
pages.add(page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$trackIndex", e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
videoInfo.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
|
||||||
|
pages.add(page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(LOG_TAG, "failed to open motion photo for uri=$uri", e)
|
|
||||||
} finally {
|
|
||||||
extractor.release()
|
|
||||||
pfd?.close()
|
|
||||||
}
|
}
|
||||||
return pages
|
return pages
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
|
fun getTrailerVideoSize(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
|
||||||
if (MimeTypes.isHeic(mimeType)) {
|
if (MimeTypes.isHeic(mimeType)) {
|
||||||
// XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video.
|
// XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video.
|
||||||
// This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
|
// This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
|
||||||
|
@ -360,6 +345,62 @@ object MultiPage {
|
||||||
return offsetFromEnd
|
return offsetFromEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getMotionPhotoVideoInfo(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): MediaFormat? {
|
||||||
|
getMotionPhotoVideoSizing(context, uri, mimeType, sizeBytes)?.let { (videoOffset, videoSize) ->
|
||||||
|
return getEmbedVideoInfo(context, uri, videoOffset, videoSize)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTrailerVideoInfo(context: Context, uri: Uri, fileSize: Long, videoSize: Long): MediaFormat? {
|
||||||
|
return getEmbedVideoInfo(context, uri, videoOffset = fileSize - videoSize, videoSize = videoSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEmbedVideoInfo(context: Context, uri: Uri, videoOffset: Long, videoSize: Long): MediaFormat? {
|
||||||
|
val extractor = MediaExtractor()
|
||||||
|
var pfd: ParcelFileDescriptor? = null
|
||||||
|
try {
|
||||||
|
pfd = context.contentResolver.openFileDescriptor(uri, "r")
|
||||||
|
pfd?.fileDescriptor?.let { fd ->
|
||||||
|
extractor.setDataSource(fd, videoOffset, videoSize)
|
||||||
|
// video track may be after an audio track
|
||||||
|
for (trackIndex in 0 until extractor.trackCount) {
|
||||||
|
try {
|
||||||
|
val format = extractor.getTrackFormat(trackIndex)
|
||||||
|
format.getString(MediaFormat.KEY_MIME)?.let {
|
||||||
|
if (MimeTypes.isVideo(it)) {
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to get track information for uri=$uri, track num=$trackIndex", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to open motion photo for uri=$uri", e)
|
||||||
|
} finally {
|
||||||
|
extractor.release()
|
||||||
|
pfd?.close()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMotionPhotoVideoSizing(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Pair<Long, Long>? {
|
||||||
|
// default to trailer videos
|
||||||
|
getTrailerVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSize ->
|
||||||
|
val videoOffset = sizeBytes - videoSize
|
||||||
|
return Pair(videoOffset, videoSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MimeTypes.isHeic(mimeType)) {
|
||||||
|
// fallback to video within Samsung SEFD box
|
||||||
|
return getHeicSefdMotionPhotoVideoSizing(context, uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
|
fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
|
||||||
fun toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap {
|
fun toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
|
|
|
@ -26,7 +26,6 @@ import pixy.meta.string.XMLUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
object PixyMetaHelper {
|
object PixyMetaHelper {
|
||||||
fun describe(input: InputStream): HashMap<String, String> {
|
fun describe(input: InputStream): HashMap<String, String> {
|
||||||
|
@ -82,17 +81,18 @@ object PixyMetaHelper {
|
||||||
output: OutputStream,
|
output: OutputStream,
|
||||||
iptcDataList: List<FieldMap>?,
|
iptcDataList: List<FieldMap>?,
|
||||||
) {
|
) {
|
||||||
val iptc = iptcDataList?.flatMap {
|
val iptc: List<IPTCDataSet> = iptcDataList?.flatMap {
|
||||||
val record = it["record"] as Int
|
val record = it["record"] as Int
|
||||||
val tag = it["tag"] as Int
|
val tag = it["tag"] as Int
|
||||||
val values = it["values"] as List<*>
|
val values = it["values"] as List<*>
|
||||||
values.map { data -> IPTCDataSet(IPTCRecord.fromRecordNumber(record), tag, data as ByteArray) }
|
values.map { data -> IPTCDataSet(IPTCRecord.fromRecordNumber(record), tag, data as ByteArray) }
|
||||||
} ?: ArrayList<IPTCDataSet>()
|
} ?: ArrayList()
|
||||||
Metadata.insertIPTC(input, output, iptc)
|
Metadata.insertIPTC(input, output, iptc)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
|
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
|
||||||
|
|
||||||
|
// PixyMeta may fail with just a log, and write nothing to the output
|
||||||
fun setXmp(
|
fun setXmp(
|
||||||
input: InputStream,
|
input: InputStream,
|
||||||
output: OutputStream,
|
output: OutputStream,
|
||||||
|
|
|
@ -3,7 +3,6 @@ package deckers.thibault.aves.metadata
|
||||||
import deckers.thibault.aves.utils.toHex
|
import deckers.thibault.aves.utils.toHex
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class QuickTimeMetadataBlock(val type: String, val value: String, val language: String)
|
class QuickTimeMetadataBlock(val type: String, val value: String, val language: String)
|
||||||
|
|
||||||
|
|
|
@ -183,7 +183,7 @@ object GoogleXMP {
|
||||||
return offsetFromEnd
|
return offsetFromEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateTrailingVideoOffset(xmp: String, oldOffset: Int, newOffset: Int): String {
|
fun updateTrailingVideoOffset(xmp: String, oldOffset: Number, newOffset: Number): String {
|
||||||
return xmp.replace(
|
return xmp.replace(
|
||||||
// GCamera motion photo
|
// GCamera motion photo
|
||||||
"${GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$oldOffset\"",
|
"${GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$oldOffset\"",
|
||||||
|
@ -195,7 +195,6 @@ object GoogleXMP {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun getDeviceContainer(meta: XMPMeta): GoogleDeviceContainer? {
|
fun getDeviceContainer(meta: XMPMeta): GoogleDeviceContainer? {
|
||||||
return if (meta.doesPropPathExist(listOf(GDEVICE_CONTAINER_PROP_NAME, GDEVICE_CONTAINER_DIRECTORY_PROP_NAME))) {
|
return if (meta.doesPropPathExist(listOf(GDEVICE_CONTAINER_PROP_NAME, GDEVICE_CONTAINER_DIRECTORY_PROP_NAME))) {
|
||||||
GoogleDeviceContainer().apply { findItems(meta) }
|
GoogleDeviceContainer().apply { findItems(meta) }
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
package deckers.thibault.aves.model
|
package deckers.thibault.aves.model
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
class AvesEntry(map: FieldMap) {
|
class AvesEntry(map: FieldMap) {
|
||||||
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
|
val uri: Uri = (map[EntryFields.URI] as String).toUri() // content or file URI
|
||||||
val path = map["path"] as String? // best effort to get local path
|
val path = map[EntryFields.PATH] as String? // best effort to get local path
|
||||||
val pageId = map["pageId"] as Int? // null means the main entry
|
val pageId = map[EntryFields.PAGE_ID] as Int? // null means the main entry
|
||||||
val mimeType = map["mimeType"] as String
|
val mimeType = map[EntryFields.MIME_TYPE] as String
|
||||||
val width = map["width"] as Int
|
val width = map[EntryFields.WIDTH] as Int
|
||||||
val height = map["height"] as Int
|
val height = map[EntryFields.HEIGHT] as Int
|
||||||
val rotationDegrees = map["rotationDegrees"] as Int
|
val rotationDegrees = map[EntryFields.ROTATION_DEGREES] as Int
|
||||||
val isFlipped = map["isFlipped"] as Boolean
|
val isFlipped = map[EntryFields.IS_FLIPPED] as Boolean
|
||||||
val sizeBytes = toLong(map["sizeBytes"])
|
val sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
|
||||||
val trashed = map["trashed"] as Boolean
|
val trashed = map[EntryFields.TRASHED] as Boolean
|
||||||
val trashPath = map["trashPath"] as String?
|
val trashPath = map[EntryFields.TRASH_PATH] as String?
|
||||||
|
|
||||||
private val isRotated: Boolean
|
private val isRotated: Boolean
|
||||||
get() = rotationDegrees % 180 == 90
|
get() = rotationDegrees % 180 == 90
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
package deckers.thibault.aves.model
|
||||||
|
|
||||||
|
// entry fields exported and imported from/to the platform side
|
||||||
|
// should match `EntryFields` on Dart side
|
||||||
|
object EntryFields {
|
||||||
|
const val ORIGIN = "origin" // int
|
||||||
|
const val URI = "uri" // string
|
||||||
|
const val CONTENT_ID = "contentId" // long
|
||||||
|
const val PATH = "path" // string
|
||||||
|
const val PAGE_ID = "pageId" // int
|
||||||
|
const val SOURCE_MIME_TYPE = "sourceMimeType" // string
|
||||||
|
const val MIME_TYPE = "mimeType" // string
|
||||||
|
|
||||||
|
const val WIDTH = "width" // int
|
||||||
|
const val HEIGHT = "height" // int
|
||||||
|
const val SOURCE_ROTATION_DEGREES = "sourceRotationDegrees" // int
|
||||||
|
const val ROTATION_DEGREES = "rotationDegrees" // int
|
||||||
|
const val IS_FLIPPED = "isFlipped" // boolean
|
||||||
|
|
||||||
|
const val DATE_ADDED_SECS = "dateAddedSecs" // long
|
||||||
|
const val DATE_MODIFIED_MILLIS = "dateModifiedMillis" // long
|
||||||
|
const val SOURCE_DATE_TAKEN_MILLIS = "sourceDateTakenMillis" // long
|
||||||
|
const val DURATION_MILLIS = "durationMillis" // long
|
||||||
|
|
||||||
|
const val SIZE_BYTES = "sizeBytes" // long
|
||||||
|
const val TRASHED = "trashed" // boolean
|
||||||
|
const val TRASH_PATH = "trashPath" // string
|
||||||
|
const val TITLE = "title" // string
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import android.content.Context
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.drew.metadata.avi.AviDirectory
|
import com.drew.metadata.avi.AviDirectory
|
||||||
import com.drew.metadata.exif.ExifIFD0Directory
|
import com.drew.metadata.exif.ExifIFD0Directory
|
||||||
import com.drew.metadata.jpeg.JpegDirectory
|
import com.drew.metadata.jpeg.JpegDirectory
|
||||||
|
@ -41,7 +42,7 @@ class SourceEntry {
|
||||||
private var sourceRotationDegrees: Int? = null
|
private var sourceRotationDegrees: Int? = null
|
||||||
private var sizeBytes: Long? = null
|
private var sizeBytes: Long? = null
|
||||||
private var dateAddedSecs: Long? = null
|
private var dateAddedSecs: Long? = null
|
||||||
private var dateModifiedSecs: Long? = null
|
private var dateModifiedMillis: Long? = null
|
||||||
private var sourceDateTakenMillis: Long? = null
|
private var sourceDateTakenMillis: Long? = null
|
||||||
private var durationMillis: Long? = null
|
private var durationMillis: Long? = null
|
||||||
|
|
||||||
|
@ -54,45 +55,45 @@ class SourceEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(map: FieldMap) {
|
constructor(map: FieldMap) {
|
||||||
origin = map["origin"] as Int
|
origin = map[EntryFields.ORIGIN] as Int
|
||||||
uri = Uri.parse(map["uri"] as String)
|
uri = (map[EntryFields.URI] as String).toUri()
|
||||||
path = map["path"] as String?
|
path = map[EntryFields.PATH] as String?
|
||||||
sourceMimeType = map["sourceMimeType"] as String
|
sourceMimeType = map[EntryFields.SOURCE_MIME_TYPE] as String
|
||||||
width = map["width"] as Int?
|
width = map[EntryFields.WIDTH] as Int?
|
||||||
height = map["height"] as Int?
|
height = map[EntryFields.HEIGHT] as Int?
|
||||||
sourceRotationDegrees = map["sourceRotationDegrees"] as Int?
|
sourceRotationDegrees = map[EntryFields.SOURCE_ROTATION_DEGREES] as Int?
|
||||||
sizeBytes = toLong(map["sizeBytes"])
|
sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
|
||||||
title = map["title"] as String?
|
title = map[EntryFields.TITLE] as String?
|
||||||
dateAddedSecs = toLong(map["dateAddedSecs"])
|
dateAddedSecs = toLong(map[EntryFields.DATE_ADDED_SECS])
|
||||||
dateModifiedSecs = toLong(map["dateModifiedSecs"])
|
dateModifiedMillis = toLong(map[EntryFields.DATE_MODIFIED_MILLIS])
|
||||||
sourceDateTakenMillis = toLong(map["sourceDateTakenMillis"])
|
sourceDateTakenMillis = toLong(map[EntryFields.SOURCE_DATE_TAKEN_MILLIS])
|
||||||
durationMillis = toLong(map["durationMillis"])
|
durationMillis = toLong(map[EntryFields.DURATION_MILLIS])
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedSecs: Long) {
|
fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedMillis: Long) {
|
||||||
this.path = path
|
this.path = path
|
||||||
this.title = title
|
this.title = title
|
||||||
this.sizeBytes = sizeBytes
|
this.sizeBytes = sizeBytes
|
||||||
this.dateModifiedSecs = dateModifiedSecs
|
this.dateModifiedMillis = dateModifiedMillis
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toMap(): FieldMap {
|
fun toMap(): FieldMap {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
"origin" to origin,
|
EntryFields.ORIGIN to origin,
|
||||||
"uri" to uri.toString(),
|
EntryFields.URI to uri.toString(),
|
||||||
"path" to path,
|
EntryFields.PATH to path,
|
||||||
"sourceMimeType" to sourceMimeType,
|
EntryFields.SOURCE_MIME_TYPE to sourceMimeType,
|
||||||
"width" to width,
|
EntryFields.WIDTH to width,
|
||||||
"height" to height,
|
EntryFields.HEIGHT to height,
|
||||||
"sourceRotationDegrees" to (sourceRotationDegrees ?: 0),
|
EntryFields.SOURCE_ROTATION_DEGREES to (sourceRotationDegrees ?: 0),
|
||||||
"sizeBytes" to sizeBytes,
|
EntryFields.SIZE_BYTES to sizeBytes,
|
||||||
"title" to title,
|
EntryFields.TITLE to title,
|
||||||
"dateAddedSecs" to dateAddedSecs,
|
EntryFields.DATE_ADDED_SECS to dateAddedSecs,
|
||||||
"dateModifiedSecs" to dateModifiedSecs,
|
EntryFields.DATE_MODIFIED_MILLIS to dateModifiedMillis,
|
||||||
"sourceDateTakenMillis" to sourceDateTakenMillis,
|
EntryFields.SOURCE_DATE_TAKEN_MILLIS to sourceDateTakenMillis,
|
||||||
"durationMillis" to durationMillis,
|
EntryFields.DURATION_MILLIS to durationMillis,
|
||||||
// only for map export
|
// only for map export
|
||||||
"contentId" to contentId,
|
EntryFields.CONTENT_ID to contentId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.content.ContextWrapper
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
|
import deckers.thibault.aves.model.EntryFields
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -45,7 +46,7 @@ internal class FileImageProvider : ImageProvider() {
|
||||||
path = path,
|
path = path,
|
||||||
title = file.name,
|
title = file.name,
|
||||||
sizeBytes = file.length(),
|
sizeBytes = file.length(),
|
||||||
dateModifiedSecs = file.lastModified() / 1000,
|
dateModifiedMillis = file.lastModified(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
|
@ -88,9 +89,9 @@ internal class FileImageProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
"uri" to Uri.fromFile(newFile).toString(),
|
EntryFields.URI to Uri.fromFile(newFile).toString(),
|
||||||
"path" to newFile.path,
|
EntryFields.PATH to newFile.path,
|
||||||
"dateModifiedSecs" to newFile.lastModified() / 1000,
|
EntryFields.DATE_MODIFIED_MILLIS to newFile.lastModified(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,8 +99,8 @@ internal class FileImageProvider : ImageProvider() {
|
||||||
try {
|
try {
|
||||||
val file = File(path)
|
val file = File(path)
|
||||||
if (file.exists()) {
|
if (file.exists()) {
|
||||||
newFields["dateModifiedSecs"] = file.lastModified() / 1000
|
newFields[EntryFields.DATE_MODIFIED_MILLIS] = file.lastModified()
|
||||||
newFields["sizeBytes"] = file.length()
|
newFields[EntryFields.SIZE_BYTES] = file.length()
|
||||||
}
|
}
|
||||||
callback.onSuccess(newFields)
|
callback.onSuccess(newFields)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
|
|
|
@ -11,16 +11,11 @@ import android.net.Uri
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
import androidx.core.net.toUri
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|
||||||
import com.bumptech.glide.request.FutureTarget
|
import com.bumptech.glide.request.FutureTarget
|
||||||
import com.bumptech.glide.request.RequestOptions
|
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat
|
import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
||||||
import deckers.thibault.aves.decoder.SvgImage
|
|
||||||
import deckers.thibault.aves.decoder.TiffImage
|
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
||||||
|
@ -38,6 +33,7 @@ import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
|
import deckers.thibault.aves.model.EntryFields
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.NameConflictResolution
|
import deckers.thibault.aves.model.NameConflictResolution
|
||||||
|
@ -68,6 +64,7 @@ import java.nio.channels.Channels
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
|
||||||
abstract class ImageProvider {
|
abstract class ImageProvider {
|
||||||
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) {
|
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) {
|
||||||
|
@ -78,10 +75,10 @@ abstract class ImageProvider {
|
||||||
return if (StorageUtils.isInVault(context, path)) {
|
return if (StorageUtils.isInVault(context, path)) {
|
||||||
val uri = Uri.fromFile(File(path))
|
val uri = Uri.fromFile(File(path))
|
||||||
hashMapOf(
|
hashMapOf(
|
||||||
"origin" to SourceEntry.ORIGIN_VAULT,
|
EntryFields.ORIGIN to SourceEntry.ORIGIN_VAULT,
|
||||||
"uri" to uri.toString(),
|
EntryFields.URI to uri.toString(),
|
||||||
"contentId" to null,
|
EntryFields.CONTENT_ID to null,
|
||||||
"path" to path,
|
EntryFields.PATH to path,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
MediaStoreImageProvider().scanNewPathByMediaStore(context, path, mimeType)
|
MediaStoreImageProvider().scanNewPathByMediaStore(context, path, mimeType)
|
||||||
|
@ -145,16 +142,18 @@ abstract class ImageProvider {
|
||||||
|
|
||||||
val oldFile = File(sourcePath)
|
val oldFile = File(sourcePath)
|
||||||
if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) {
|
if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) {
|
||||||
|
val defaultExtension = oldFile.extension
|
||||||
oldFile.parent?.let { dir ->
|
oldFile.parent?.let { dir ->
|
||||||
val resolution = resolveTargetFileNameWithoutExtension(
|
val resolution = resolveTargetFileNameWithoutExtension(
|
||||||
contextWrapper = activity,
|
contextWrapper = activity,
|
||||||
dir = dir,
|
dir = dir,
|
||||||
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
|
defaultExtension = defaultExtension,
|
||||||
conflictStrategy = NameConflictStrategy.RENAME,
|
conflictStrategy = NameConflictStrategy.RENAME,
|
||||||
)
|
)
|
||||||
resolution.nameWithoutExtension?.let { targetNameWithoutExtension ->
|
resolution.nameWithoutExtension?.let { targetNameWithoutExtension ->
|
||||||
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
|
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}"
|
||||||
val newFile = File(dir, targetFileName)
|
val newFile = File(dir, targetFileName)
|
||||||
if (oldFile != newFile) {
|
if (oldFile != newFile) {
|
||||||
newFields = renameSingle(
|
newFields = renameSingle(
|
||||||
|
@ -280,11 +279,17 @@ abstract class ImageProvider {
|
||||||
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
|
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
|
||||||
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
|
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// there is no benefit providing input extension
|
||||||
|
// for known output MIME type
|
||||||
|
val defaultExtension = null
|
||||||
|
|
||||||
val resolution = resolveTargetFileNameWithoutExtension(
|
val resolution = resolveTargetFileNameWithoutExtension(
|
||||||
contextWrapper = activity,
|
contextWrapper = activity,
|
||||||
dir = targetDir,
|
dir = targetDir,
|
||||||
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||||
mimeType = exportMimeType,
|
mimeType = exportMimeType,
|
||||||
|
defaultExtension = defaultExtension,
|
||||||
conflictStrategy = nameConflictStrategy,
|
conflictStrategy = nameConflictStrategy,
|
||||||
)
|
)
|
||||||
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
|
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
|
||||||
|
@ -317,27 +322,12 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val model: Any = if (pageId != null && MultiPageImage.isSupported(sourceMimeType)) {
|
|
||||||
MultiPageImage(activity, sourceUri, sourceMimeType, pageId)
|
|
||||||
} else if (sourceMimeType == MimeTypes.TIFF) {
|
|
||||||
TiffImage(activity, sourceUri, pageId)
|
|
||||||
} else if (sourceMimeType == MimeTypes.SVG) {
|
|
||||||
SvgImage(activity, sourceUri)
|
|
||||||
} else {
|
|
||||||
StorageUtils.getGlideSafeUri(activity, sourceUri, sourceMimeType, sourceEntry.sizeBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// request a fresh image with the highest quality format
|
|
||||||
val glideOptions = RequestOptions()
|
|
||||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
||||||
.skipMemoryCache(true)
|
|
||||||
|
|
||||||
target = Glide.with(activity.applicationContext)
|
target = Glide.with(activity.applicationContext)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(glideOptions)
|
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
||||||
.load(model)
|
.load(AvesAppGlideModule.getModel(activity, sourceUri, sourceMimeType, pageId, sourceEntry.sizeBytes))
|
||||||
.submit(targetWidthPx, targetHeightPx)
|
.submit(targetWidthPx, targetHeightPx)
|
||||||
|
|
||||||
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||||
if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) {
|
if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) {
|
||||||
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
||||||
|
@ -376,11 +366,12 @@ abstract class ImageProvider {
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetDirDocFile = targetDirDocFile,
|
targetDirDocFile = targetDirDocFile,
|
||||||
targetNameWithoutExtension = targetNameWithoutExtension,
|
targetNameWithoutExtension = targetNameWithoutExtension,
|
||||||
|
defaultExtension = defaultExtension,
|
||||||
write = write,
|
write = write,
|
||||||
)
|
)
|
||||||
|
|
||||||
val newFields = scanNewPath(activity, targetPath, exportMimeType)
|
val newFields = scanNewPath(activity, targetPath, exportMimeType)
|
||||||
val targetUri = Uri.parse(newFields["uri"] as String)
|
val targetUri = (newFields[EntryFields.URI] as String).toUri()
|
||||||
if (writeMetadata) {
|
if (writeMetadata) {
|
||||||
copyMetadata(
|
copyMetadata(
|
||||||
context = activity,
|
context = activity,
|
||||||
|
@ -483,6 +474,7 @@ abstract class ImageProvider {
|
||||||
dir = targetDir,
|
dir = targetDir,
|
||||||
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||||
mimeType = captureMimeType,
|
mimeType = captureMimeType,
|
||||||
|
defaultExtension = null,
|
||||||
conflictStrategy = nameConflictStrategy,
|
conflictStrategy = nameConflictStrategy,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -589,13 +581,14 @@ abstract class ImageProvider {
|
||||||
dir: String,
|
dir: String,
|
||||||
desiredNameWithoutExtension: String,
|
desiredNameWithoutExtension: String,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
|
defaultExtension: String?,
|
||||||
conflictStrategy: NameConflictStrategy,
|
conflictStrategy: NameConflictStrategy,
|
||||||
): NameConflictResolution {
|
): NameConflictResolution {
|
||||||
val sanitizedNameWithoutExtension = sanitizeDesiredFileName(desiredNameWithoutExtension)
|
val sanitizedNameWithoutExtension = sanitizeDesiredFileName(desiredNameWithoutExtension)
|
||||||
var resolvedName: String? = sanitizedNameWithoutExtension
|
var resolvedName: String? = sanitizedNameWithoutExtension
|
||||||
var replacementFile: File? = null
|
var replacementFile: File? = null
|
||||||
|
|
||||||
val extension = extensionFor(mimeType)
|
val extension = extensionFor(mimeType, defaultExtension)
|
||||||
val targetFile = File(dir, "$sanitizedNameWithoutExtension$extension")
|
val targetFile = File(dir, "$sanitizedNameWithoutExtension$extension")
|
||||||
when (conflictStrategy) {
|
when (conflictStrategy) {
|
||||||
NameConflictStrategy.RENAME -> {
|
NameConflictStrategy.RENAME -> {
|
||||||
|
@ -664,19 +657,21 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalFileSize = File(path).length()
|
val originalFileSize = File(path).length()
|
||||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
var trailerVideoBytes: ByteArray? = null
|
||||||
var videoBytes: ByteArray? = null
|
|
||||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
|
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
|
||||||
|
val isTrailerVideoValid = trailerVideoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, trailerVideoSize) != null
|
||||||
try {
|
try {
|
||||||
if (videoSize != null) {
|
if (trailerVideoSize != null && isTrailerVideoValid) {
|
||||||
// handle motion photo and embedded video separately
|
// handle motion photo and embedded video separately
|
||||||
val imageSize = (originalFileSize - videoSize).toInt()
|
val imageSize = (originalFileSize - trailerVideoSize).toInt()
|
||||||
videoBytes = ByteArray(videoSize)
|
val videoByteSize = trailerVideoSize.toInt()
|
||||||
|
trailerVideoBytes = ByteArray(videoByteSize)
|
||||||
|
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
val imageBytes = ByteArray(imageSize)
|
val imageBytes = ByteArray(imageSize)
|
||||||
input.read(imageBytes, 0, imageSize)
|
input.read(imageBytes, 0, imageSize)
|
||||||
input.read(videoBytes, 0, videoSize)
|
input.read(trailerVideoBytes, 0, videoByteSize)
|
||||||
|
|
||||||
// copy only the image to a temporary file for editing
|
// copy only the image to a temporary file for editing
|
||||||
// video will be appended after metadata modification
|
// video will be appended after metadata modification
|
||||||
|
@ -696,30 +691,31 @@ abstract class ImageProvider {
|
||||||
try {
|
try {
|
||||||
edit(ExifInterface(editableFile))
|
edit(ExifInterface(editableFile))
|
||||||
|
|
||||||
|
if (editableFile.length() == 0L) {
|
||||||
|
callback.onFailure(Exception("editing Exif yielded an empty file"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
val editedMimeType = detectMimeType(context, Uri.fromFile(editableFile), mimeType)
|
val editedMimeType = detectMimeType(context, Uri.fromFile(editableFile), mimeType)
|
||||||
if (editedMimeType != mimeType) {
|
if (editedMimeType != mimeType) {
|
||||||
throw Exception("editing Exif changes mimeType=$mimeType -> $editedMimeType for uri=$uri path=$path")
|
throw Exception("editing Exif changes mimeType=$mimeType -> $editedMimeType for uri=$uri path=$path")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
// 1) as of androidx.exifinterface:exifinterface:1.3.6, editing some specific WEBP
|
// editing may corrupt the file for various reasons,
|
||||||
// makes them undecodable by some decoders (including Android's and Chrome's)
|
|
||||||
// even though `BitmapFactory` successfully decodes their bounds,
|
|
||||||
// so we check whether decoding it throws an exception
|
// so we check whether decoding it throws an exception
|
||||||
// 2) some users have reported corruption when editing JPEG as well,
|
|
||||||
// but conditions are unknown (specific image, custom ROM, low storage, race condition, etc.)
|
|
||||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(editableFile))
|
ImageDecoder.decodeBitmap(ImageDecoder.createSource(editableFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoBytes != null) {
|
if (trailerVideoBytes != null) {
|
||||||
// append trailer video, if any
|
// append trailer video, if any
|
||||||
editableFile.appendBytes(videoBytes!!)
|
editableFile.appendBytes(trailerVideoBytes!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||||
|
|
||||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoBytes?.size, editableFile, callback)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
editableFile.delete()
|
editableFile.delete()
|
||||||
|
@ -747,19 +743,21 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalFileSize = File(path).length()
|
val originalFileSize = File(path).length()
|
||||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
var trailerVideoBytes: ByteArray? = null
|
||||||
var videoBytes: ByteArray? = null
|
|
||||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
|
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
|
||||||
|
val isTrailerVideoValid = trailerVideoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, trailerVideoSize) != null
|
||||||
try {
|
try {
|
||||||
if (videoSize != null) {
|
if (trailerVideoSize != null && isTrailerVideoValid) {
|
||||||
// handle motion photo and embedded video separately
|
// handle motion photo and embedded video separately
|
||||||
val imageSize = (originalFileSize - videoSize).toInt()
|
val imageSize = (originalFileSize - trailerVideoSize).toInt()
|
||||||
videoBytes = ByteArray(videoSize)
|
val videoByteSize = trailerVideoSize.toInt()
|
||||||
|
trailerVideoBytes = ByteArray(videoByteSize)
|
||||||
|
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
val imageBytes = ByteArray(imageSize)
|
val imageBytes = ByteArray(imageSize)
|
||||||
input.read(imageBytes, 0, imageSize)
|
input.read(imageBytes, 0, imageSize)
|
||||||
input.read(videoBytes, 0, videoSize)
|
input.read(trailerVideoBytes, 0, videoByteSize)
|
||||||
|
|
||||||
// copy only the image to a temporary file for editing
|
// copy only the image to a temporary file for editing
|
||||||
// video will be appended after metadata modification
|
// video will be appended after metadata modification
|
||||||
|
@ -795,15 +793,20 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoBytes != null) {
|
if (editableFile.length() == 0L) {
|
||||||
|
callback.onFailure(Exception("editing IPTC yielded an empty file"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trailerVideoBytes != null) {
|
||||||
// append trailer video, if any
|
// append trailer video, if any
|
||||||
editableFile.appendBytes(videoBytes!!)
|
editableFile.appendBytes(trailerVideoBytes!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||||
|
|
||||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoBytes?.size, editableFile, callback)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
editableFile.delete()
|
editableFile.delete()
|
||||||
|
@ -913,7 +916,7 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalFileSize = File(path).length()
|
val originalFileSize = File(path).length()
|
||||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
try {
|
try {
|
||||||
editXmpWithPixy(
|
editXmpWithPixy(
|
||||||
|
@ -931,11 +934,16 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editableFile.length() == 0L) {
|
||||||
|
callback.onFailure(Exception("editing XMP yielded an empty file"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||||
|
|
||||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoSize, editableFile, callback)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
editableFile.delete()
|
editableFile.delete()
|
||||||
|
@ -996,7 +1004,7 @@ abstract class ImageProvider {
|
||||||
path: String,
|
path: String,
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
trailerOffset: Int?,
|
trailerOffset: Number?,
|
||||||
editedFile: File,
|
editedFile: File,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
@ -1011,7 +1019,7 @@ abstract class ImageProvider {
|
||||||
LOG_TAG, "Edited file length=$expectedLength does not match final document file length=$actualLength. " +
|
LOG_TAG, "Edited file length=$expectedLength does not match final document file length=$actualLength. " +
|
||||||
"We need to edit XMP to adjust trailer video offset by $diff bytes."
|
"We need to edit XMP to adjust trailer video offset by $diff bytes."
|
||||||
)
|
)
|
||||||
val newTrailerOffset = trailerOffset + diff
|
val newTrailerOffset = trailerOffset.toLong() + diff
|
||||||
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
|
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
|
||||||
GoogleXMP.updateTrailingVideoOffset(xmp, trailerOffset, newTrailerOffset)
|
GoogleXMP.updateTrailingVideoOffset(xmp, trailerOffset, newTrailerOffset)
|
||||||
})
|
})
|
||||||
|
@ -1276,17 +1284,23 @@ abstract class ImageProvider {
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
) {
|
) {
|
||||||
val originalFileSize = File(path).length()
|
val originalFileSize = File(path).length()
|
||||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)
|
||||||
if (videoSize == null) {
|
if (trailerVideoSize == null) {
|
||||||
callback.onFailure(Exception("failed to get trailer video size"))
|
callback.onFailure(Exception("failed to get trailer video size"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isTrailerVideoValid = MultiPage.getTrailerVideoInfo(context, uri, fileSize = originalFileSize, videoSize = trailerVideoSize) != null
|
||||||
|
if (!isTrailerVideoValid) {
|
||||||
|
callback.onFailure(Exception("failed to open trailer video with size=$trailerVideoSize"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
try {
|
try {
|
||||||
val inputStream = StorageUtils.openInputStream(context, uri)
|
val inputStream = StorageUtils.openInputStream(context, uri)
|
||||||
// partial copy
|
// partial copy
|
||||||
transferFrom(inputStream, originalFileSize - videoSize)
|
transferFrom(inputStream, originalFileSize - trailerVideoSize)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(LOG_TAG, "failed to remove trailer video", e)
|
Log.d(LOG_TAG, "failed to remove trailer video", e)
|
||||||
callback.onFailure(e)
|
callback.onFailure(e)
|
||||||
|
@ -1321,7 +1335,8 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalFileSize = File(path).length()
|
val originalFileSize = File(path).length()
|
||||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)
|
||||||
|
val isTrailerVideoValid = trailerVideoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, trailerVideoSize) != null
|
||||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
try {
|
try {
|
||||||
outputStream().use { output ->
|
outputStream().use { output ->
|
||||||
|
@ -1337,11 +1352,16 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editableFile.length() == 0L) {
|
||||||
|
callback.onFailure(Exception("removing metadata yielded an empty file"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||||
|
|
||||||
if (!types.contains(TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
if (!types.contains(TYPE_XMP) && isTrailerVideoValid && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoSize, editableFile, callback)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
editableFile.delete()
|
editableFile.delete()
|
||||||
|
|
|
@ -20,6 +20,7 @@ import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.MainActivity
|
import deckers.thibault.aves.MainActivity
|
||||||
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
|
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
|
import deckers.thibault.aves.model.EntryFields
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.NameConflictStrategy
|
import deckers.thibault.aves.model.NameConflictStrategy
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
|
@ -40,7 +41,6 @@ import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.io.SyncFailedException
|
import java.io.SyncFailedException
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
import kotlin.coroutines.Continuation
|
import kotlin.coroutines.Continuation
|
||||||
|
@ -51,14 +51,14 @@ import kotlin.coroutines.suspendCoroutine
|
||||||
class MediaStoreImageProvider : ImageProvider() {
|
class MediaStoreImageProvider : ImageProvider() {
|
||||||
fun fetchAll(
|
fun fetchAll(
|
||||||
context: Context,
|
context: Context,
|
||||||
knownEntries: Map<Long?, Int?>,
|
knownEntries: Map<Long?, Long?>,
|
||||||
directory: String?,
|
directory: String?,
|
||||||
handleNewEntry: NewEntryHandler,
|
handleNewEntry: NewEntryHandler,
|
||||||
) {
|
) {
|
||||||
Log.d(LOG_TAG, "fetching all media store items for ${knownEntries.size} known entries, directory=$directory")
|
Log.d(LOG_TAG, "fetching all media store items for ${knownEntries.size} known entries, directory=$directory")
|
||||||
val isModified = fun(contentId: Long, dateModifiedSecs: Int): Boolean {
|
val isModified = fun(contentId: Long, dateModifiedMillis: Long): Boolean {
|
||||||
val knownDate = knownEntries[contentId]
|
val knownDate = knownEntries[contentId]
|
||||||
return knownDate == null || knownDate < dateModifiedSecs
|
return knownDate == null || knownDate < dateModifiedMillis
|
||||||
}
|
}
|
||||||
val handleNew: NewEntryHandler
|
val handleNew: NewEntryHandler
|
||||||
var selection: String? = null
|
var selection: String? = null
|
||||||
|
@ -77,7 +77,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val parentCheckDirectory = removeTrailingSeparator(directory)
|
val parentCheckDirectory = removeTrailingSeparator(directory)
|
||||||
handleNew = { entry ->
|
handleNew = { entry ->
|
||||||
// skip entries in subfolders
|
// skip entries in subfolders
|
||||||
val path = entry["path"] as String?
|
val path = entry[EntryFields.PATH] as String?
|
||||||
if (path != null && File(path).parent == parentCheckDirectory) {
|
if (path != null && File(path).parent == parentCheckDirectory) {
|
||||||
handleNewEntry(entry)
|
handleNewEntry(entry)
|
||||||
}
|
}
|
||||||
|
@ -96,7 +96,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
var found = false
|
var found = false
|
||||||
val fetched = arrayListOf<FieldMap>()
|
val fetched = arrayListOf<FieldMap>()
|
||||||
val id = uri.tryParseId()
|
val id = uri.tryParseId()
|
||||||
val alwaysValid: NewEntryChecker = fun(_: Long, _: Int): Boolean = true
|
val alwaysValid: NewEntryChecker = fun(_: Long, _: Long): Boolean = true
|
||||||
val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) }
|
val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) }
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
if (sourceMimeType == null || isImage(sourceMimeType)) {
|
if (sourceMimeType == null || isImage(sourceMimeType)) {
|
||||||
|
@ -227,8 +227,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
||||||
val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
||||||
val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
||||||
val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
val dateAddedSecsColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
||||||
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
val dateModifiedSecsColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
val dateTakenColumn = cursor.getColumnIndex(MediaColumns.DATE_TAKEN)
|
val dateTakenColumn = cursor.getColumnIndex(MediaColumns.DATE_TAKEN)
|
||||||
|
|
||||||
// image & video for API >=29, only for images for API <29
|
// image & video for API >=29, only for images for API <29
|
||||||
|
@ -240,8 +240,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
|
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
val id = cursor.getLong(idColumn)
|
val id = cursor.getLong(idColumn)
|
||||||
val dateModifiedSecs = cursor.getInt(dateModifiedColumn)
|
val dateModifiedMillis = cursor.getInt(dateModifiedSecsColumn) * 1000L
|
||||||
if (isValidEntry(id, dateModifiedSecs)) {
|
if (isValidEntry(id, dateModifiedMillis)) {
|
||||||
// for multiple items, `contentUri` is the root without ID,
|
// for multiple items, `contentUri` is the root without ID,
|
||||||
// but for single items, `contentUri` already contains the ID
|
// but for single items, `contentUri` already contains the ID
|
||||||
val itemUri = if (contentUriContainsId) contentUri else ContentUris.withAppendedId(contentUri, id)
|
val itemUri = if (contentUriContainsId) contentUri else ContentUris.withAppendedId(contentUri, id)
|
||||||
|
@ -255,21 +255,22 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
if (mimeType == null) {
|
if (mimeType == null) {
|
||||||
Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type")
|
Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type")
|
||||||
} else {
|
} else {
|
||||||
var entryMap: FieldMap = hashMapOf(
|
val path = cursor.getString(pathColumn)
|
||||||
"origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
var entryFields: FieldMap = hashMapOf(
|
||||||
"uri" to itemUri.toString(),
|
EntryFields.ORIGIN to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
||||||
"path" to cursor.getString(pathColumn),
|
EntryFields.URI to itemUri.toString(),
|
||||||
"sourceMimeType" to mimeType,
|
EntryFields.PATH to path,
|
||||||
"width" to width,
|
EntryFields.SOURCE_MIME_TYPE to mimeType,
|
||||||
"height" to height,
|
EntryFields.WIDTH to width,
|
||||||
"sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
|
EntryFields.HEIGHT to height,
|
||||||
"sizeBytes" to cursor.getLong(sizeColumn),
|
EntryFields.SOURCE_ROTATION_DEGREES to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
|
||||||
"dateAddedSecs" to cursor.getInt(dateAddedColumn),
|
EntryFields.SIZE_BYTES to cursor.getLong(sizeColumn),
|
||||||
"dateModifiedSecs" to dateModifiedSecs,
|
EntryFields.DATE_ADDED_SECS to cursor.getInt(dateAddedSecsColumn),
|
||||||
"sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
|
EntryFields.DATE_MODIFIED_MILLIS to dateModifiedMillis,
|
||||||
"durationMillis" to durationMillis,
|
EntryFields.SOURCE_DATE_TAKEN_MILLIS to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
|
||||||
|
EntryFields.DURATION_MILLIS to durationMillis,
|
||||||
// only for map export
|
// only for map export
|
||||||
"contentId" to id,
|
EntryFields.CONTENT_ID to id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (MimeTypes.isHeic(mimeType)) {
|
if (MimeTypes.isHeic(mimeType)) {
|
||||||
|
@ -285,8 +286,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
if (outWidth > 0 && outHeight > 0) {
|
if (outWidth > 0 && outHeight > 0) {
|
||||||
width = outWidth
|
width = outWidth
|
||||||
height = outHeight
|
height = outHeight
|
||||||
entryMap["width"] = width
|
entryFields[EntryFields.WIDTH] = width
|
||||||
entryMap["height"] = height
|
entryFields[EntryFields.HEIGHT] = height
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
|
@ -302,11 +303,13 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
// missing some attributes such as width, height, orientation.
|
// missing some attributes such as width, height, orientation.
|
||||||
// Also, the reported size of raw images is inconsistent across devices
|
// Also, the reported size of raw images is inconsistent across devices
|
||||||
// and Android versions (sometimes the raw size, sometimes the decoded size).
|
// and Android versions (sometimes the raw size, sometimes the decoded size).
|
||||||
val entry = SourceEntry(entryMap).fillPreCatalogMetadata(context)
|
val entry = SourceEntry(entryFields).fillPreCatalogMetadata(context)
|
||||||
entryMap = entry.toMap()
|
entryFields = entry.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNewEntry(entryMap)
|
getFileModifiedDateMillis(path)?.let { entryFields[EntryFields.DATE_MODIFIED_MILLIS] = it }
|
||||||
|
|
||||||
|
handleNewEntry(entryFields)
|
||||||
found = true
|
found = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -554,6 +557,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
toBin: Boolean,
|
toBin: Boolean,
|
||||||
): FieldMap {
|
): FieldMap {
|
||||||
val sourcePath = sourceFile?.path
|
val sourcePath = sourceFile?.path
|
||||||
|
val sourceExtension = sourceFile?.extension
|
||||||
val sourceDir = sourceFile?.parent?.let { ensureTrailingSeparator(it) }
|
val sourceDir = sourceFile?.parent?.let { ensureTrailingSeparator(it) }
|
||||||
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
|
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
|
||||||
// nothing to do unless it's a renamed copy
|
// nothing to do unless it's a renamed copy
|
||||||
|
@ -566,6 +570,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
dir = targetDir,
|
dir = targetDir,
|
||||||
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
|
defaultExtension = sourceExtension,
|
||||||
conflictStrategy = nameConflictStrategy,
|
conflictStrategy = nameConflictStrategy,
|
||||||
)
|
)
|
||||||
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
|
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
|
||||||
|
@ -577,6 +582,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetDirDocFile = targetDirDocFile,
|
targetDirDocFile = targetDirDocFile,
|
||||||
targetNameWithoutExtension = targetNameWithoutExtension,
|
targetNameWithoutExtension = targetNameWithoutExtension,
|
||||||
|
defaultExtension = sourceExtension,
|
||||||
) { output: OutputStream ->
|
) { output: OutputStream ->
|
||||||
try {
|
try {
|
||||||
sourceDocFile.copyTo(output)
|
sourceDocFile.copyTo(output)
|
||||||
|
@ -598,8 +604,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
return if (toBin) {
|
return if (toBin) {
|
||||||
hashMapOf(
|
hashMapOf(
|
||||||
"trashed" to true,
|
EntryFields.TRASHED to true,
|
||||||
"trashPath" to targetPath,
|
EntryFields.TRASH_PATH to targetPath,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
scanNewPath(activity, targetPath, mimeType)
|
scanNewPath(activity, targetPath, mimeType)
|
||||||
|
@ -612,12 +618,13 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
targetDir: String,
|
targetDir: String,
|
||||||
targetDirDocFile: DocumentFileCompat?,
|
targetDirDocFile: DocumentFileCompat?,
|
||||||
targetNameWithoutExtension: String,
|
targetNameWithoutExtension: String,
|
||||||
|
defaultExtension: String?,
|
||||||
write: (OutputStream) -> Unit,
|
write: (OutputStream) -> Unit,
|
||||||
): String {
|
): String {
|
||||||
if (StorageUtils.isInVault(activity, targetDir)) {
|
if (StorageUtils.isInVault(activity, targetDir)) {
|
||||||
return insertByFile(
|
return insertByFile(
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
|
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}",
|
||||||
write = write,
|
write = write,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -627,7 +634,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
return insertByMediaStore(
|
return insertByMediaStore(
|
||||||
activity = activity,
|
activity = activity,
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
|
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}",
|
||||||
write = write,
|
write = write,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -639,6 +646,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetDirDocFile = targetDirDocFile,
|
targetDirDocFile = targetDirDocFile,
|
||||||
targetNameWithoutExtension = targetNameWithoutExtension,
|
targetNameWithoutExtension = targetNameWithoutExtension,
|
||||||
|
defaultExtension = defaultExtension,
|
||||||
write = write,
|
write = write,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -697,6 +705,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
targetDir: String,
|
targetDir: String,
|
||||||
targetDirDocFile: DocumentFileCompat?,
|
targetDirDocFile: DocumentFileCompat?,
|
||||||
targetNameWithoutExtension: String,
|
targetNameWithoutExtension: String,
|
||||||
|
defaultExtension: String?,
|
||||||
write: (OutputStream) -> Unit,
|
write: (OutputStream) -> Unit,
|
||||||
): String {
|
): String {
|
||||||
targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir")
|
targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir")
|
||||||
|
@ -705,8 +714,22 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||||
// through a document URI, not a tree URI
|
// through a document URI, not a tree URI
|
||||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||||
val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
|
var targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
|
||||||
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
targetDocFile.openOutputStream().use(write)
|
targetDocFile.openOutputStream().use(write)
|
||||||
|
@ -823,18 +846,32 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
try {
|
try {
|
||||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = 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.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) }
|
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields[EntryFields.SIZE_BYTES] = cursor.getLong(it) }
|
||||||
cursor.close()
|
cursor.close()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
callback.onFailure(e)
|
callback.onFailure(e)
|
||||||
return@scanFile
|
return@scanFile
|
||||||
}
|
}
|
||||||
|
getFileModifiedDateMillis(path)?.let { newFields[EntryFields.DATE_MODIFIED_MILLIS] = it }
|
||||||
callback.onSuccess(newFields)
|
callback.onSuccess(newFields)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// try to fetch the modified date from the file,
|
||||||
|
// as it is more precise than the one from the Media Store
|
||||||
|
private fun getFileModifiedDateMillis(path: String?): Long? {
|
||||||
|
if (path != null) {
|
||||||
|
try {
|
||||||
|
return File(path).lastModified()
|
||||||
|
} catch (securityException: SecurityException) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private fun scanObsoletePath(context: Context, uri: Uri, path: String, mimeType: String) {
|
private fun scanObsoletePath(context: Context, uri: Uri, path: String, mimeType: String) {
|
||||||
val file = File(path)
|
val file = File(path)
|
||||||
val delayMillis = 500L
|
val delayMillis = 500L
|
||||||
|
@ -912,14 +949,15 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
val newFields = hashMapOf<String, Any?>(
|
val newFields = hashMapOf<String, Any?>(
|
||||||
"origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
EntryFields.ORIGIN to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
||||||
"uri" to uri.toString(),
|
EntryFields.URI to uri.toString(),
|
||||||
"contentId" to uri.tryParseId(),
|
EntryFields.CONTENT_ID to uri.tryParseId(),
|
||||||
"path" to path,
|
EntryFields.PATH to path,
|
||||||
)
|
)
|
||||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields["dateAddedSecs"] = cursor.getInt(it) }
|
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["dateModifiedSecs"] = cursor.getInt(it) }
|
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields[EntryFields.DATE_MODIFIED_MILLIS] = cursor.getInt(it) * 1000 }
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
getFileModifiedDateMillis(path)?.let { newFields[EntryFields.DATE_MODIFIED_MILLIS] = it }
|
||||||
return newFields
|
return newFields
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -1030,4 +1068,4 @@ object MediaColumns {
|
||||||
|
|
||||||
typealias NewEntryHandler = (entry: FieldMap) -> Unit
|
typealias NewEntryHandler = (entry: FieldMap) -> Unit
|
||||||
|
|
||||||
private typealias NewEntryChecker = (contentId: Long, dateModifiedSecs: Int) -> Boolean
|
private typealias NewEntryChecker = (contentId: Long, dateModifiedMillis: Long) -> Boolean
|
|
@ -7,6 +7,7 @@ import android.provider.OpenableColumns
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
|
import deckers.thibault.aves.model.EntryFields
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -43,9 +44,9 @@ open class UnknownContentProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val fields: FieldMap = hashMapOf(
|
val fields: FieldMap = hashMapOf(
|
||||||
"origin" to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
|
EntryFields.ORIGIN to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
|
||||||
"uri" to uri.toString(),
|
EntryFields.URI to uri.toString(),
|
||||||
"sourceMimeType" to mimeType,
|
EntryFields.SOURCE_MIME_TYPE to mimeType,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
// some providers do not provide the mandatory `OpenableColumns`
|
// some providers do not provide the mandatory `OpenableColumns`
|
||||||
|
@ -53,11 +54,11 @@ open class UnknownContentProvider : ImageProvider() {
|
||||||
// e.g. `content://mms/part/[id]` on Android KitKat
|
// e.g. `content://mms/part/[id]` on Android KitKat
|
||||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields["title"] = cursor.getString(it) }
|
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields[EntryFields.TITLE] = cursor.getString(it) }
|
||||||
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields["sizeBytes"] = cursor.getLong(it) }
|
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields[EntryFields.SIZE_BYTES] = cursor.getLong(it) }
|
||||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATA).let { if (it != -1) fields["path"] = cursor.getString(it) }
|
cursor.getColumnIndex(MediaStore.MediaColumns.DATA).let { if (it != -1) fields[EntryFields.PATH] = cursor.getString(it) }
|
||||||
// mime type fallback if it was not provided and not found via `metadata-extractor`
|
// mime type fallback if it was not provided and not found via `metadata-extractor`
|
||||||
cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE).let { if (it != -1 && mimeType == null) fields["sourceMimeType"] = cursor.getString(it) }
|
cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE).let { if (it != -1 && mimeType == null) fields[EntryFields.SOURCE_MIME_TYPE] = cursor.getString(it) }
|
||||||
cursor.close()
|
cursor.close()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -65,7 +66,7 @@ open class UnknownContentProvider : ImageProvider() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields["sourceMimeType"] == null) {
|
if (fields[EntryFields.SOURCE_MIME_TYPE] == null) {
|
||||||
callback.onFailure(Exception("Failed to find MIME type for uri=$uri"))
|
callback.onFailure(Exception("Failed to find MIME type for uri=$uri"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,27 +2,121 @@ package deckers.thibault.aves.utils
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.ColorSpace
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Half
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||||
import deckers.thibault.aves.metadata.Metadata.getExifCode
|
import deckers.thibault.aves.metadata.Metadata.getExifCode
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
object BitmapUtils {
|
object BitmapUtils {
|
||||||
private val LOG_TAG = LogUtils.createTag<BitmapUtils>()
|
private val LOG_TAG = LogUtils.createTag<BitmapUtils>()
|
||||||
private const val INITIAL_BUFFER_SIZE = 2 shl 17 // 256kB
|
private const val INITIAL_BUFFER_SIZE = 2 shl 17 // 256kB
|
||||||
|
|
||||||
// arbitrary size to detect buffer that may yield an OOM
|
|
||||||
private const val BUFFER_SIZE_DANGER_THRESHOLD = 3 * (1 shl 20) // MB
|
|
||||||
|
|
||||||
private val freeBaos = ArrayList<ByteArrayOutputStream>()
|
private val freeBaos = ArrayList<ByteArrayOutputStream>()
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
const val ARGB_8888_BYTE_SIZE = 4
|
private const val INT_BYTE_SIZE = 4
|
||||||
|
private const val MAX_2_BITS_FLOAT = 0x3.toFloat()
|
||||||
|
private const val MAX_8_BITS_FLOAT = 0xff.toFloat()
|
||||||
|
private const val MAX_10_BITS_FLOAT = 0x3ff.toFloat()
|
||||||
|
|
||||||
|
private const val RAW_BYTES_TRAILER_LENGTH = INT_BYTE_SIZE * 2
|
||||||
|
|
||||||
|
// bytes per pixel with different bitmap config
|
||||||
|
private const val BPP_ALPHA_8 = 1
|
||||||
|
private const val BPP_RGB_565 = 2
|
||||||
|
private const val BPP_ARGB_8888 = 4
|
||||||
|
private const val BPP_RGBA_1010102 = 4
|
||||||
|
private const val BPP_RGBA_F16 = 8
|
||||||
|
|
||||||
|
private fun getBytePerPixel(config: Bitmap.Config?): Int {
|
||||||
|
return when (config) {
|
||||||
|
Bitmap.Config.ALPHA_8 -> BPP_ALPHA_8
|
||||||
|
Bitmap.Config.RGB_565 -> BPP_RGB_565
|
||||||
|
Bitmap.Config.ARGB_8888 -> BPP_ARGB_8888
|
||||||
|
else -> {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && config == Bitmap.Config.RGBA_F16) {
|
||||||
|
BPP_RGBA_F16
|
||||||
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
|
||||||
|
BPP_RGBA_1010102
|
||||||
|
} else {
|
||||||
|
// default
|
||||||
|
BPP_ARGB_8888
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getExpectedImageSize(pixelCount: Long, config: Bitmap.Config?): Long {
|
||||||
|
return pixelCount * getBytePerPixel(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRawBytes(bitmap: Bitmap?, recycle: Boolean): ByteArray? {
|
||||||
|
bitmap ?: return null
|
||||||
|
|
||||||
|
val byteCount = bitmap.byteCount
|
||||||
|
val width = bitmap.width
|
||||||
|
val height = bitmap.height
|
||||||
|
val config = bitmap.config
|
||||||
|
val colorSpace = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) bitmap.colorSpace else null
|
||||||
|
|
||||||
|
if (!MemoryUtils.canAllocate(byteCount)) {
|
||||||
|
throw Exception("bitmap buffer is $byteCount bytes, which cannot be allocated to a new byte array")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// `ByteBuffer` initial order is always `BIG_ENDIAN`
|
||||||
|
var bytes = ByteBuffer.allocate(byteCount + RAW_BYTES_TRAILER_LENGTH).apply {
|
||||||
|
bitmap.copyPixelsToBuffer(this)
|
||||||
|
}.array()
|
||||||
|
|
||||||
|
// do not access bitmap after recycling
|
||||||
|
if (recycle) bitmap.recycle()
|
||||||
|
|
||||||
|
// convert pixel format and color space, if necessary
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
colorSpace?.let { srcColorSpace ->
|
||||||
|
val dstColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
|
||||||
|
val connector = ColorSpace.connect(srcColorSpace, dstColorSpace)
|
||||||
|
if (config == Bitmap.Config.ARGB_8888) {
|
||||||
|
if (srcColorSpace != dstColorSpace) {
|
||||||
|
argb8888ToArgb8888(bytes, connector, end = byteCount)
|
||||||
|
}
|
||||||
|
} else if (config == Bitmap.Config.RGBA_F16) {
|
||||||
|
rgbaf16ToArgb8888(bytes, connector, end = byteCount)
|
||||||
|
val newConfigByteCount = byteCount / (BPP_RGBA_F16 / BPP_ARGB_8888)
|
||||||
|
bytes = bytes.sliceArray(0..<newConfigByteCount + RAW_BYTES_TRAILER_LENGTH)
|
||||||
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
|
||||||
|
rgba1010102ToArgb8888(bytes, connector, end = byteCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// append bitmap size for use by the caller to interpret the raw bytes
|
||||||
|
val trailerOffset = bytes.size - RAW_BYTES_TRAILER_LENGTH
|
||||||
|
bytes = ByteBuffer.wrap(bytes).apply {
|
||||||
|
position(trailerOffset)
|
||||||
|
putInt(width)
|
||||||
|
putInt(height)
|
||||||
|
}.array()
|
||||||
|
|
||||||
|
return bytes
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(LOG_TAG, "failed to get bytes from bitmap", e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getEncodedBytes(bitmap: Bitmap?, canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean): ByteArray? {
|
||||||
|
bitmap ?: return null
|
||||||
|
|
||||||
suspend fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean): ByteArray? {
|
|
||||||
val stream: ByteArrayOutputStream
|
val stream: ByteArrayOutputStream
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
// this method is called a lot, so we try and reuse output streams
|
// this method is called a lot, so we try and reuse output streams
|
||||||
|
@ -34,19 +128,17 @@ object BitmapUtils {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// the Bitmap raw bytes are not decodable by Flutter
|
|
||||||
// we need to format them (compress, or add a BMP header) before sending them
|
|
||||||
// `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency
|
// `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency
|
||||||
// the BMP format allows an alpha channel, but Android decoding seems to ignore it
|
// the BMP format allows an alpha channel, but Android decoding seems to ignore it
|
||||||
if (canHaveAlpha && hasAlpha()) {
|
if (canHaveAlpha && bitmap.hasAlpha()) {
|
||||||
this.compress(Bitmap.CompressFormat.PNG, quality, stream)
|
bitmap.compress(Bitmap.CompressFormat.PNG, quality, stream)
|
||||||
} else {
|
} else {
|
||||||
this.compress(Bitmap.CompressFormat.JPEG, quality, stream)
|
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream)
|
||||||
}
|
}
|
||||||
if (recycle) this.recycle()
|
if (recycle) bitmap.recycle()
|
||||||
|
|
||||||
val bufferSize = stream.size()
|
val bufferSize = stream.size()
|
||||||
if (bufferSize > BUFFER_SIZE_DANGER_THRESHOLD && !MemoryUtils.canAllocate(bufferSize)) {
|
if (!MemoryUtils.canAllocate(bufferSize)) {
|
||||||
throw Exception("bitmap compressed to $bufferSize bytes, which cannot be allocated to a new byte array")
|
throw Exception("bitmap compressed to $bufferSize bytes, which cannot be allocated to a new byte array")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,6 +154,107 @@ object BitmapUtils {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convert bytes, without reallocation:
|
||||||
|
// - from original color space to sRGB.
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
private fun argb8888ToArgb8888(bytes: ByteArray, connector: ColorSpace.Connector, start: Int = 0, end: Int = bytes.size) {
|
||||||
|
// unpacking from ARGB_8888 and packing to ARGB_8888
|
||||||
|
// stored as [3,2,1,0] -> [AAAAAAAA BBBBBBBB GGGGGGGG RRRRRRRR]
|
||||||
|
for (i in start..<end step BPP_ARGB_8888) {
|
||||||
|
// mask with `0xff` to yield values in [0, 255], instead of [-128, 127]
|
||||||
|
val iB = bytes[i + 2].toInt() and 0xff
|
||||||
|
val iG = bytes[i + 1].toInt() and 0xff
|
||||||
|
val iR = bytes[i].toInt() and 0xff
|
||||||
|
|
||||||
|
// components as floats in sRGB
|
||||||
|
val srgbFloats = connector.transform(iR / MAX_8_BITS_FLOAT, iG / MAX_8_BITS_FLOAT, iB / MAX_8_BITS_FLOAT)
|
||||||
|
val srgbR = (srgbFloats[0] * 255.0f + 0.5f).toInt()
|
||||||
|
val srgbG = (srgbFloats[1] * 255.0f + 0.5f).toInt()
|
||||||
|
val srgbB = (srgbFloats[2] * 255.0f + 0.5f).toInt()
|
||||||
|
|
||||||
|
// keep alpha as it is, in `bytes[i + 3]`
|
||||||
|
bytes[i + 2] = srgbB.toByte()
|
||||||
|
bytes[i + 1] = srgbG.toByte()
|
||||||
|
bytes[i] = srgbR.toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert bytes, without reallocation:
|
||||||
|
// - from config RGBA_F16 to ARGB_8888,
|
||||||
|
// - from original color space to sRGB.
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
private fun rgbaf16ToArgb8888(bytes: ByteArray, connector: ColorSpace.Connector, start: Int = 0, end: Int = bytes.size) {
|
||||||
|
val indexDivider = BPP_RGBA_F16 / BPP_ARGB_8888
|
||||||
|
for (i in start..<end step BPP_RGBA_F16) {
|
||||||
|
// unpacking from RGBA_F16
|
||||||
|
// stored as [7,6,5,4,3,2,1,0] -> [AAAAAAAA AAAAAAAA BBBBBBBB BBBBBBBB GGGGGGGG GGGGGGGG RRRRRRRR RRRRRRRR]
|
||||||
|
val i7 = bytes[i + 7].toInt()
|
||||||
|
val i6 = bytes[i + 6].toInt()
|
||||||
|
val i5 = bytes[i + 5].toInt()
|
||||||
|
val i4 = bytes[i + 4].toInt()
|
||||||
|
val i3 = bytes[i + 3].toInt()
|
||||||
|
val i2 = bytes[i + 2].toInt()
|
||||||
|
val i1 = bytes[i + 1].toInt()
|
||||||
|
val i0 = bytes[i].toInt()
|
||||||
|
|
||||||
|
val hA = Half((((i7 and 0xff) shl 8) or (i6 and 0xff)).toShort())
|
||||||
|
val hB = Half((((i5 and 0xff) shl 8) or (i4 and 0xff)).toShort())
|
||||||
|
val hG = Half((((i3 and 0xff) shl 8) or (i2 and 0xff)).toShort())
|
||||||
|
val hR = Half((((i1 and 0xff) shl 8) or (i0 and 0xff)).toShort())
|
||||||
|
|
||||||
|
// components as floats in sRGB
|
||||||
|
val srgbFloats = connector.transform(hR.toFloat(), hG.toFloat(), hB.toFloat())
|
||||||
|
val srgbR = (srgbFloats[0] * 255.0f + 0.5f).toInt()
|
||||||
|
val srgbG = (srgbFloats[1] * 255.0f + 0.5f).toInt()
|
||||||
|
val srgbB = (srgbFloats[2] * 255.0f + 0.5f).toInt()
|
||||||
|
val alpha = (hA.toFloat() * 255.0f + 0.5f).toInt()
|
||||||
|
|
||||||
|
// packing to ARGB_8888
|
||||||
|
// stored as [3,2,1,0] -> [AAAAAAAA BBBBBBBB GGGGGGGG RRRRRRRR]
|
||||||
|
val dstI = i / indexDivider
|
||||||
|
bytes[dstI + 3] = alpha.toByte()
|
||||||
|
bytes[dstI + 2] = srgbB.toByte()
|
||||||
|
bytes[dstI + 1] = srgbG.toByte()
|
||||||
|
bytes[dstI] = srgbR.toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert bytes, without reallocation:
|
||||||
|
// - from config RGBA_1010102 to ARGB_8888,
|
||||||
|
// - from original color space to sRGB.
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
private fun rgba1010102ToArgb8888(bytes: ByteArray, connector: ColorSpace.Connector, start: Int = 0, end: Int = bytes.size) {
|
||||||
|
val alphaFactor = 255.0f / MAX_2_BITS_FLOAT
|
||||||
|
|
||||||
|
for (i in start..<end step BPP_RGBA_1010102) {
|
||||||
|
// unpacking from RGBA_1010102
|
||||||
|
// stored as [3,2,1,0] -> [AABBBBBB BBBBGGGG GGGGGGRR RRRRRRRR]
|
||||||
|
val i3 = bytes[i + 3].toInt()
|
||||||
|
val i2 = bytes[i + 2].toInt()
|
||||||
|
val i1 = bytes[i + 1].toInt()
|
||||||
|
val i0 = bytes[i].toInt()
|
||||||
|
|
||||||
|
val iA = ((i3 and 0xc0) shr 6)
|
||||||
|
val iB = ((i3 and 0x3f) shl 4) or ((i2 and 0xf0) shr 4)
|
||||||
|
val iG = ((i2 and 0x0f) shl 6) or ((i1 and 0xfc) shr 2)
|
||||||
|
val iR = ((i1 and 0x03) shl 8) or ((i0 and 0xff) shr 0)
|
||||||
|
|
||||||
|
// components as floats in sRGB
|
||||||
|
val srgbFloats = connector.transform(iR / MAX_10_BITS_FLOAT, iG / MAX_10_BITS_FLOAT, iB / MAX_10_BITS_FLOAT)
|
||||||
|
val srgbR = (srgbFloats[0] * 255.0f + 0.5f).toInt()
|
||||||
|
val srgbG = (srgbFloats[1] * 255.0f + 0.5f).toInt()
|
||||||
|
val srgbB = (srgbFloats[2] * 255.0f + 0.5f).toInt()
|
||||||
|
val alpha = (iA * alphaFactor + 0.5f).toInt()
|
||||||
|
|
||||||
|
// packing to ARGB_8888
|
||||||
|
// stored as [3,2,1,0] -> [AAAAAAAA BBBBBBBB GGGGGGGG RRRRRRRR]
|
||||||
|
bytes[i + 3] = alpha.toByte()
|
||||||
|
bytes[i + 2] = srgbB.toByte()
|
||||||
|
bytes[i + 1] = srgbG.toByte()
|
||||||
|
bytes[i] = srgbR.toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
|
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
|
||||||
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
|
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
|
||||||
if (rotationDegrees == 0 && !isFlipped) return bitmap
|
if (rotationDegrees == 0 && !isFlipped) return bitmap
|
||||||
|
|
|
@ -90,12 +90,7 @@ object BmpWriter {
|
||||||
|
|
||||||
var column = 0
|
var column = 0
|
||||||
while (column < biWidth) {
|
while (column < biWidth) {
|
||||||
/*
|
// non-premultiplied ARGB values in the sRGB color space
|
||||||
alpha: (value shr 24 and 0xFF).toByte()
|
|
||||||
red: (value shr 16 and 0xFF).toByte()
|
|
||||||
green: (value shr 8 and 0xFF).toByte()
|
|
||||||
blue: (value and 0xFF).toByte()
|
|
||||||
*/
|
|
||||||
value = pixels[column]
|
value = pixels[column]
|
||||||
// blue: [0], green: [1], red: [2]
|
// blue: [0], green: [1], red: [2]
|
||||||
rgb[0] = (value and 0xFF).toByte()
|
rgb[0] = (value and 0xFF).toByte()
|
||||||
|
|
|
@ -8,6 +8,8 @@ fun ByteBuffer.toByteArray(): ByteArray {
|
||||||
return bytes
|
return bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Int.toHex(): String = "0x${byteArrayOf(shr(8).toByte(), toByte()).toHex()}"
|
||||||
|
|
||||||
fun ByteArray.toHex(): String = joinToString(separator = "") { it.toHex() }
|
fun ByteArray.toHex(): String = joinToString(separator = "") { it.toHex() }
|
||||||
|
|
||||||
fun Byte.toHex(): String = "%02x".format(this)
|
fun Byte.toHex(): String = "%02x".format(this)
|
|
@ -20,6 +20,7 @@ fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boyer-Moore algorithm for pattern searching
|
// Boyer-Moore algorithm for pattern searching
|
||||||
|
// Returns: an index of the first occurrence of the pattern or -1 if none is found.
|
||||||
fun ByteArray.indexOfBytes(pattern: ByteArray, start: Int = 0): Int {
|
fun ByteArray.indexOfBytes(pattern: ByteArray, start: Int = 0): Int {
|
||||||
val n: Int = this.size
|
val n: Int = this.size
|
||||||
val m: Int = pattern.size
|
val m: Int = pattern.size
|
||||||
|
|
|
@ -5,5 +5,5 @@ import kotlin.math.pow
|
||||||
|
|
||||||
object MathUtils {
|
object MathUtils {
|
||||||
fun highestPowerOf2(x: Int): Int = highestPowerOf2(x.toDouble())
|
fun highestPowerOf2(x: Int): Int = highestPowerOf2(x.toDouble())
|
||||||
private fun highestPowerOf2(x: Double): Int = if (x < 1) 0 else 2.toDouble().pow(log2(x).toInt()).toInt()
|
fun highestPowerOf2(x: Double): Int = if (x < 1) 0 else 2.toDouble().pow(log2(x).toInt()).toInt()
|
||||||
}
|
}
|
|
@ -1,8 +1,8 @@
|
||||||
package deckers.thibault.aves.utils
|
package deckers.thibault.aves.utils
|
||||||
|
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
import deckers.thibault.aves.decoder.MultiPageImage
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
|
||||||
object MimeTypes {
|
object MimeTypes {
|
||||||
const val ANY = "*/*"
|
const val ANY = "*/*"
|
||||||
|
@ -84,11 +84,11 @@ object MimeTypes {
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
// as of Flutter v3.16.4, with additional custom handling for SVG
|
// as of Flutter v3.16.4, with additional custom handling for SVG in Dart,
|
||||||
fun canDecodeWithFlutter(mimeType: String, pageId: Int?, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
|
// while handling still PNG and JPEG on Android for color space and config conversion
|
||||||
|
fun canDecodeWithFlutter(mimeType: String, isAnimated: Boolean) = when (mimeType) {
|
||||||
GIF, WEBP, BMP, WBMP, ICO, SVG -> true
|
GIF, WEBP, BMP, WBMP, ICO, SVG -> true
|
||||||
JPEG -> (pageId ?: 0) == 0
|
JPEG, PNG -> isAnimated
|
||||||
PNG -> (rotationDegrees ?: 0) == 0 && !(isFlipped ?: false)
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -163,12 +163,24 @@ object MimeTypes {
|
||||||
|
|
||||||
// among other refs:
|
// among other refs:
|
||||||
// - https://android.googlesource.com/platform/external/mime-support/+/refs/heads/master/mime.types
|
// - https://android.googlesource.com/platform/external/mime-support/+/refs/heads/master/mime.types
|
||||||
fun extensionFor(mimeType: String): String? = when (mimeType) {
|
fun extensionFor(mimeType: String, defaultExtension: String?): String = when (mimeType) {
|
||||||
AVI, AVI_VND -> ".avi"
|
AVI, AVI_VND -> ".avi"
|
||||||
|
DNG, DNG_ADOBE -> ".dng"
|
||||||
HEIC, HEIF -> ".heif"
|
HEIC, HEIF -> ".heif"
|
||||||
MP2T, MP2TS -> ".m2ts"
|
MP2T, MP2TS -> ".m2ts"
|
||||||
PSD_VND, PSD_X -> ".psd"
|
PSD_VND, PSD_X -> ".psd"
|
||||||
else -> MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
|
val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
|
||||||
|
|
|
@ -9,13 +9,14 @@ import android.content.pm.PackageManager
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.core.text.isDigitsOnly
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat
|
import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider
|
import deckers.thibault.aves.model.provider.ImageProvider
|
||||||
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
||||||
|
@ -81,7 +82,8 @@ object StorageUtils {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val trashDir = File(externalFilesDir, "trash")
|
val trashDir = File(externalFilesDir, "trash")
|
||||||
if (!trashDir.exists() && !trashDir.mkdirs()) {
|
trashDir.mkdirs()
|
||||||
|
if (!trashDir.exists()) {
|
||||||
Log.e(LOG_TAG, "failed to create directories at path=$trashDir")
|
Log.e(LOG_TAG, "failed to create directories at path=$trashDir")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -228,7 +230,7 @@ object StorageUtils {
|
||||||
// Device has emulated storage; external storage paths should have userId burned into them.
|
// Device has emulated storage; external storage paths should have userId burned into them.
|
||||||
// /storage/emulated/[0,1,2,...]/
|
// /storage/emulated/[0,1,2,...]/
|
||||||
val path = getPrimaryVolumePath(context)
|
val path = getPrimaryVolumePath(context)
|
||||||
val rawUserId = path.split(File.separator).lastOrNull(String::isNotEmpty)?.takeIf { TextUtils.isDigitsOnly(it) } ?: ""
|
val rawUserId = path.split(File.separator).lastOrNull(String::isNotEmpty)?.takeIf { it.isDigitsOnly() } ?: ""
|
||||||
if (rawUserId.isEmpty()) {
|
if (rawUserId.isEmpty()) {
|
||||||
paths.add(rawEmulatedStorageTarget)
|
paths.add(rawEmulatedStorageTarget)
|
||||||
} else {
|
} else {
|
||||||
|
@ -499,7 +501,8 @@ object StorageUtils {
|
||||||
parentFile
|
parentFile
|
||||||
} else {
|
} else {
|
||||||
val directory = File(cleanDirPath)
|
val directory = File(cleanDirPath)
|
||||||
if (!directory.exists() && !directory.mkdirs()) {
|
directory.mkdirs()
|
||||||
|
if (!directory.exists()) {
|
||||||
Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath")
|
Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -636,7 +639,7 @@ object StorageUtils {
|
||||||
|
|
||||||
// strip user info, if any
|
// strip user info, if any
|
||||||
// e.g. `content://0@media/...`
|
// e.g. `content://0@media/...`
|
||||||
private fun stripMediaUriUserInfo(uri: Uri) = Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", ""))
|
private fun stripMediaUriUserInfo(uri: Uri) = uri.toString().replaceFirst("${uri.userInfo}@", "").toUri()
|
||||||
|
|
||||||
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
||||||
val effectiveUri = getOriginalUri(context, uri)
|
val effectiveUri = getOriginalUri(context, uri)
|
||||||
|
@ -712,7 +715,8 @@ object StorageUtils {
|
||||||
|
|
||||||
fun createTempFile(context: Context, extension: String? = null): File {
|
fun createTempFile(context: Context, extension: String? = null): File {
|
||||||
val directory = getTempDirectory(context)
|
val directory = getTempDirectory(context)
|
||||||
if (!directory.exists() && !directory.mkdirs()) {
|
directory.mkdirs()
|
||||||
|
if (!directory.exists()) {
|
||||||
throw IOException("failed to create directories at path=$directory")
|
throw IOException("failed to create directories at path=$directory")
|
||||||
}
|
}
|
||||||
val tempFile = File.createTempFile("aves", extension, directory)
|
val tempFile = File.createTempFile("aves", extension, directory)
|
||||||
|
|
12
android/app/src/main/res/values-bg/strings.xml
Normal file
12
android/app/src/main/res/values-bg/strings.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Aves</string>
|
||||||
|
<string name="app_widget_label">Фоторамка</string>
|
||||||
|
<string name="wallpaper">Тапет</string>
|
||||||
|
<string name="map_shortcut_short_label">Карта</string>
|
||||||
|
<string name="search_shortcut_short_label">Търсене</string>
|
||||||
|
<string name="analysis_channel_name">Сканиране медия</string>
|
||||||
|
<string name="analysis_notification_default_title">Сканиране медия</string>
|
||||||
|
<string name="analysis_notification_action_stop">Стоп</string>
|
||||||
|
<string name="videos_shortcut_short_label">Видео</string>
|
||||||
|
</resources>
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_channel_name">Exploració de mitjans</string>
|
<string name="analysis_channel_name">Exploració de mitjans</string>
|
||||||
<string name="analysis_notification_default_title">Explorant mitjans</string>
|
<string name="analysis_notification_default_title">Explorant mitjans</string>
|
||||||
<string name="analysis_notification_action_stop">Atura</string>
|
<string name="analysis_notification_action_stop">Atura</string>
|
||||||
|
<string name="map_shortcut_short_label">Mapa</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_notification_default_title">Prohledávání médií</string>
|
<string name="analysis_notification_default_title">Prohledávání médií</string>
|
||||||
<string name="analysis_notification_action_stop">Zastavit</string>
|
<string name="analysis_notification_action_stop">Zastavit</string>
|
||||||
<string name="app_widget_label">Fotorámeček</string>
|
<string name="app_widget_label">Fotorámeček</string>
|
||||||
|
<string name="map_shortcut_short_label">Mapa</string>
|
||||||
</resources>
|
</resources>
|
|
@ -3,9 +3,10 @@
|
||||||
<string name="app_widget_label">Fotoramme</string>
|
<string name="app_widget_label">Fotoramme</string>
|
||||||
<string name="wallpaper">Baggrund</string>
|
<string name="wallpaper">Baggrund</string>
|
||||||
<string name="videos_shortcut_short_label">Videoer</string>
|
<string name="videos_shortcut_short_label">Videoer</string>
|
||||||
<string name="analysis_channel_name">Mediascanning</string>
|
<string name="analysis_channel_name">Mediescanning</string>
|
||||||
<string name="analysis_notification_default_title">Scanner medier</string>
|
<string name="analysis_notification_default_title">Scanner medier</string>
|
||||||
<string name="analysis_notification_action_stop">Stop</string>
|
<string name="analysis_notification_action_stop">Stop</string>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="search_shortcut_short_label">Søg</string>
|
<string name="search_shortcut_short_label">Søg</string>
|
||||||
|
<string name="map_shortcut_short_label">Kort</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_channel_name">Σάρωση πολυμέσων</string>
|
<string name="analysis_channel_name">Σάρωση πολυμέσων</string>
|
||||||
<string name="analysis_notification_default_title">Σάρωση στοιχείων</string>
|
<string name="analysis_notification_default_title">Σάρωση στοιχείων</string>
|
||||||
<string name="analysis_notification_action_stop">Διακοπή</string>
|
<string name="analysis_notification_action_stop">Διακοπή</string>
|
||||||
|
<string name="map_shortcut_short_label">Χάρτης</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_notification_action_stop">توقف کردن</string>
|
<string name="analysis_notification_action_stop">توقف کردن</string>
|
||||||
<string name="app_widget_label">قاب عکس</string>
|
<string name="app_widget_label">قاب عکس</string>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
|
<string name="map_shortcut_short_label">نقشه</string>
|
||||||
</resources>
|
</resources>
|
|
@ -5,7 +5,8 @@
|
||||||
<string name="wallpaper">Fondo da pantalla</string>
|
<string name="wallpaper">Fondo da pantalla</string>
|
||||||
<string name="search_shortcut_short_label">Procura</string>
|
<string name="search_shortcut_short_label">Procura</string>
|
||||||
<string name="videos_shortcut_short_label">Vídeos</string>
|
<string name="videos_shortcut_short_label">Vídeos</string>
|
||||||
<string name="analysis_channel_name">Escaneo multimedia</string>
|
<string name="analysis_channel_name">Escanear medios</string>
|
||||||
<string name="analysis_notification_default_title">Escaneando medios</string>
|
<string name="analysis_notification_default_title">Escaneando medios</string>
|
||||||
<string name="analysis_notification_action_stop">Pare</string>
|
<string name="analysis_notification_action_stop">Pare</string>
|
||||||
|
<string name="map_shortcut_short_label">Mapa</string>
|
||||||
</resources>
|
</resources>
|
|
@ -6,7 +6,7 @@
|
||||||
<string name="videos_shortcut_short_label">Videók</string>
|
<string name="videos_shortcut_short_label">Videók</string>
|
||||||
<string name="analysis_notification_action_stop">Állj</string>
|
<string name="analysis_notification_action_stop">Állj</string>
|
||||||
<string name="app_widget_label">Fotó keret</string>
|
<string name="app_widget_label">Fotó keret</string>
|
||||||
<string name="analysis_channel_name">Tartalom keresése</string>
|
<string name="analysis_channel_name">Médiafájlok keresése</string>
|
||||||
<string name="analysis_notification_default_title">Média beolvasása</string>
|
<string name="analysis_notification_default_title">Média beolvasása</string>
|
||||||
<string name="map_shortcut_short_label">Térkép</string>
|
<string name="map_shortcut_short_label">Térkép</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="analysis_channel_name">Skönnun myndefnis</string>
|
<string name="analysis_channel_name">Skönnun myndefnis</string>
|
||||||
<string name="search_shortcut_short_label">Leita</string>
|
<string name="search_shortcut_short_label">Leita</string>
|
||||||
|
<string name="map_shortcut_short_label">Landakort</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_channel_name">Scansione media</string>
|
<string name="analysis_channel_name">Scansione media</string>
|
||||||
<string name="analysis_notification_default_title">Scansione in corso</string>
|
<string name="analysis_notification_default_title">Scansione in corso</string>
|
||||||
<string name="analysis_notification_action_stop">Annulla</string>
|
<string name="analysis_notification_action_stop">Annulla</string>
|
||||||
|
<string name="map_shortcut_short_label">Mappa</string>
|
||||||
</resources>
|
</resources>
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">אייבז</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="app_widget_label">מסגרת תמונה</string>
|
<string name="app_widget_label">מסגרת תמונה</string>
|
||||||
<string name="wallpaper">טפט</string>
|
<string name="wallpaper">טפט</string>
|
||||||
<string name="search_shortcut_short_label">חיפוש</string>
|
<string name="search_shortcut_short_label">חיפוש</string>
|
||||||
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_channel_name">סריקת מדיה</string>
|
<string name="analysis_channel_name">סריקת מדיה</string>
|
||||||
<string name="analysis_notification_default_title">סורק מדיה</string>
|
<string name="analysis_notification_default_title">סורק מדיה</string>
|
||||||
<string name="analysis_notification_action_stop">הפסק</string>
|
<string name="analysis_notification_action_stop">הפסק</string>
|
||||||
|
<string name="map_shortcut_short_label">מפה</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_channel_name">メディアスキャン</string>
|
<string name="analysis_channel_name">メディアスキャン</string>
|
||||||
<string name="analysis_notification_default_title">メディアをスキャン中</string>
|
<string name="analysis_notification_default_title">メディアをスキャン中</string>
|
||||||
<string name="analysis_notification_action_stop">停止</string>
|
<string name="analysis_notification_action_stop">停止</string>
|
||||||
|
<string name="map_shortcut_short_label">マップ</string>
|
||||||
</resources>
|
</resources>
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">ಎವೀಸ್</string>
|
||||||
<string name="app_widget_label">ಫೋಟೋ ಫ್ರೇಮ್</string>
|
<string name="app_widget_label">ಫೋಟೋ ಫ್ರೇಮ್</string>
|
||||||
<string name="wallpaper">ವಾಲ್ಪೇಪರ್</string>
|
<string name="wallpaper">ವಾಲ್ಪೇಪರ್</string>
|
||||||
<string name="videos_shortcut_short_label">ವೀಡಿಯೊಗಳು</string>
|
<string name="videos_shortcut_short_label">ವೀಡಿಯೊಗಳು</string>
|
||||||
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_notification_default_title">ಮೀಡಿಯಾ ಸ್ಕ್ಯಾನ್ ಮಾಡಲಾಗುತ್ತಿದೆ</string>
|
<string name="analysis_notification_default_title">ಮೀಡಿಯಾ ಸ್ಕ್ಯಾನ್ ಮಾಡಲಾಗುತ್ತಿದೆ</string>
|
||||||
<string name="analysis_notification_action_stop">ನಿಲ್ಲಿಸಿ</string>
|
<string name="analysis_notification_action_stop">ನಿಲ್ಲಿಸಿ</string>
|
||||||
<string name="search_shortcut_short_label">ಹುಡುಕಿ</string>
|
<string name="search_shortcut_short_label">ಹುಡುಕಿ</string>
|
||||||
|
<string name="map_shortcut_short_label">ನಕ್ಷೆ</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="videos_shortcut_short_label">ဗီဒီယိုများ</string>
|
<string name="videos_shortcut_short_label">ဗီဒီယိုများ</string>
|
||||||
<string name="analysis_notification_default_title">မီဒီယာ ကိုစကင်ဖတ်နေသည်</string>
|
<string name="analysis_notification_default_title">မီဒီယာ ကိုစကင်ဖတ်နေသည်</string>
|
||||||
<string name="analysis_notification_action_stop">ရပ်ရန်</string>
|
<string name="analysis_notification_action_stop">ရပ်ရန်</string>
|
||||||
|
<string name="map_shortcut_short_label">မြေပုံ</string>
|
||||||
</resources>
|
</resources>
|
4
android/app/src/main/res/values-ne/strings.xml
Normal file
4
android/app/src/main/res/values-ne/strings.xml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">एभस</string>
|
||||||
|
</resources>
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_notification_default_title">Scanarea suporturilor</string>
|
<string name="analysis_notification_default_title">Scanarea suporturilor</string>
|
||||||
<string name="analysis_notification_action_stop">Stop</string>
|
<string name="analysis_notification_action_stop">Stop</string>
|
||||||
<string name="search_shortcut_short_label">Căutare</string>
|
<string name="search_shortcut_short_label">Căutare</string>
|
||||||
|
<string name="map_shortcut_short_label">Hartă</string>
|
||||||
</resources>
|
</resources>
|
12
android/app/src/main/res/values-ta/strings.xml
Normal file
12
android/app/src/main/res/values-ta/strings.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">ஏவ்ச்</string>
|
||||||
|
<string name="app_widget_label">புகைப்பட சட்டகம்</string>
|
||||||
|
<string name="wallpaper">வால்பேப்பர்</string>
|
||||||
|
<string name="map_shortcut_short_label">வரைபடம்</string>
|
||||||
|
<string name="search_shortcut_short_label">தேடல்</string>
|
||||||
|
<string name="analysis_notification_default_title">ஊடக ச்கேனிங்</string>
|
||||||
|
<string name="videos_shortcut_short_label">வீடியோக்கள்</string>
|
||||||
|
<string name="analysis_channel_name">மீடியா ச்கேன்</string>
|
||||||
|
<string name="analysis_notification_action_stop">நிறுத்து</string>
|
||||||
|
</resources>
|
4
android/app/src/main/res/values-ur/strings.xml
Normal file
4
android/app/src/main/res/values-ur/strings.xml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Aves</string>
|
||||||
|
</resources>
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="search_shortcut_short_label">搜尋</string>
|
<string name="search_shortcut_short_label">搜尋</string>
|
||||||
<string name="analysis_channel_name">媒體掃描</string>
|
<string name="analysis_channel_name">媒體掃描</string>
|
||||||
<string name="analysis_notification_action_stop">停止</string>
|
<string name="analysis_notification_action_stop">停止</string>
|
||||||
|
<string name="map_shortcut_short_label">地圖</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_channel_name">媒体扫描</string>
|
<string name="analysis_channel_name">媒体扫描</string>
|
||||||
<string name="analysis_notification_default_title">正在扫描媒体</string>
|
<string name="analysis_notification_default_title">正在扫描媒体</string>
|
||||||
<string name="analysis_notification_action_stop">停止</string>
|
<string name="analysis_notification_action_stop">停止</string>
|
||||||
|
<string name="map_shortcut_short_label">地图</string>
|
||||||
</resources>
|
</resources>
|
|
@ -13,8 +13,8 @@ buildscript {
|
||||||
dependencies {
|
dependencies {
|
||||||
if (useCrashlytics) {
|
if (useCrashlytics) {
|
||||||
// GMS & Firebase Crashlytics (used by some flavors only)
|
// GMS & Firebase Crashlytics (used by some flavors only)
|
||||||
classpath 'com.google.gms:google-services:4.4.1'
|
classpath 'com.google.gms:google-services:4.4.2'
|
||||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9'
|
classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.2'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,10 +22,10 @@ import static androidx.exifinterface.media.ExifInterfaceUtilsFork.convertToLongA
|
||||||
import static androidx.exifinterface.media.ExifInterfaceUtilsFork.copy;
|
import static androidx.exifinterface.media.ExifInterfaceUtilsFork.copy;
|
||||||
import static androidx.exifinterface.media.ExifInterfaceUtilsFork.parseSubSeconds;
|
import static androidx.exifinterface.media.ExifInterfaceUtilsFork.parseSubSeconds;
|
||||||
import static androidx.exifinterface.media.ExifInterfaceUtilsFork.startsWith;
|
import static androidx.exifinterface.media.ExifInterfaceUtilsFork.startsWith;
|
||||||
|
|
||||||
import static java.lang.annotation.ElementType.TYPE_USE;
|
import static java.lang.annotation.ElementType.TYPE_USE;
|
||||||
import static java.nio.ByteOrder.BIG_ENDIAN;
|
import static java.nio.ByteOrder.BIG_ENDIAN;
|
||||||
import static java.nio.ByteOrder.LITTLE_ENDIAN;
|
import static java.nio.ByteOrder.LITTLE_ENDIAN;
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.content.res.AssetManager;
|
import android.content.res.AssetManager;
|
||||||
|
@ -54,6 +54,7 @@ import java.io.ByteArrayInputStream;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.DataInput;
|
import java.io.DataInput;
|
||||||
import java.io.DataInputStream;
|
import java.io.DataInputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
import java.io.EOFException;
|
import java.io.EOFException;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileDescriptor;
|
import java.io.FileDescriptor;
|
||||||
|
@ -89,8 +90,9 @@ import java.util.regex.Pattern;
|
||||||
import java.util.zip.CRC32;
|
import java.util.zip.CRC32;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Forked from 'androidx.exifinterface:exifinterface:1.4.0-alpha01' on 2024/11/17
|
* Forked from 'androidx.exifinterface:exifinterface:1.4.1'
|
||||||
* Named differently to let ExifInterface be loaded as subdependency.
|
* 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
|
* cf https://github.com/androidx/androidx/tree/androidx-main/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -136,6 +138,12 @@ public class ExifInterfaceFork {
|
||||||
// TLAD threshold for safer Exif attribute parsing
|
// TLAD threshold for safer Exif attribute parsing
|
||||||
private static final int ATTRIBUTE_SIZE_DANGER_THRESHOLD = 3 * (1 << 20); // MB
|
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 String TAG = "ExifInterface";
|
||||||
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
|
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
|
||||||
|
|
||||||
|
@ -190,6 +198,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #DATA_UNCOMPRESSED
|
* @see #DATA_UNCOMPRESSED
|
||||||
* @see #DATA_JPEG
|
* @see #DATA_JPEG
|
||||||
*/
|
*/
|
||||||
|
@ -205,6 +214,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #PHOTOMETRIC_INTERPRETATION_RGB
|
* @see #PHOTOMETRIC_INTERPRETATION_RGB
|
||||||
* @see #PHOTOMETRIC_INTERPRETATION_YCBCR
|
* @see #PHOTOMETRIC_INTERPRETATION_YCBCR
|
||||||
*/
|
*/
|
||||||
|
@ -219,6 +229,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #ORIENTATION_NORMAL}</li>
|
* <li>Default = {@link #ORIENTATION_NORMAL}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #ORIENTATION_UNDEFINED
|
* @see #ORIENTATION_UNDEFINED
|
||||||
* @see #ORIENTATION_NORMAL
|
* @see #ORIENTATION_NORMAL
|
||||||
* @see #ORIENTATION_FLIP_HORIZONTAL
|
* @see #ORIENTATION_FLIP_HORIZONTAL
|
||||||
|
@ -254,6 +265,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Count = 1</li>
|
* <li>Count = 1</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #FORMAT_CHUNKY
|
* @see #FORMAT_CHUNKY
|
||||||
* @see #FORMAT_PLANAR
|
* @see #FORMAT_PLANAR
|
||||||
*/
|
*/
|
||||||
|
@ -294,6 +306,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #Y_CB_CR_POSITIONING_CENTERED}</li>
|
* <li>Default = {@link #Y_CB_CR_POSITIONING_CENTERED}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #Y_CB_CR_POSITIONING_CENTERED
|
* @see #Y_CB_CR_POSITIONING_CENTERED
|
||||||
* @see #Y_CB_CR_POSITIONING_CO_SITED
|
* @see #Y_CB_CR_POSITIONING_CO_SITED
|
||||||
*/
|
*/
|
||||||
|
@ -309,6 +322,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = 72</li>
|
* <li>Default = 72</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #TAG_Y_RESOLUTION
|
* @see #TAG_Y_RESOLUTION
|
||||||
* @see #TAG_RESOLUTION_UNIT
|
* @see #TAG_RESOLUTION_UNIT
|
||||||
*/
|
*/
|
||||||
|
@ -324,6 +338,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = 72</li>
|
* <li>Default = 72</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #TAG_X_RESOLUTION
|
* @see #TAG_X_RESOLUTION
|
||||||
* @see #TAG_RESOLUTION_UNIT
|
* @see #TAG_RESOLUTION_UNIT
|
||||||
*/
|
*/
|
||||||
|
@ -340,6 +355,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li>
|
* <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #RESOLUTION_UNIT_INCHES
|
* @see #RESOLUTION_UNIT_INCHES
|
||||||
* @see #RESOLUTION_UNIT_CENTIMETERS
|
* @see #RESOLUTION_UNIT_CENTIMETERS
|
||||||
* @see #TAG_X_RESOLUTION
|
* @see #TAG_X_RESOLUTION
|
||||||
|
@ -365,6 +381,7 @@ public class ExifInterfaceFork {
|
||||||
* <p>StripsPerImage = floor(({@link #TAG_IMAGE_LENGTH} + {@link #TAG_ROWS_PER_STRIP} - 1)
|
* <p>StripsPerImage = floor(({@link #TAG_IMAGE_LENGTH} + {@link #TAG_ROWS_PER_STRIP} - 1)
|
||||||
* / {@link #TAG_ROWS_PER_STRIP})</p>
|
* / {@link #TAG_ROWS_PER_STRIP})</p>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #TAG_ROWS_PER_STRIP
|
* @see #TAG_ROWS_PER_STRIP
|
||||||
* @see #TAG_STRIP_BYTE_COUNTS
|
* @see #TAG_STRIP_BYTE_COUNTS
|
||||||
*/
|
*/
|
||||||
|
@ -381,6 +398,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #TAG_STRIP_OFFSETS
|
* @see #TAG_STRIP_OFFSETS
|
||||||
* @see #TAG_STRIP_BYTE_COUNTS
|
* @see #TAG_STRIP_BYTE_COUNTS
|
||||||
*/
|
*/
|
||||||
|
@ -656,6 +674,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Count = 1</li>
|
* <li>Count = 1</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #COLOR_SPACE_S_RGB
|
* @see #COLOR_SPACE_S_RGB
|
||||||
* @see #COLOR_SPACE_UNCALIBRATED
|
* @see #COLOR_SPACE_UNCALIBRATED
|
||||||
*/
|
*/
|
||||||
|
@ -962,6 +981,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #EXPOSURE_PROGRAM_NOT_DEFINED}</li>
|
* <li>Default = {@link #EXPOSURE_PROGRAM_NOT_DEFINED}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #EXPOSURE_PROGRAM_NOT_DEFINED
|
* @see #EXPOSURE_PROGRAM_NOT_DEFINED
|
||||||
* @see #EXPOSURE_PROGRAM_MANUAL
|
* @see #EXPOSURE_PROGRAM_MANUAL
|
||||||
* @see #EXPOSURE_PROGRAM_NORMAL
|
* @see #EXPOSURE_PROGRAM_NORMAL
|
||||||
|
@ -1031,6 +1051,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SENSITIVITY_TYPE_UNKNOWN
|
* @see #SENSITIVITY_TYPE_UNKNOWN
|
||||||
* @see #SENSITIVITY_TYPE_SOS
|
* @see #SENSITIVITY_TYPE_SOS
|
||||||
* @see #SENSITIVITY_TYPE_REI
|
* @see #SENSITIVITY_TYPE_REI
|
||||||
|
@ -1197,6 +1218,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #METERING_MODE_UNKNOWN}</li>
|
* <li>Default = {@link #METERING_MODE_UNKNOWN}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #METERING_MODE_UNKNOWN
|
* @see #METERING_MODE_UNKNOWN
|
||||||
* @see #METERING_MODE_AVERAGE
|
* @see #METERING_MODE_AVERAGE
|
||||||
* @see #METERING_MODE_CENTER_WEIGHT_AVERAGE
|
* @see #METERING_MODE_CENTER_WEIGHT_AVERAGE
|
||||||
|
@ -1217,6 +1239,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #LIGHT_SOURCE_UNKNOWN}</li>
|
* <li>Default = {@link #LIGHT_SOURCE_UNKNOWN}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #LIGHT_SOURCE_UNKNOWN
|
* @see #LIGHT_SOURCE_UNKNOWN
|
||||||
* @see #LIGHT_SOURCE_DAYLIGHT
|
* @see #LIGHT_SOURCE_DAYLIGHT
|
||||||
* @see #LIGHT_SOURCE_FLUORESCENT
|
* @see #LIGHT_SOURCE_FLUORESCENT
|
||||||
|
@ -1253,6 +1276,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Count = 1</li>
|
* <li>Count = 1</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #FLAG_FLASH_FIRED
|
* @see #FLAG_FLASH_FIRED
|
||||||
* @see #FLAG_FLASH_RETURN_LIGHT_NOT_DETECTED
|
* @see #FLAG_FLASH_RETURN_LIGHT_NOT_DETECTED
|
||||||
* @see #FLAG_FLASH_RETURN_LIGHT_DETECTED
|
* @see #FLAG_FLASH_RETURN_LIGHT_DETECTED
|
||||||
|
@ -1365,6 +1389,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li>
|
* <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #TAG_RESOLUTION_UNIT
|
* @see #TAG_RESOLUTION_UNIT
|
||||||
* @see #RESOLUTION_UNIT_INCHES
|
* @see #RESOLUTION_UNIT_INCHES
|
||||||
* @see #RESOLUTION_UNIT_CENTIMETERS
|
* @see #RESOLUTION_UNIT_CENTIMETERS
|
||||||
|
@ -1407,6 +1432,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SENSOR_TYPE_NOT_DEFINED
|
* @see #SENSOR_TYPE_NOT_DEFINED
|
||||||
* @see #SENSOR_TYPE_ONE_CHIP
|
* @see #SENSOR_TYPE_ONE_CHIP
|
||||||
* @see #SENSOR_TYPE_TWO_CHIP
|
* @see #SENSOR_TYPE_TWO_CHIP
|
||||||
|
@ -1427,6 +1453,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #FILE_SOURCE_DSC}</li>
|
* <li>Default = {@link #FILE_SOURCE_DSC}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #FILE_SOURCE_OTHER
|
* @see #FILE_SOURCE_OTHER
|
||||||
* @see #FILE_SOURCE_TRANSPARENT_SCANNER
|
* @see #FILE_SOURCE_TRANSPARENT_SCANNER
|
||||||
* @see #FILE_SOURCE_REFLEX_SCANNER
|
* @see #FILE_SOURCE_REFLEX_SCANNER
|
||||||
|
@ -1444,6 +1471,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = 1</li>
|
* <li>Default = 1</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SCENE_TYPE_DIRECTLY_PHOTOGRAPHED
|
* @see #SCENE_TYPE_DIRECTLY_PHOTOGRAPHED
|
||||||
*/
|
*/
|
||||||
public static final String TAG_SCENE_TYPE = "SceneType";
|
public static final String TAG_SCENE_TYPE = "SceneType";
|
||||||
|
@ -1457,6 +1485,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #TAG_SENSING_METHOD
|
* @see #TAG_SENSING_METHOD
|
||||||
* @see #SENSOR_TYPE_ONE_CHIP
|
* @see #SENSOR_TYPE_ONE_CHIP
|
||||||
*/
|
*/
|
||||||
|
@ -1473,6 +1502,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #RENDERED_PROCESS_NORMAL}</li>
|
* <li>Default = {@link #RENDERED_PROCESS_NORMAL}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #RENDERED_PROCESS_NORMAL
|
* @see #RENDERED_PROCESS_NORMAL
|
||||||
* @see #RENDERED_PROCESS_CUSTOM
|
* @see #RENDERED_PROCESS_CUSTOM
|
||||||
*/
|
*/
|
||||||
|
@ -1489,6 +1519,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #EXPOSURE_MODE_AUTO
|
* @see #EXPOSURE_MODE_AUTO
|
||||||
* @see #EXPOSURE_MODE_MANUAL
|
* @see #EXPOSURE_MODE_MANUAL
|
||||||
* @see #EXPOSURE_MODE_AUTO_BRACKET
|
* @see #EXPOSURE_MODE_AUTO_BRACKET
|
||||||
|
@ -1504,6 +1535,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #WHITEBALANCE_AUTO
|
* @see #WHITEBALANCE_AUTO
|
||||||
* @see #WHITEBALANCE_MANUAL
|
* @see #WHITEBALANCE_MANUAL
|
||||||
*/
|
*/
|
||||||
|
@ -1553,6 +1585,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = 0</li>
|
* <li>Default = 0</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SCENE_CAPTURE_TYPE_STANDARD
|
* @see #SCENE_CAPTURE_TYPE_STANDARD
|
||||||
* @see #SCENE_CAPTURE_TYPE_LANDSCAPE
|
* @see #SCENE_CAPTURE_TYPE_LANDSCAPE
|
||||||
* @see #SCENE_CAPTURE_TYPE_PORTRAIT
|
* @see #SCENE_CAPTURE_TYPE_PORTRAIT
|
||||||
|
@ -1569,6 +1602,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GAIN_CONTROL_NONE
|
* @see #GAIN_CONTROL_NONE
|
||||||
* @see #GAIN_CONTROL_LOW_GAIN_UP
|
* @see #GAIN_CONTROL_LOW_GAIN_UP
|
||||||
* @see #GAIN_CONTROL_HIGH_GAIN_UP
|
* @see #GAIN_CONTROL_HIGH_GAIN_UP
|
||||||
|
@ -1587,6 +1621,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #CONTRAST_NORMAL}</li>
|
* <li>Default = {@link #CONTRAST_NORMAL}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #CONTRAST_NORMAL
|
* @see #CONTRAST_NORMAL
|
||||||
* @see #CONTRAST_SOFT
|
* @see #CONTRAST_SOFT
|
||||||
* @see #CONTRAST_HARD
|
* @see #CONTRAST_HARD
|
||||||
|
@ -1603,6 +1638,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #SATURATION_NORMAL}</li>
|
* <li>Default = {@link #SATURATION_NORMAL}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SATURATION_NORMAL
|
* @see #SATURATION_NORMAL
|
||||||
* @see #SATURATION_LOW
|
* @see #SATURATION_LOW
|
||||||
* @see #SATURATION_HIGH
|
* @see #SATURATION_HIGH
|
||||||
|
@ -1619,6 +1655,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #SHARPNESS_NORMAL}</li>
|
* <li>Default = {@link #SHARPNESS_NORMAL}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SHARPNESS_NORMAL
|
* @see #SHARPNESS_NORMAL
|
||||||
* @see #SHARPNESS_SOFT
|
* @see #SHARPNESS_SOFT
|
||||||
* @see #SHARPNESS_HARD
|
* @see #SHARPNESS_HARD
|
||||||
|
@ -1646,6 +1683,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SUBJECT_DISTANCE_RANGE_UNKNOWN
|
* @see #SUBJECT_DISTANCE_RANGE_UNKNOWN
|
||||||
* @see #SUBJECT_DISTANCE_RANGE_MACRO
|
* @see #SUBJECT_DISTANCE_RANGE_MACRO
|
||||||
* @see #SUBJECT_DISTANCE_RANGE_CLOSE_VIEW
|
* @see #SUBJECT_DISTANCE_RANGE_CLOSE_VIEW
|
||||||
|
@ -1675,6 +1713,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @deprecated Use {@link #TAG_CAMERA_OWNER_NAME} instead.
|
* @deprecated Use {@link #TAG_CAMERA_OWNER_NAME} instead.
|
||||||
*/
|
*/
|
||||||
@Deprecated
|
@Deprecated
|
||||||
|
@ -1780,6 +1819,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #LATITUDE_NORTH
|
* @see #LATITUDE_NORTH
|
||||||
* @see #LATITUDE_SOUTH
|
* @see #LATITUDE_SOUTH
|
||||||
*/
|
*/
|
||||||
|
@ -1809,6 +1849,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #LONGITUDE_EAST
|
* @see #LONGITUDE_EAST
|
||||||
* @see #LONGITUDE_WEST
|
* @see #LONGITUDE_WEST
|
||||||
*/
|
*/
|
||||||
|
@ -1841,6 +1882,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = 0</li>
|
* <li>Default = 0</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #ALTITUDE_ABOVE_SEA_LEVEL
|
* @see #ALTITUDE_ABOVE_SEA_LEVEL
|
||||||
* @see #ALTITUDE_BELOW_SEA_LEVEL
|
* @see #ALTITUDE_BELOW_SEA_LEVEL
|
||||||
*/
|
*/
|
||||||
|
@ -1899,6 +1941,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_MEASUREMENT_IN_PROGRESS
|
* @see #GPS_MEASUREMENT_IN_PROGRESS
|
||||||
* @see #GPS_MEASUREMENT_INTERRUPTED
|
* @see #GPS_MEASUREMENT_INTERRUPTED
|
||||||
*/
|
*/
|
||||||
|
@ -1915,6 +1958,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_MEASUREMENT_2D
|
* @see #GPS_MEASUREMENT_2D
|
||||||
* @see #GPS_MEASUREMENT_3D
|
* @see #GPS_MEASUREMENT_3D
|
||||||
*/
|
*/
|
||||||
|
@ -1941,6 +1985,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #GPS_SPEED_KILOMETERS_PER_HOUR}</li>
|
* <li>Default = {@link #GPS_SPEED_KILOMETERS_PER_HOUR}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_SPEED_KILOMETERS_PER_HOUR
|
* @see #GPS_SPEED_KILOMETERS_PER_HOUR
|
||||||
* @see #GPS_SPEED_MILES_PER_HOUR
|
* @see #GPS_SPEED_MILES_PER_HOUR
|
||||||
* @see #GPS_SPEED_KNOTS
|
* @see #GPS_SPEED_KNOTS
|
||||||
|
@ -1968,6 +2013,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
|
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_DIRECTION_TRUE
|
* @see #GPS_DIRECTION_TRUE
|
||||||
* @see #GPS_DIRECTION_MAGNETIC
|
* @see #GPS_DIRECTION_MAGNETIC
|
||||||
*/
|
*/
|
||||||
|
@ -1994,6 +2040,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
|
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_DIRECTION_TRUE
|
* @see #GPS_DIRECTION_TRUE
|
||||||
* @see #GPS_DIRECTION_MAGNETIC
|
* @see #GPS_DIRECTION_MAGNETIC
|
||||||
*/
|
*/
|
||||||
|
@ -2032,6 +2079,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #LATITUDE_NORTH
|
* @see #LATITUDE_NORTH
|
||||||
* @see #LATITUDE_SOUTH
|
* @see #LATITUDE_SOUTH
|
||||||
*/
|
*/
|
||||||
|
@ -2061,6 +2109,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #LONGITUDE_EAST
|
* @see #LONGITUDE_EAST
|
||||||
* @see #LONGITUDE_WEST
|
* @see #LONGITUDE_WEST
|
||||||
*/
|
*/
|
||||||
|
@ -2090,6 +2139,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
|
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_DIRECTION_TRUE
|
* @see #GPS_DIRECTION_TRUE
|
||||||
* @see #GPS_DIRECTION_MAGNETIC
|
* @see #GPS_DIRECTION_MAGNETIC
|
||||||
*/
|
*/
|
||||||
|
@ -2116,6 +2166,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #GPS_DISTANCE_KILOMETERS}</li>
|
* <li>Default = {@link #GPS_DISTANCE_KILOMETERS}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_DISTANCE_KILOMETERS
|
* @see #GPS_DISTANCE_KILOMETERS
|
||||||
* @see #GPS_DISTANCE_MILES
|
* @see #GPS_DISTANCE_MILES
|
||||||
* @see #GPS_DISTANCE_NAUTICAL_MILES
|
* @see #GPS_DISTANCE_NAUTICAL_MILES
|
||||||
|
@ -2177,6 +2228,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_MEASUREMENT_NO_DIFFERENTIAL
|
* @see #GPS_MEASUREMENT_NO_DIFFERENTIAL
|
||||||
* @see #GPS_MEASUREMENT_DIFFERENTIAL_CORRECTED
|
* @see #GPS_MEASUREMENT_DIFFERENTIAL_CORRECTED
|
||||||
*/
|
*/
|
||||||
|
@ -3132,11 +3184,18 @@ public class ExifInterfaceFork {
|
||||||
// See "Extensions to the PNG 1.2 Specification, Version 1.5.0",
|
// See "Extensions to the PNG 1.2 Specification, Version 1.5.0",
|
||||||
// 3.7. eXIf Exchangeable Image File (Exif) Profile
|
// 3.7. eXIf Exchangeable Image File (Exif) Profile
|
||||||
private static final int PNG_CHUNK_TYPE_EXIF = 'e' << 24 | 'X' << 16 | 'I' << 8 | 'f';
|
private static final int PNG_CHUNK_TYPE_EXIF = 'e' << 24 | 'X' << 16 | 'I' << 8 | 'f';
|
||||||
|
// See "XMP Specification Part 3: Storage in Files" section 1.1.5
|
||||||
|
private static final int PNG_CHUNK_TYPE_ITXT = 'i' << 24 | 'T' << 16 | 'X' << 8 | 't';
|
||||||
private static final int PNG_CHUNK_TYPE_IHDR = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R';
|
private static final int PNG_CHUNK_TYPE_IHDR = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R';
|
||||||
private static final int PNG_CHUNK_TYPE_IEND = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D';
|
private static final int PNG_CHUNK_TYPE_IEND = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D';
|
||||||
private static final int PNG_CHUNK_TYPE_BYTE_LENGTH = 4;
|
|
||||||
private static final int PNG_CHUNK_CRC_BYTE_LENGTH = 4;
|
private static final int PNG_CHUNK_CRC_BYTE_LENGTH = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keyword and 5 null bytes defined by XMP spec part 3 table 9 (section 1.1.5).
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
static final byte[] PNG_ITXT_XMP_KEYWORD = "XML:com.adobe.xmp\0\0\0\0\0".getBytes(UTF_8);
|
||||||
|
|
||||||
// See https://developers.google.com/speed/webp/docs/riff_container, Section "WebP File Header"
|
// See https://developers.google.com/speed/webp/docs/riff_container, Section "WebP File Header"
|
||||||
private static final byte[] WEBP_SIGNATURE_1 = new byte[]{'R', 'I', 'F', 'F'};
|
private static final byte[] WEBP_SIGNATURE_1 = new byte[]{'R', 'I', 'F', 'F'};
|
||||||
private static final byte[] WEBP_SIGNATURE_2 = new byte[]{'W', 'E', 'B', 'P'};
|
private static final byte[] WEBP_SIGNATURE_2 = new byte[]{'W', 'E', 'B', 'P'};
|
||||||
|
@ -4069,20 +4128,33 @@ public class ExifInterfaceFork {
|
||||||
// Used to indicate offset from the start of the original input stream to EXIF data
|
// Used to indicate offset from the start of the original input stream to EXIF data
|
||||||
private int mOffsetToExifData;
|
private int mOffsetToExifData;
|
||||||
private int mOrfMakerNoteOffset;
|
private int mOrfMakerNoteOffset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The position of the thumbnail within the Exif data (from {@link #mOffsetToExifData}).
|
||||||
|
*/
|
||||||
private int mOrfThumbnailOffset;
|
private int mOrfThumbnailOffset;
|
||||||
|
|
||||||
private int mOrfThumbnailLength;
|
private int mOrfThumbnailLength;
|
||||||
private boolean mModified;
|
private boolean mModified;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* XMP data can occur as either part of the TIFF/Exif data (tag number 700), or as a separate
|
* XMP data can occur as either part of the TIFF/Exif data (tag number 700), or as a separate
|
||||||
* section of the file (e.g. a separate APP1 segment in JPEG). XMP read from within the
|
* section of the file (e.g. a separate APP1 segment in JPEG, or an iTXt chunk in PNG). XMP read
|
||||||
* TIFF/Exif data is stored in {@link #mAttributes}, while XMP read from a separate section is
|
* from within the TIFF/Exif data is stored in {@link #mAttributes}, while XMP read from a
|
||||||
* here. If both are present, the disambiguation rules vary per file format, see
|
* separate section is here. If both are present, the disambiguation rules vary per file format,
|
||||||
* {@link #getXmpHandlingForImageType(int)}.
|
* see {@link #getXmpHandlingForImageType(int)}.
|
||||||
*/
|
*/
|
||||||
@Nullable
|
@Nullable
|
||||||
private ExifAttribute mXmpFromSeparateMarker;
|
private ExifAttribute mXmpFromSeparateMarker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the file on disk contains XMP in a separate section.
|
||||||
|
*
|
||||||
|
* <p>This means the file the instance was loaded with, or the file created by the last call to
|
||||||
|
* {@link #saveAttributes()}.
|
||||||
|
*/
|
||||||
|
private boolean mFileOnDiskContainsSeparateXmpMarker;
|
||||||
|
|
||||||
// Pattern to check non zero timestamp
|
// Pattern to check non zero timestamp
|
||||||
private static final Pattern NON_ZERO_TIME_PATTERN = Pattern.compile(".*[1-9].*");
|
private static final Pattern NON_ZERO_TIME_PATTERN = Pattern.compile(".*[1-9].*");
|
||||||
// Pattern to check gps timestamp
|
// Pattern to check gps timestamp
|
||||||
|
@ -4300,6 +4372,7 @@ public class ExifInterfaceFork {
|
||||||
return XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT;
|
return XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT;
|
||||||
case IMAGE_TYPE_AVIF:
|
case IMAGE_TYPE_AVIF:
|
||||||
case IMAGE_TYPE_HEIC:
|
case IMAGE_TYPE_HEIC:
|
||||||
|
case IMAGE_TYPE_PNG:
|
||||||
// RAF stores XMP/Exif in JPEG, but we have no documented backwards-compat obligations
|
// RAF stores XMP/Exif in JPEG, but we have no documented backwards-compat obligations
|
||||||
// so we can implement the spec to store XMP in a separate APP1 segment.
|
// so we can implement the spec to store XMP in a separate APP1 segment.
|
||||||
case IMAGE_TYPE_RAF:
|
case IMAGE_TYPE_RAF:
|
||||||
|
@ -4309,10 +4382,8 @@ public class ExifInterfaceFork {
|
||||||
case IMAGE_TYPE_PEF:
|
case IMAGE_TYPE_PEF:
|
||||||
case IMAGE_TYPE_RW2:
|
case IMAGE_TYPE_RW2:
|
||||||
case IMAGE_TYPE_UNKNOWN:
|
case IMAGE_TYPE_UNKNOWN:
|
||||||
// PNG and WebP support a separate XMP chunk (so should be
|
// WebP supports a separate XMP chunk (so should be XMP_HANDLING_PREFER_SEPARATE), but
|
||||||
// XMP_HANDLING_PREFER_SEPARATE), but ExifInterface doesn't currently read or write
|
// ExifInterface doesn't currently read or write it.
|
||||||
// them.
|
|
||||||
case IMAGE_TYPE_PNG:
|
|
||||||
case IMAGE_TYPE_WEBP:
|
case IMAGE_TYPE_WEBP:
|
||||||
default:
|
default:
|
||||||
return XMP_HANDLING_TIFF_700_ONLY;
|
return XMP_HANDLING_TIFF_700_ONLY;
|
||||||
|
@ -4487,7 +4558,7 @@ public class ExifInterfaceFork {
|
||||||
&& (mXmpFromSeparateMarker != null || !containsTiff700Xmp))
|
&& (mXmpFromSeparateMarker != null || !containsTiff700Xmp))
|
||||||
|| (xmpHandling == XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT
|
|| (xmpHandling == XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT
|
||||||
&& !containsTiff700Xmp)) {
|
&& !containsTiff700Xmp)) {
|
||||||
mXmpFromSeparateMarker = ExifAttribute.createByte(value);
|
mXmpFromSeparateMarker = value != null ? ExifAttribute.createByte(value) : null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5160,14 +5231,18 @@ public class ExifInterfaceFork {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the offset and length of the requested tag inside the image file,
|
* Returns the offset and length of the requested tag inside the image file, or {@code null} if
|
||||||
* or {@code null} if the tag is not contained.
|
* the tag is not contained.
|
||||||
*
|
*
|
||||||
* @return two-element array, the offset in the first value, and length in
|
* <p>If the attribute has been modified with {@link #setAttribute(String, String)} but not yet
|
||||||
* the second, or {@code null} if no tag was found.
|
* written to disk with {@link #saveAttributes()}, the returned range will have the correct
|
||||||
* @throws IllegalStateException if {@link #saveAttributes()} has been
|
* length for the modified value, but an offset of {@code -1} to indicate its position in the
|
||||||
* called since the underlying file was initially parsed, since
|
* file isn't known.
|
||||||
* that means offsets may have changed.
|
*
|
||||||
|
* @return two-element array, the offset in the first value, and length in the second, or {@code
|
||||||
|
* null} if no tag was found.
|
||||||
|
* @throws IllegalStateException if {@link #saveAttributes()} has been called since the
|
||||||
|
* underlying file was initially parsed, since that means offsets may have changed.
|
||||||
*/
|
*/
|
||||||
public long @Nullable [] getAttributeRange(@NonNull String tag) {
|
public long @Nullable [] getAttributeRange(@NonNull String tag) {
|
||||||
if (tag == null) {
|
if (tag == null) {
|
||||||
|
@ -5841,6 +5916,7 @@ public class ExifInterfaceFork {
|
||||||
IDENTIFIER_XMP_APP1.length, bytes.length);
|
IDENTIFIER_XMP_APP1.length, bytes.length);
|
||||||
mXmpFromSeparateMarker =
|
mXmpFromSeparateMarker =
|
||||||
new ExifAttribute(IFD_FORMAT_BYTE, value.length, offset, value);
|
new ExifAttribute(IFD_FORMAT_BYTE, value.length, offset, value);
|
||||||
|
mFileOnDiskContainsSeparateXmpMarker = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -6165,6 +6241,7 @@ public class ExifInterfaceFork {
|
||||||
in.readFully(xmpBytes);
|
in.readFully(xmpBytes);
|
||||||
mXmpFromSeparateMarker =
|
mXmpFromSeparateMarker =
|
||||||
new ExifAttribute(IFD_FORMAT_BYTE, xmpBytes.length, offset, xmpBytes);
|
new ExifAttribute(IFD_FORMAT_BYTE, xmpBytes.length, offset, xmpBytes);
|
||||||
|
mFileOnDiskContainsSeparateXmpMarker = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
|
@ -6352,10 +6429,12 @@ public class ExifInterfaceFork {
|
||||||
// See PNG (Portable Network Graphics) Specification, Version 1.2,
|
// See PNG (Portable Network Graphics) Specification, Version 1.2,
|
||||||
// 3.2. Chunk layout
|
// 3.2. Chunk layout
|
||||||
try {
|
try {
|
||||||
while (true) {
|
boolean foundExif = false;
|
||||||
|
boolean foundXmpItxt = false;
|
||||||
|
while (!foundExif || !foundXmpItxt) {
|
||||||
int length = in.readInt();
|
int length = in.readInt();
|
||||||
|
|
||||||
int type = in.readInt();
|
int type = in.readInt();
|
||||||
|
int startOfNextChunk = in.position() + length + PNG_CHUNK_CRC_BYTE_LENGTH;
|
||||||
|
|
||||||
// The first chunk must be the IHDR chunk
|
// The first chunk must be the IHDR chunk
|
||||||
if (in.position() - startPosition == 16 && type != PNG_CHUNK_TYPE_IHDR) {
|
if (in.position() - startPosition == 16 && type != PNG_CHUNK_TYPE_IHDR) {
|
||||||
|
@ -6367,7 +6446,7 @@ public class ExifInterfaceFork {
|
||||||
if (type == PNG_CHUNK_TYPE_IEND) {
|
if (type == PNG_CHUNK_TYPE_IEND) {
|
||||||
// IEND marks the end of the image.
|
// IEND marks the end of the image.
|
||||||
break;
|
break;
|
||||||
} else if (type == PNG_CHUNK_TYPE_EXIF) {
|
} else if (type == PNG_CHUNK_TYPE_EXIF && !foundExif) {
|
||||||
// Save offset to EXIF data for handling thumbnail and attribute offsets.
|
// Save offset to EXIF data for handling thumbnail and attribute offsets.
|
||||||
mOffsetToExifData = in.position() - startPosition;
|
mOffsetToExifData = in.position() - startPosition;
|
||||||
|
|
||||||
|
@ -6388,20 +6467,40 @@ public class ExifInterfaceFork {
|
||||||
updateCrcWithInt(crc, type);
|
updateCrcWithInt(crc, type);
|
||||||
crc.update(data);
|
crc.update(data);
|
||||||
if ((int) crc.getValue() != dataCrcValue) {
|
if ((int) crc.getValue() != dataCrcValue) {
|
||||||
throw new IOException("Encountered invalid CRC value for PNG-EXIF chunk."
|
throw new IOException(
|
||||||
+ "\n recorded CRC value: " + dataCrcValue + ", calculated CRC "
|
"Encountered invalid CRC value for PNG-EXIF chunk."
|
||||||
+ "value: " + crc.getValue());
|
+ "\n recorded CRC value: "
|
||||||
|
+ dataCrcValue
|
||||||
|
+ ", calculated CRC "
|
||||||
|
+ "value: "
|
||||||
|
+ crc.getValue());
|
||||||
}
|
}
|
||||||
readExifSegment(data, IFD_TYPE_PRIMARY);
|
readExifSegment(data, IFD_TYPE_PRIMARY);
|
||||||
validateImages();
|
validateImages();
|
||||||
|
|
||||||
setThumbnailData(new ByteOrderedDataInputStream(data));
|
setThumbnailData(new ByteOrderedDataInputStream(data));
|
||||||
break;
|
foundExif = true;
|
||||||
} else {
|
} else if (type == PNG_CHUNK_TYPE_ITXT
|
||||||
// Skip to next chunk
|
&& !foundXmpItxt
|
||||||
in.skipFully(length + PNG_CHUNK_CRC_BYTE_LENGTH);
|
&& length >= PNG_ITXT_XMP_KEYWORD.length) {
|
||||||
|
// Read the 17 byte keyword and 5 expected null bytes.
|
||||||
|
byte[] keyword = new byte[PNG_ITXT_XMP_KEYWORD.length];
|
||||||
|
in.readFully(keyword);
|
||||||
|
if (Arrays.equals(keyword, PNG_ITXT_XMP_KEYWORD)) {
|
||||||
|
int xmpDataOffset = in.position() - startPosition;
|
||||||
|
int xmpLength = length - keyword.length;
|
||||||
|
byte[] xmpData = new byte[xmpLength];
|
||||||
|
in.readFully(xmpData);
|
||||||
|
mXmpFromSeparateMarker =
|
||||||
|
new ExifAttribute(
|
||||||
|
IFD_FORMAT_BYTE, xmpLength, xmpDataOffset, xmpData);
|
||||||
|
foundXmpItxt = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Skip to next chunk
|
||||||
|
in.skipFully(startOfNextChunk - in.position());
|
||||||
}
|
}
|
||||||
|
mFileOnDiskContainsSeparateXmpMarker = foundXmpItxt;
|
||||||
} catch (EOFException e) {
|
} catch (EOFException e) {
|
||||||
// Should not reach here. Will only reach here if the file is corrupted or
|
// Should not reach here. Will only reach here if the file is corrupted or
|
||||||
// does not follow the PNG specifications
|
// does not follow the PNG specifications
|
||||||
|
@ -6464,9 +6563,9 @@ public class ExifInterfaceFork {
|
||||||
// Exif data in WebP images (e.g.
|
// Exif data in WebP images (e.g.
|
||||||
// https://github.com/ImageMagick/ImageMagick/issues/3140)
|
// https://github.com/ImageMagick/ImageMagick/issues/3140)
|
||||||
if (startsWith(payload, IDENTIFIER_EXIF_APP1)) {
|
if (startsWith(payload, IDENTIFIER_EXIF_APP1)) {
|
||||||
int adjustedChunkSize = chunkSize - IDENTIFIER_EXIF_APP1.length;
|
payload =
|
||||||
payload = Arrays.copyOfRange(payload, IDENTIFIER_EXIF_APP1.length,
|
Arrays.copyOfRange(
|
||||||
adjustedChunkSize);
|
payload, IDENTIFIER_EXIF_APP1.length, payload.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save offset to EXIF data for handling thumbnail and attribute offsets.
|
// Save offset to EXIF data for handling thumbnail and attribute offsets.
|
||||||
|
@ -6522,7 +6621,7 @@ public class ExifInterfaceFork {
|
||||||
// Write EXIF APP1 segment
|
// Write EXIF APP1 segment
|
||||||
dataOutputStream.writeByte(MARKER);
|
dataOutputStream.writeByte(MARKER);
|
||||||
dataOutputStream.writeByte(MARKER_APP1);
|
dataOutputStream.writeByte(MARKER_APP1);
|
||||||
writeExifSegment(dataOutputStream);
|
mOffsetToExifData = writeExifSegment(dataOutputStream);
|
||||||
|
|
||||||
if (mXmpFromSeparateMarker != null) {
|
if (mXmpFromSeparateMarker != null) {
|
||||||
// Write XMP APP1 segment. The XMP spec (part 3, section 1.1.3) recommends for this to
|
// Write XMP APP1 segment. The XMP spec (part 3, section 1.1.3) recommends for this to
|
||||||
|
@ -6533,6 +6632,7 @@ public class ExifInterfaceFork {
|
||||||
dataOutputStream.writeUnsignedShort(length);
|
dataOutputStream.writeUnsignedShort(length);
|
||||||
dataOutputStream.write(IDENTIFIER_XMP_APP1);
|
dataOutputStream.write(IDENTIFIER_XMP_APP1);
|
||||||
dataOutputStream.write(mXmpFromSeparateMarker.bytes);
|
dataOutputStream.write(mXmpFromSeparateMarker.bytes);
|
||||||
|
mFileOnDiskContainsSeparateXmpMarker = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] bytes = new byte[4096];
|
byte[] bytes = new byte[4096];
|
||||||
|
@ -6627,60 +6727,94 @@ public class ExifInterfaceFork {
|
||||||
// Copy PNG signature bytes
|
// Copy PNG signature bytes
|
||||||
copy(dataInputStream, dataOutputStream, PNG_SIGNATURE.length);
|
copy(dataInputStream, dataOutputStream, PNG_SIGNATURE.length);
|
||||||
|
|
||||||
// EXIF chunk can appear anywhere between the first (IHDR) and last (IEND) chunks, except
|
boolean needToWriteExif = true;
|
||||||
// between IDAT chunks.
|
// Either there's some XMP data to write, or it has been cleared locally but was present in
|
||||||
// Adhering to these rules,
|
// the file when it was read (and so needs to be removed).
|
||||||
// 1) if EXIF chunk did not exist in the original file, it will be stored right after the
|
boolean needToHandleXmpChunk =
|
||||||
// first chunk,
|
mXmpFromSeparateMarker != null || mFileOnDiskContainsSeparateXmpMarker;
|
||||||
// 2) if EXIF chunk existed in the original file, it will be stored in the same location.
|
while (needToWriteExif || needToHandleXmpChunk) {
|
||||||
if (mOffsetToExifData == 0) {
|
int chunkLength = dataInputStream.readInt();
|
||||||
// Copy IHDR chunk bytes
|
int chunkType = dataInputStream.readInt();
|
||||||
int ihdrChunkLength = dataInputStream.readInt();
|
if (chunkType == PNG_CHUNK_TYPE_IHDR) {
|
||||||
dataOutputStream.writeInt(ihdrChunkLength);
|
dataOutputStream.writeInt(chunkLength);
|
||||||
copy(dataInputStream, dataOutputStream, PNG_CHUNK_TYPE_BYTE_LENGTH
|
dataOutputStream.writeInt(chunkType);
|
||||||
+ ihdrChunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
|
copy(dataInputStream, dataOutputStream, chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
|
||||||
} else {
|
if (mOffsetToExifData == 0) {
|
||||||
// Copy up until the point where EXIF chunk length information is stored.
|
// There was no Exif segment in the original file, so we put it directly
|
||||||
int copyLength = mOffsetToExifData - PNG_SIGNATURE.length
|
// after the IHDR chunk.
|
||||||
- 4 /* PNG EXIF chunk length bytes */
|
writePngExifChunk(dataOutputStream);
|
||||||
- PNG_CHUNK_TYPE_BYTE_LENGTH;
|
needToWriteExif = false;
|
||||||
copy(dataInputStream, dataOutputStream, copyLength);
|
}
|
||||||
|
if (mXmpFromSeparateMarker != null && !mFileOnDiskContainsSeparateXmpMarker) {
|
||||||
// Skip to the start of the chunk after the EXIF chunk
|
writePngXmpItxtChunk(dataOutputStream);
|
||||||
int exifChunkLength = dataInputStream.readInt();
|
needToHandleXmpChunk = false;
|
||||||
dataInputStream.skipFully(PNG_CHUNK_TYPE_BYTE_LENGTH + exifChunkLength
|
}
|
||||||
+ PNG_CHUNK_CRC_BYTE_LENGTH);
|
continue;
|
||||||
}
|
} else if (chunkType == PNG_CHUNK_TYPE_EXIF && needToWriteExif) {
|
||||||
|
writePngExifChunk(dataOutputStream);
|
||||||
// Write EXIF data
|
dataInputStream.skipFully(chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
|
||||||
ByteArrayOutputStream exifByteArrayOutputStream = null;
|
needToWriteExif = false;
|
||||||
try {
|
continue;
|
||||||
// A byte array is needed to calculate the CRC value of this chunk which requires
|
} else if (chunkType == PNG_CHUNK_TYPE_ITXT
|
||||||
// the chunk type bytes and the chunk data bytes.
|
&& chunkLength >= PNG_ITXT_XMP_KEYWORD.length) {
|
||||||
exifByteArrayOutputStream = new ByteArrayOutputStream();
|
// Read the 17 byte keyword and 5 expected null bytes.
|
||||||
ByteOrderedDataOutputStream exifDataOutputStream =
|
byte[] keyword = new byte[PNG_ITXT_XMP_KEYWORD.length];
|
||||||
new ByteOrderedDataOutputStream(exifByteArrayOutputStream, BIG_ENDIAN);
|
dataInputStream.readFully(keyword);
|
||||||
|
int remainingChunkBytes = chunkLength - keyword.length + PNG_CHUNK_CRC_BYTE_LENGTH;
|
||||||
// Store Exif data in separate byte array
|
if (Arrays.equals(keyword, PNG_ITXT_XMP_KEYWORD)) {
|
||||||
writeExifSegment(exifDataOutputStream);
|
if (mXmpFromSeparateMarker != null) {
|
||||||
byte[] exifBytes =
|
writePngXmpItxtChunk(dataOutputStream);
|
||||||
((ByteArrayOutputStream) exifDataOutputStream.mOutputStream).toByteArray();
|
}
|
||||||
|
dataInputStream.skipFully(remainingChunkBytes);
|
||||||
// Write EXIF chunk data
|
needToHandleXmpChunk = false;
|
||||||
dataOutputStream.write(exifBytes);
|
} else {
|
||||||
|
// This is a non-XMP iTXt chunk, so just copy it to the output and continue.
|
||||||
// Write EXIF chunk CRC
|
dataOutputStream.writeInt(chunkLength);
|
||||||
CRC32 crc = new CRC32();
|
dataOutputStream.writeInt(chunkType);
|
||||||
crc.update(exifBytes, 4 /* skip length bytes */, exifBytes.length - 4);
|
dataOutputStream.write(keyword);
|
||||||
dataOutputStream.writeInt((int) crc.getValue());
|
copy(dataInputStream, dataOutputStream, remainingChunkBytes);
|
||||||
} finally {
|
}
|
||||||
closeQuietly(exifByteArrayOutputStream);
|
continue;
|
||||||
|
}
|
||||||
|
dataOutputStream.writeInt(chunkLength);
|
||||||
|
dataOutputStream.writeInt(chunkType);
|
||||||
|
copy(dataInputStream, dataOutputStream, chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy the rest of the file
|
// Copy the rest of the file
|
||||||
copy(dataInputStream, dataOutputStream);
|
copy(dataInputStream, dataOutputStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void writePngExifChunk(ByteOrderedDataOutputStream dataOutputStream)
|
||||||
|
throws IOException {
|
||||||
|
// Write the eXIF chunk out to an intermediate byte array so we can calculate the CRC value.
|
||||||
|
ByteArrayOutputStream exifByteArrayOutputStream = new ByteArrayOutputStream();
|
||||||
|
// Write eXIF chunk data (including chunk type & length).
|
||||||
|
int exifOffset =
|
||||||
|
writeExifSegment(
|
||||||
|
new ByteOrderedDataOutputStream(exifByteArrayOutputStream, BIG_ENDIAN));
|
||||||
|
mOffsetToExifData = dataOutputStream.mOutputStream.size() + exifOffset;
|
||||||
|
byte[] exifBytes = exifByteArrayOutputStream.toByteArray();
|
||||||
|
dataOutputStream.write(exifBytes);
|
||||||
|
CRC32 crc = new CRC32();
|
||||||
|
crc.update(exifBytes, 4 /* skip length bytes */, exifBytes.length - 4);
|
||||||
|
dataOutputStream.writeInt((int) crc.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writePngXmpItxtChunk(ByteOrderedDataOutputStream dataOutputStream)
|
||||||
|
throws IOException {
|
||||||
|
dataOutputStream.writeInt(mXmpFromSeparateMarker.bytes.length + 22);
|
||||||
|
CRC32 crc = new CRC32();
|
||||||
|
dataOutputStream.writeInt(PNG_CHUNK_TYPE_ITXT);
|
||||||
|
updateCrcWithInt(crc, PNG_CHUNK_TYPE_ITXT);
|
||||||
|
dataOutputStream.write(PNG_ITXT_XMP_KEYWORD);
|
||||||
|
crc.update(PNG_ITXT_XMP_KEYWORD);
|
||||||
|
dataOutputStream.write(mXmpFromSeparateMarker.bytes);
|
||||||
|
crc.update(mXmpFromSeparateMarker.bytes);
|
||||||
|
dataOutputStream.writeInt((int) crc.getValue());
|
||||||
|
mFileOnDiskContainsSeparateXmpMarker = true;
|
||||||
|
}
|
||||||
|
|
||||||
// A WebP file has a header and a series of chunks.
|
// A WebP file has a header and a series of chunks.
|
||||||
// The header is composed of:
|
// The header is composed of:
|
||||||
// "RIFF" + File Size + "WEBP"
|
// "RIFF" + File Size + "WEBP"
|
||||||
|
@ -6726,11 +6860,12 @@ public class ExifInterfaceFork {
|
||||||
|
|
||||||
// WebP signature
|
// WebP signature
|
||||||
copy(totalInputStream, totalOutputStream, WEBP_SIGNATURE_1.length);
|
copy(totalInputStream, totalOutputStream, WEBP_SIGNATURE_1.length);
|
||||||
// File length will be written after all the chunks have been written
|
int riffLength = totalInputStream.readInt();
|
||||||
totalInputStream.skipFully(WEBP_FILE_SIZE_BYTE_LENGTH + WEBP_SIGNATURE_2.length);
|
totalInputStream.skipFully(WEBP_SIGNATURE_2.length);
|
||||||
|
|
||||||
// Create a separate byte array to calculate file length
|
// Create a separate byte array to calculate file length
|
||||||
ByteArrayOutputStream nonHeaderByteArrayOutputStream = null;
|
ByteArrayOutputStream nonHeaderByteArrayOutputStream = null;
|
||||||
|
int exifOffset = -1;
|
||||||
try {
|
try {
|
||||||
nonHeaderByteArrayOutputStream = new ByteArrayOutputStream();
|
nonHeaderByteArrayOutputStream = new ByteArrayOutputStream();
|
||||||
ByteOrderedDataOutputStream nonHeaderOutputStream =
|
ByteOrderedDataOutputStream nonHeaderOutputStream =
|
||||||
|
@ -6756,7 +6891,7 @@ public class ExifInterfaceFork {
|
||||||
totalInputStream.skipFully(exifChunkLength);
|
totalInputStream.skipFully(exifChunkLength);
|
||||||
|
|
||||||
// Write new EXIF chunk to output stream
|
// Write new EXIF chunk to output stream
|
||||||
writeExifSegment(nonHeaderOutputStream);
|
exifOffset = writeExifSegment(nonHeaderOutputStream);
|
||||||
} else {
|
} else {
|
||||||
// EXIF chunk does not exist in the original file
|
// EXIF chunk does not exist in the original file
|
||||||
byte[] firstChunkType = new byte[WEBP_CHUNK_TYPE_BYTE_LENGTH];
|
byte[] firstChunkType = new byte[WEBP_CHUNK_TYPE_BYTE_LENGTH];
|
||||||
|
@ -6801,7 +6936,7 @@ public class ExifInterfaceFork {
|
||||||
animationFinished = true;
|
animationFinished = true;
|
||||||
}
|
}
|
||||||
if (animationFinished) {
|
if (animationFinished) {
|
||||||
writeExifSegment(nonHeaderOutputStream);
|
exifOffset = writeExifSegment(nonHeaderOutputStream);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
copyWebPChunk(totalInputStream, nonHeaderOutputStream, type);
|
copyWebPChunk(totalInputStream, nonHeaderOutputStream, type);
|
||||||
|
@ -6810,7 +6945,7 @@ public class ExifInterfaceFork {
|
||||||
// Skip until we find the VP8 or VP8L chunk
|
// Skip until we find the VP8 or VP8L chunk
|
||||||
copyChunksUpToGivenChunkType(totalInputStream, nonHeaderOutputStream,
|
copyChunksUpToGivenChunkType(totalInputStream, nonHeaderOutputStream,
|
||||||
WEBP_CHUNK_TYPE_VP8, WEBP_CHUNK_TYPE_VP8L);
|
WEBP_CHUNK_TYPE_VP8, WEBP_CHUNK_TYPE_VP8L);
|
||||||
writeExifSegment(nonHeaderOutputStream);
|
exifOffset = writeExifSegment(nonHeaderOutputStream);
|
||||||
}
|
}
|
||||||
} else if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8)
|
} else if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8)
|
||||||
|| Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8L)) {
|
|| Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8L)) {
|
||||||
|
@ -6897,18 +7032,24 @@ public class ExifInterfaceFork {
|
||||||
copy(totalInputStream, nonHeaderOutputStream, bytesToRead);
|
copy(totalInputStream, nonHeaderOutputStream, bytesToRead);
|
||||||
|
|
||||||
// Write EXIF chunk
|
// Write EXIF chunk
|
||||||
writeExifSegment(nonHeaderOutputStream);
|
exifOffset = writeExifSegment(nonHeaderOutputStream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy the rest of the file
|
// Copy the rest of the RIFF part of the file
|
||||||
copy(totalInputStream, nonHeaderOutputStream);
|
int remainingRiffBytes = riffLength + 8 - totalInputStream.position();
|
||||||
|
copy(totalInputStream, nonHeaderOutputStream, remainingRiffBytes);
|
||||||
|
|
||||||
// Write file length + second signature
|
// Write file length + second signature
|
||||||
totalOutputStream.writeInt(nonHeaderByteArrayOutputStream.size()
|
totalOutputStream.writeInt(nonHeaderByteArrayOutputStream.size()
|
||||||
+ WEBP_SIGNATURE_2.length);
|
+ WEBP_SIGNATURE_2.length);
|
||||||
totalOutputStream.write(WEBP_SIGNATURE_2);
|
totalOutputStream.write(WEBP_SIGNATURE_2);
|
||||||
|
if (exifOffset != -1) {
|
||||||
|
mOffsetToExifData = totalOutputStream.mOutputStream.size() + exifOffset;
|
||||||
|
}
|
||||||
nonHeaderByteArrayOutputStream.writeTo(totalOutputStream);
|
nonHeaderByteArrayOutputStream.writeTo(totalOutputStream);
|
||||||
|
// Copy any non-RIFF trailing data
|
||||||
|
copy(totalInputStream, totalOutputStream);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new IOException("Failed to save WebP file", e);
|
throw new IOException("Failed to save WebP file", e);
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -7419,6 +7560,13 @@ public class ExifInterfaceFork {
|
||||||
Log.d(TAG, "Invalid strip offset value");
|
Log.d(TAG, "Invalid strip offset value");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TLAD start
|
||||||
|
if (bytesToSkip > getAvailableHeapSize()) {
|
||||||
|
throw new IOException("cannot allocate " + bytesToSkip + " bytes to skip to retrieve thumbnail");
|
||||||
|
}
|
||||||
|
// TLAD end
|
||||||
|
|
||||||
try {
|
try {
|
||||||
in.skipFully(bytesToSkip);
|
in.skipFully(bytesToSkip);
|
||||||
} catch (EOFException e) {
|
} catch (EOFException e) {
|
||||||
|
@ -7624,7 +7772,12 @@ public class ExifInterfaceFork {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writes an Exif segment into the given output stream.
|
/**
|
||||||
|
* Writes an Exif segment into the given output stream.
|
||||||
|
*
|
||||||
|
* @return The offset of the start of the Exif data (the byte-order marker) written into {@code
|
||||||
|
* dataOutputStream}.
|
||||||
|
*/
|
||||||
private int writeExifSegment(ByteOrderedDataOutputStream dataOutputStream) throws IOException {
|
private int writeExifSegment(ByteOrderedDataOutputStream dataOutputStream) throws IOException {
|
||||||
// The following variables are for calculating each IFD tag group size in bytes.
|
// The following variables are for calculating each IFD tag group size in bytes.
|
||||||
int[] ifdOffsets = new int[EXIF_TAGS.length];
|
int[] ifdOffsets = new int[EXIF_TAGS.length];
|
||||||
|
@ -7772,6 +7925,8 @@ public class ExifInterfaceFork {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int offsetToExifData = dataOutputStream.mOutputStream.size();
|
||||||
|
|
||||||
// Write TIFF Headers. See JEITA CP-3451C Section 4.5.2. Table 1.
|
// Write TIFF Headers. See JEITA CP-3451C Section 4.5.2. Table 1.
|
||||||
dataOutputStream.writeShort(mExifByteOrder == BIG_ENDIAN ? BYTE_ALIGN_MM : BYTE_ALIGN_II);
|
dataOutputStream.writeShort(mExifByteOrder == BIG_ENDIAN ? BYTE_ALIGN_MM : BYTE_ALIGN_II);
|
||||||
dataOutputStream.setByteOrder(mExifByteOrder);
|
dataOutputStream.setByteOrder(mExifByteOrder);
|
||||||
|
@ -7844,7 +7999,7 @@ public class ExifInterfaceFork {
|
||||||
// Reset the byte order to big endian in order to write remaining parts of the JPEG file.
|
// Reset the byte order to big endian in order to write remaining parts of the JPEG file.
|
||||||
dataOutputStream.setByteOrder(BIG_ENDIAN);
|
dataOutputStream.setByteOrder(BIG_ENDIAN);
|
||||||
|
|
||||||
return totalSize;
|
return offsetToExifData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -8240,12 +8395,12 @@ public class ExifInterfaceFork {
|
||||||
// An output stream to write EXIF data area, which can be written in either little or big endian
|
// An output stream to write EXIF data area, which can be written in either little or big endian
|
||||||
// order.
|
// order.
|
||||||
private static class ByteOrderedDataOutputStream extends FilterOutputStream {
|
private static class ByteOrderedDataOutputStream extends FilterOutputStream {
|
||||||
final OutputStream mOutputStream;
|
final DataOutputStream mOutputStream;
|
||||||
private ByteOrder mByteOrder;
|
private ByteOrder mByteOrder;
|
||||||
|
|
||||||
public ByteOrderedDataOutputStream(OutputStream out, ByteOrder byteOrder) {
|
public ByteOrderedDataOutputStream(OutputStream out, ByteOrder byteOrder) {
|
||||||
super(out);
|
super(out);
|
||||||
mOutputStream = out;
|
mOutputStream = new DataOutputStream(out);
|
||||||
mByteOrder = byteOrder;
|
mByteOrder = byteOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,7 +31,7 @@ import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Forked from 'androidx.exifinterface:exifinterface:1.4.0-alpha01' on 2024/11/17
|
* Forked from 'androidx.exifinterface:exifinterface:1.4.1'
|
||||||
* Named differently to let ExifInterface be loaded as subdependency.
|
* 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
|
* cf https://github.com/androidx/androidx/tree/androidx-main/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -1,22 +1,10 @@
|
||||||
# Project-wide Gradle settings.
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
# 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.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
|
|
||||||
# Kotlin code style for this project: "official" or "obsolete":
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|
||||||
android.defaults.buildfeatures.buildconfig=true
|
|
||||||
android.nonTransitiveRClass=false
|
|
||||||
android.nonFinalResIds=false
|
|
||||||
|
|
||||||
# full mode is too aggressive and removes essential code
|
# full mode is too aggressive and removes essential code
|
||||||
# of `metadata-extractor` even when adding `-keep class com.drew.**{ *; }`
|
# of `metadata-extractor` even when adding `-keep class com.drew.**{ *; }`
|
||||||
android.enableR8.fullMode=false
|
android.enableR8.fullMode=false
|
||||||
|
|
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
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 = '1.9.24'
|
|
||||||
settings.ext.ksp_version = "$kotlin_version-1.0.20"
|
|
||||||
settings.ext.agp_version = '8.7.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")
|
|
28
android/settings.gradle.kts
Normal file
28
android/settings.gradle.kts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
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")
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue