Compare commits
253 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 |
2
.flutter
|
@ -1 +1 @@
|
||||||
Subproject commit 17025dd88227cd9532c33fa78f5250d548d87e9a
|
Subproject commit d8a9f9a52e5af486f80d932e838ee93861ffd863
|
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@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
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
|
||||||
|
|
13
.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@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
|
@ -28,6 +28,9 @@ jobs:
|
||||||
- name: Get Flutter packages
|
- name: Get Flutter packages
|
||||||
run: ./flutterw pub get
|
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@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
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@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.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@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
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@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||||
with:
|
with:
|
||||||
category: "/language:${{matrix.language}}"
|
category: "/language:${{matrix.language}}"
|
||||||
|
|
17
.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@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
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@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
@ -36,6 +36,9 @@ jobs:
|
||||||
- name: Get Flutter packages
|
- name: Get Flutter packages
|
||||||
run: ./flutterw pub get
|
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@7668571508540a607bdfd90a87a560489fe372eb # v2.1.0
|
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@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
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@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
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
|
@ -31,7 +31,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
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@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
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@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|
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
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
141
CHANGELOG.md
|
@ -4,6 +4,147 @@ 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
|
## <a id="v1.12.2"></a>[v1.12.2] - 2025-01-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
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,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.9.0'
|
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.16'
|
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.11.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,10 +329,6 @@
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
<!--
|
|
||||||
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 {
|
||||||
|
|
|
@ -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?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
</resources>
|
<string name="map_shortcut_short_label">Mapa</string>
|
||||||
|
</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>
|
||||||
</resources>
|
<string name="map_shortcut_short_label">Mapa</string>
|
||||||
|
</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>
|
||||||
</resources>
|
<string name="map_shortcut_short_label">Χάρτης</string>
|
||||||
|
</resources>
|
||||||
|
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_notification_action_stop">توقف کردن</string>
|
<string name="analysis_notification_action_stop">توقف کردن</string>
|
||||||
<string name="app_widget_label">قاب عکس</string>
|
<string name="app_widget_label">قاب عکس</string>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
</resources>
|
<string name="map_shortcut_short_label">نقشه</string>
|
||||||
|
</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>
|
||||||
</resources>
|
<string name="map_shortcut_short_label">Mapa</string>
|
||||||
|
</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>
|
||||||
</resources>
|
<string name="map_shortcut_short_label">Landakort</string>
|
||||||
|
</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>
|
||||||
|
@ -9,4 +9,4 @@
|
||||||
<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>
|
<string name="map_shortcut_short_label">מפה</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -9,4 +9,4 @@
|
||||||
<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>
|
<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>
|
||||||
</resources>
|
<string name="map_shortcut_short_label">ನಕ್ಷೆ</string>
|
||||||
|
</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>
|
||||||
</resources>
|
<string name="map_shortcut_short_label">မြေပုံ</string>
|
||||||
|
</resources>
|
||||||
|
|
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>
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="search_shortcut_short_label">ସନ୍ଧାନ</string>
|
<string name="search_shortcut_short_label">ସନ୍ଧାନ</string>
|
||||||
</resources>
|
</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>
|
||||||
</resources>
|
<string name="map_shortcut_short_label">Hartă</string>
|
||||||
|
</resources>
|
||||||
|
|
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>
|
||||||
</resources>
|
<string name="map_shortcut_short_label">地圖</string>
|
||||||
|
</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
|
@ -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")
|
35
assets/terms.txt
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
Terms of Service
|
||||||
|
================
|
||||||
|
|
||||||
|
“Aves Gallery” is an open-source gallery and metadata explorer app allowing you to access and manage your local photos and videos.
|
||||||
|
|
||||||
|
The app is designed for legal, authorized and acceptable purposes.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Disclaimer
|
||||||
|
==========
|
||||||
|
|
||||||
|
The app is released “as-is”, without any warranty, responsibility or liability. Use of the app is at your own risk.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Privacy Policy
|
||||||
|
==============
|
||||||
|
|
||||||
|
The app does not collect any personal data. We never have access to your photos and videos. This also means that we cannot get them back for you if you delete them without backing them up.
|
||||||
|
|
||||||
|
Optionally, with your consent, the app accesses the inventory of installed apps to improve album display.
|
||||||
|
|
||||||
|
Optionally, with your consent, the app collects anonymous error and diagnostic data to improve the app quality. We use Firebase Crashlytics, and the anonymous data are stored on their servers. Please note that those are anonymous data, there is absolutely nothing personal about those data.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Contact
|
||||||
|
=======
|
||||||
|
|
||||||
|
Developer: Thibault Deckers
|
||||||
|
|
||||||
|
Email: gallery.aves@gmail.com
|
||||||
|
|
||||||
|
Website: https://github.com/deckerst/aves
|
|
@ -1,3 +1,4 @@
|
||||||
description: This file stores settings for Dart & Flutter DevTools.
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
extensions:
|
extensions:
|
||||||
|
- provider: true
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 306 KiB |
Before Width: | Height: | Size: 495 KiB After Width: | Height: | Size: 553 KiB |
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 146 KiB |
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 114 KiB |
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 85 KiB |