Compare commits
476 commits
Author | SHA1 | Date | |
---|---|---|---|
9037f8e610 | |||
05bb77a793 | |||
2f0f3da2fa | |||
31f85c3e01 | |||
84a822022a | |||
94c83914a4 | |||
a461e2c55f | |||
99c9f85eaf | |||
848ad5220e | |||
![]() |
7577466978 | ||
![]() |
dfcaf4d35a | ||
![]() |
171394056f | ||
![]() |
60211545e1 | ||
![]() |
edbf9744f5 | ||
![]() |
d272c82454 | ||
![]() |
20b4f10b62 | ||
![]() |
3db0478be2 | ||
![]() |
c9fd71056f | ||
![]() |
ca2d2c2026 | ||
![]() |
2e775b3906 | ||
![]() |
ea3cb3c063 | ||
![]() |
340ed6a6d9 | ||
![]() |
5e0f0b59d8 | ||
![]() |
a0163001bd | ||
![]() |
1222a711e0 | ||
![]() |
8c3d0f1b83 | ||
![]() |
43cb2cd101 | ||
![]() |
81a2b84c9f | ||
![]() |
bae6d2b7c4 | ||
![]() |
9a377ed7bc | ||
![]() |
1119fa1407 | ||
![]() |
7b0f72d6ee | ||
![]() |
6f9a581d99 | ||
![]() |
b6faf36671 | ||
![]() |
17f3ec437c | ||
![]() |
3ec5b96bc9 | ||
![]() |
540fbbc2b4 | ||
![]() |
f355efefc1 | ||
![]() |
3bcaab9a4b | ||
![]() |
ef091b9932 | ||
![]() |
33667e7e6e | ||
![]() |
2a3cce422b | ||
![]() |
e0af21f098 | ||
![]() |
8636e4b73e | ||
![]() |
0ad4b2f16f | ||
![]() |
df63f06897 | ||
![]() |
09df269ee0 | ||
![]() |
39d7587ac9 | ||
![]() |
0f6a8230d8 | ||
![]() |
a6c1fd52a6 | ||
![]() |
eaa4fe3317 | ||
![]() |
5358a38bd8 | ||
![]() |
244c1a293d | ||
![]() |
2ef03f1592 | ||
![]() |
f2ef5c6f32 | ||
![]() |
b582dbb3b2 | ||
![]() |
b864a4dae3 | ||
![]() |
2b01fb41e2 | ||
![]() |
244217417b | ||
![]() |
651b5926dc | ||
![]() |
27879a900d | ||
![]() |
4b87717cd2 | ||
![]() |
91cfe01af3 | ||
![]() |
cb6ccab6ca | ||
![]() |
f04c55e901 | ||
![]() |
8e0c69cd66 | ||
![]() |
c31b64535d | ||
![]() |
89dee8d508 | ||
![]() |
93c2c7d34d | ||
![]() |
573b7c4593 | ||
![]() |
f9f21fbe76 | ||
![]() |
9cfb4436fa | ||
![]() |
925998b709 | ||
![]() |
a8bb2eb69f | ||
![]() |
bb6c2c341b | ||
![]() |
cbaaf2fd87 | ||
![]() |
e1fad28411 | ||
![]() |
bb26b18017 | ||
![]() |
3a6ad33ea1 | ||
![]() |
75421faf46 | ||
![]() |
4df4738dd3 | ||
![]() |
e8eae7e9db | ||
![]() |
f47cd57692 | ||
![]() |
bb332d5adf | ||
![]() |
7f54befb72 | ||
![]() |
af4ca96da8 | ||
![]() |
90d0256bf7 | ||
![]() |
353cde0ee8 | ||
![]() |
63130de577 | ||
![]() |
ad5a9c848d | ||
![]() |
be75d5a284 | ||
![]() |
0142ed7f4f | ||
![]() |
6c2db18af2 | ||
![]() |
93e2b1d310 | ||
![]() |
9baa4a0441 | ||
![]() |
f123faeee8 | ||
![]() |
e3ece7425f | ||
![]() |
ff4c49718e | ||
![]() |
5598b0a69d | ||
![]() |
dac91a2d1d | ||
![]() |
759f666085 | ||
![]() |
c89441bb78 | ||
![]() |
969187444b | ||
![]() |
1fd3d77bf9 | ||
![]() |
32202cc603 | ||
![]() |
bce7009ab0 | ||
![]() |
b00b17a473 | ||
![]() |
8be5b8d9c5 | ||
![]() |
503757da0e | ||
![]() |
85a1b33e83 | ||
![]() |
022ad0334e | ||
![]() |
e09b3e4440 | ||
![]() |
409d80df4e | ||
![]() |
a6ae2fd4cb | ||
![]() |
59aa75e46c | ||
![]() |
1df2154f85 | ||
![]() |
326e74c04c | ||
![]() |
e1aee40f0c | ||
![]() |
8193c48234 | ||
![]() |
9c641e0f49 | ||
![]() |
988d9b2c8d | ||
![]() |
305dcb4528 | ||
![]() |
a13da39af2 | ||
![]() |
9d7e6bb9ff | ||
![]() |
509c402227 | ||
![]() |
77443202aa | ||
![]() |
508e5ae739 | ||
![]() |
f38178ab7e | ||
![]() |
a670b683e5 | ||
![]() |
9a4c567c2b | ||
![]() |
672b6fd2dc | ||
![]() |
b8e9786f4d | ||
![]() |
cb067aa1ac | ||
![]() |
cf74e75d58 | ||
![]() |
9e33db5b4d | ||
![]() |
8e6a995c4e | ||
![]() |
62d666fb34 | ||
![]() |
403ccac5c2 | ||
![]() |
dcd42b7048 | ||
![]() |
f646639055 | ||
![]() |
a32c0cf0f0 | ||
![]() |
3f6ef0cdaf | ||
![]() |
32bda0d9a7 | ||
![]() |
4e10d882c9 | ||
![]() |
9eadf61109 | ||
![]() |
7a84a80736 | ||
![]() |
efc6ed2a01 | ||
![]() |
967e192b4c | ||
![]() |
a3ce840d02 | ||
![]() |
ee748d8920 | ||
![]() |
15fe378107 | ||
![]() |
2325501f3f | ||
![]() |
f02108fbcd | ||
![]() |
b224709c5d | ||
![]() |
152b942f57 | ||
![]() |
c1a99d9be5 | ||
![]() |
d4791df333 | ||
![]() |
1f95506abe | ||
![]() |
f850178afd | ||
![]() |
5805bb2b5b | ||
![]() |
731e82028c | ||
![]() |
fe53d488b5 | ||
![]() |
eed280e840 | ||
![]() |
f0580d8724 | ||
![]() |
83f24374e5 | ||
![]() |
6c055dd24c | ||
![]() |
bf99f751bb | ||
![]() |
8577eba448 | ||
![]() |
12672984d7 | ||
![]() |
1d2aedf4c3 | ||
![]() |
bb401e3410 | ||
![]() |
699c56e2f5 | ||
![]() |
64cc59eae9 | ||
![]() |
76a9cf3cb3 | ||
![]() |
5c94d2eece | ||
![]() |
6c7dc7a0e3 | ||
![]() |
b52d2242a4 | ||
![]() |
f02363592f | ||
![]() |
a3f6cd7a32 | ||
![]() |
a608e122b1 | ||
![]() |
3424631f5e | ||
![]() |
5c297c1daf | ||
![]() |
8fe267d345 | ||
![]() |
f4108c244b | ||
![]() |
5769593799 | ||
![]() |
ec9cb234a8 | ||
![]() |
5f26cfbbf3 | ||
![]() |
9280b4a6a7 | ||
![]() |
728b8018c4 | ||
![]() |
98537339bd | ||
![]() |
ae9e2977b4 | ||
![]() |
61de0ffc4c | ||
![]() |
421e5d1522 | ||
![]() |
5a505344ef | ||
![]() |
7b25d37616 | ||
![]() |
8641de4d5b | ||
![]() |
af0b79b07d | ||
![]() |
983f50a814 | ||
![]() |
79840b098f | ||
![]() |
95e0272afb | ||
![]() |
b89db3bc9b | ||
![]() |
8353064945 | ||
![]() |
de0cfb1431 | ||
![]() |
026cfebd49 | ||
![]() |
e2e0ee706f | ||
![]() |
d11bd21d89 | ||
![]() |
62952de907 | ||
![]() |
881882a117 | ||
![]() |
578abe3d5a | ||
![]() |
fd9a118bb0 | ||
![]() |
b7b57ca15b | ||
![]() |
670b4b1830 | ||
![]() |
f143f86732 | ||
![]() |
71b37a9d77 | ||
![]() |
22f984bb72 | ||
![]() |
bad11564c6 | ||
![]() |
bbd819df19 | ||
![]() |
34e22cd486 | ||
![]() |
4b4d444884 | ||
![]() |
cdfd7808dd | ||
![]() |
ffa23f445d | ||
![]() |
b2e4c291e8 | ||
![]() |
377601422e | ||
![]() |
eeb2f912b5 | ||
![]() |
e266dc3c28 | ||
![]() |
e14a1a0bee | ||
![]() |
598b705b36 | ||
![]() |
0a3a792a7e | ||
![]() |
16da0ec3f5 | ||
![]() |
892e64ef28 | ||
![]() |
b54ed21c93 | ||
![]() |
8ef2c09856 | ||
![]() |
0301269171 | ||
![]() |
a99f4877ce | ||
![]() |
405794467b | ||
![]() |
2610de09ae | ||
![]() |
2be1b8b538 | ||
![]() |
aea76c6c1a | ||
![]() |
dbbef98ac3 | ||
![]() |
43b832680c | ||
![]() |
b282d0a250 | ||
![]() |
0575a6cce6 | ||
![]() |
8e5d971a6f | ||
![]() |
a0f7af96e0 | ||
![]() |
2d65efdcb4 | ||
![]() |
456486b846 | ||
![]() |
1b404e0ee8 | ||
![]() |
aad89c2255 | ||
![]() |
9faa9b7c26 | ||
![]() |
cfe87422aa | ||
![]() |
8b7a14d916 | ||
![]() |
e1934510b5 | ||
![]() |
4067d89075 | ||
![]() |
41ab5d8f90 | ||
![]() |
6a5b0770e0 | ||
![]() |
f108103a4b | ||
![]() |
4d50f258e4 | ||
![]() |
bb5bbcc069 | ||
![]() |
8c11a7bbd4 | ||
![]() |
dc01b46fd0 | ||
![]() |
43521e6268 | ||
![]() |
550c72e994 | ||
![]() |
07f253d587 | ||
![]() |
162900091e | ||
![]() |
ecbbd3b459 | ||
![]() |
0685bc2a95 | ||
![]() |
d1ab33e64d | ||
![]() |
b87dff0fb0 | ||
![]() |
2d1fbbd4d3 | ||
![]() |
9bdb3171d9 | ||
![]() |
43a42ad1dc | ||
![]() |
799c263202 | ||
![]() |
54b5d5d377 | ||
![]() |
68a285b7fa | ||
![]() |
cfb546f056 | ||
![]() |
89f177539e | ||
![]() |
73b3c96fe2 | ||
![]() |
487b364c09 | ||
![]() |
dd36ecb7e1 | ||
![]() |
ef21280721 | ||
![]() |
e80f6645f1 | ||
![]() |
67bdc50758 | ||
![]() |
4685708d49 | ||
![]() |
784ee82b35 | ||
![]() |
17c2c9c044 | ||
![]() |
4bd8ebed79 | ||
![]() |
e30e65a2cc | ||
![]() |
bb37664df0 | ||
![]() |
939895599d | ||
![]() |
b027e9a9a8 | ||
![]() |
96d43a4ab0 | ||
![]() |
14e8f702ea | ||
![]() |
9b71593743 | ||
![]() |
c0a821a44f | ||
![]() |
29d5fab00d | ||
![]() |
202273b669 | ||
![]() |
7b375bb6a8 | ||
![]() |
966275923c | ||
![]() |
4e0677512f | ||
![]() |
2f5831cf4a | ||
![]() |
c15595887a | ||
![]() |
2d70dde7cd | ||
![]() |
ebb00923bf | ||
![]() |
d2a27e979b | ||
![]() |
07f6419bb5 | ||
![]() |
bbfbc509c6 | ||
![]() |
4d823acded | ||
![]() |
303425e699 | ||
![]() |
76f0764d27 | ||
![]() |
d02e6cc693 | ||
![]() |
cdd7aca33c | ||
![]() |
066bff3faf | ||
![]() |
801cdf2897 | ||
![]() |
cc58f919f7 | ||
![]() |
8ff44186f6 | ||
![]() |
fc9c2dfcc5 | ||
![]() |
1a78fdfd0b | ||
![]() |
a3024fdf4e | ||
![]() |
5b6eb44c05 | ||
![]() |
ffbf0bd8f2 | ||
![]() |
ec7e4ac2f2 | ||
![]() |
66f0e2b1e9 | ||
![]() |
b50c305204 | ||
![]() |
1f679ec11c | ||
![]() |
2f5e959fb0 | ||
![]() |
0dee00dbca | ||
![]() |
0c55c7eab0 | ||
![]() |
46374dfcc1 | ||
![]() |
58e3912f86 | ||
![]() |
89c4fdd854 | ||
![]() |
039de32faa | ||
![]() |
ad214b3f56 | ||
![]() |
a25f117e1b | ||
![]() |
b16bb12749 | ||
![]() |
925845a130 | ||
![]() |
d7f5eabc34 | ||
![]() |
d5fef14e9a | ||
![]() |
e7ec41b8d5 | ||
![]() |
d83537f774 | ||
![]() |
e3eda2d330 | ||
![]() |
88df125f5e | ||
![]() |
339a039dd6 | ||
![]() |
e071ff299a | ||
![]() |
a65c0fe3b1 | ||
![]() |
1c61aa7d14 | ||
![]() |
b5e6ce9c94 | ||
![]() |
4384d4dcf3 | ||
![]() |
be1e96270b | ||
![]() |
63b9b1015a | ||
![]() |
382c7e5b1f | ||
![]() |
71efe696c1 | ||
![]() |
da959874fc | ||
![]() |
e65f23d248 | ||
![]() |
ee6b34ad21 | ||
![]() |
e0b3f92b65 | ||
![]() |
9e64da8940 | ||
![]() |
dbb19e1f31 | ||
![]() |
b97000e8e4 | ||
![]() |
c9663fea19 | ||
![]() |
520499d33e | ||
![]() |
b5fea82fce | ||
![]() |
f0e048e340 | ||
![]() |
104403948c | ||
![]() |
00a4890fb1 | ||
![]() |
d4bb360902 | ||
![]() |
ae22a25a13 | ||
![]() |
c6ec5afba1 | ||
![]() |
ccbca7c506 | ||
![]() |
ea3619d7f5 | ||
![]() |
33ffb1cd1a | ||
![]() |
687ca5eb41 | ||
![]() |
1ab663ba70 | ||
![]() |
cce5b1fced | ||
![]() |
1c828c3cf6 | ||
![]() |
5985a89f85 | ||
![]() |
3a63533dd9 | ||
![]() |
8de8d26756 | ||
![]() |
884baae602 | ||
![]() |
e039798463 | ||
![]() |
6c8fb28e09 | ||
![]() |
0af94eee1a | ||
![]() |
f1b143ac95 | ||
![]() |
0b614e8ce5 | ||
![]() |
86b06bff7a | ||
![]() |
0ec2875736 | ||
![]() |
fc30db2f82 | ||
![]() |
d96f9768f9 | ||
![]() |
9aeb0a1fc3 | ||
![]() |
3448a060db | ||
![]() |
50d151ccc0 | ||
![]() |
d93022591a | ||
![]() |
1522e5c5c4 | ||
![]() |
1eecd224e5 | ||
![]() |
ee116c49f4 | ||
![]() |
25d9349cdc | ||
![]() |
9983f3800c | ||
![]() |
df9d9733fa | ||
![]() |
b9327db44b | ||
![]() |
5ce8bef9cc | ||
![]() |
51868287d4 | ||
![]() |
67616ad077 | ||
![]() |
a3b613662a | ||
![]() |
5f80fab460 | ||
![]() |
fdf9aa7ef4 | ||
![]() |
76c3244074 | ||
![]() |
7982c536e4 | ||
![]() |
95c279088a | ||
![]() |
deb9f4af87 | ||
![]() |
342894c360 | ||
![]() |
a7c117a942 | ||
![]() |
b47e5bf3f4 | ||
![]() |
0a99dc24b6 | ||
![]() |
78a28e6762 | ||
![]() |
ee4dd38bbe | ||
![]() |
bb89756815 | ||
![]() |
1058aba262 | ||
![]() |
2932e0b80f | ||
![]() |
a8ad8b9ee6 | ||
![]() |
148a120e4c | ||
![]() |
fb0a9436db | ||
![]() |
5d1e59ca0f | ||
![]() |
ef33cd7902 | ||
![]() |
1c51767de4 | ||
![]() |
9067d75959 | ||
![]() |
dde21abbac | ||
![]() |
9401dd2874 | ||
![]() |
7da501ad2e | ||
![]() |
01e2bcc1b4 | ||
![]() |
83f273f76e | ||
![]() |
802516a82d | ||
![]() |
44ea51e1f2 | ||
![]() |
763eebac7d | ||
![]() |
eb68b8eba6 | ||
![]() |
77cea1b7cb | ||
![]() |
211f803afe | ||
![]() |
618b63bfc0 | ||
![]() |
ada3c2908b | ||
![]() |
f833689961 | ||
![]() |
0f5d0a42a3 | ||
![]() |
8f431a5426 | ||
![]() |
eb1efe10e5 | ||
![]() |
306456967e | ||
![]() |
2f4d1f5689 | ||
![]() |
c6265940ab | ||
![]() |
c2a8aa7919 | ||
![]() |
c7eb9d3c91 | ||
![]() |
a7c2a2bb41 | ||
![]() |
c2de228a5d | ||
![]() |
05dd44e3ce | ||
![]() |
b800ed97da | ||
![]() |
a2260f995f | ||
![]() |
e6a2d65168 | ||
![]() |
7fabb059b7 | ||
![]() |
1864f77866 | ||
![]() |
fcdbbc9854 | ||
![]() |
a1ddcf37d8 | ||
![]() |
a4edacc2d4 | ||
![]() |
c07dc36d26 | ||
![]() |
d859887319 | ||
![]() |
ec59e348c5 | ||
![]() |
fb0b4dcab2 | ||
![]() |
34ff91ccea | ||
![]() |
7a63164891 | ||
![]() |
c982811748 | ||
![]() |
1e58ae94b9 | ||
![]() |
c7e5b1ab7b | ||
![]() |
00d928fa47 | ||
![]() |
da8f6cdf7f | ||
![]() |
9163059a16 | ||
![]() |
615af106d8 | ||
![]() |
d0d9783b78 | ||
![]() |
c85d97b598 | ||
![]() |
178229b283 | ||
![]() |
c2353de3b4 | ||
![]() |
c48687b77c | ||
![]() |
33bdc41e7b |
1082 changed files with 32573 additions and 12594 deletions
2
.flutter
2
.flutter
|
@ -1 +1 @@
|
||||||
Subproject commit 2663184aa79047d0a33a14a3b607954f8fdd8730
|
Subproject commit d8a9f9a52e5af486f80d932e838ee93861ffd863
|
6
.github/workflows/dependency-review.yml
vendored
6
.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@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- name: 'Dependency Review'
|
- name: 'Dependency Review'
|
||||||
uses: actions/dependency-review-action@5a2ce3f5b92ee19cbb1541a4984c76d921601d7c # v4.3.4
|
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
||||||
|
|
19
.github/workflows/quality-check.yml
vendored
19
.github/workflows/quality-check.yml
vendored
|
@ -18,15 +18,18 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Get Flutter packages
|
- name: Get Flutter packages
|
||||||
run: scripts/pub_get_all.sh
|
run: ./flutterw pub get
|
||||||
|
|
||||||
|
- name: Generate app localizations
|
||||||
|
run: ./flutterw gen-l10n
|
||||||
|
|
||||||
- name: Static analysis.
|
- name: Static analysis.
|
||||||
run: ./flutterw analyze
|
run: ./flutterw analyze
|
||||||
|
@ -52,24 +55,24 @@ jobs:
|
||||||
build-mode: manual
|
build-mode: manual
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
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@2dfa2011c5b2a0f1489bf9e433881c92c1631f88 # v4.3.0
|
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@8214744c546c1e5c8f03dde8fab3a7353211988d # v3.26.7
|
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@8214744c546c1e5c8f03dde8fab3a7353211988d # v3.26.7
|
uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||||
with:
|
with:
|
||||||
category: "/language:${{matrix.language}}"
|
category: "/language:${{matrix.language}}"
|
||||||
|
|
28
.github/workflows/release.yml
vendored
28
.github/workflows/release.yml
vendored
|
@ -13,26 +13,31 @@ jobs:
|
||||||
name: GitHub release
|
name: GitHub release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
attestations: write
|
||||||
contents: write
|
contents: write
|
||||||
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
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@2dfa2011c5b2a0f1489bf9e433881c92c1631f88 # v4.3.0
|
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Get Flutter packages
|
- name: Get Flutter packages
|
||||||
run: scripts/pub_get_all.sh
|
run: ./flutterw pub get
|
||||||
|
|
||||||
|
- name: Generate app localizations
|
||||||
|
run: ./flutterw gen-l10n
|
||||||
|
|
||||||
- name: Update Flutter version file
|
- name: Update Flutter version file
|
||||||
run: scripts/update_flutter_version.sh
|
run: scripts/update_flutter_version.sh
|
||||||
|
@ -72,15 +77,20 @@ jobs:
|
||||||
AVES_KEY_PASSWORD: ${{ secrets.AVES_KEY_PASSWORD }}
|
AVES_KEY_PASSWORD: ${{ secrets.AVES_KEY_PASSWORD }}
|
||||||
AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }}
|
AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }}
|
||||||
|
|
||||||
|
- name: Generate artifact attestation
|
||||||
|
uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0
|
||||||
|
with:
|
||||||
|
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@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.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
|
||||||
|
@ -91,15 +101,15 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
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
|
||||||
|
|
||||||
|
|
10
.github/workflows/scorecards.yml
vendored
10
.github/workflows/scorecards.yml
vendored
|
@ -31,17 +31,17 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
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@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.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@8214744c546c1e5c8f03dde8fab3a7353211988d # v3.26.7
|
uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -5,9 +5,11 @@
|
||||||
*.swp
|
*.swp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.atom/
|
.atom/
|
||||||
|
.build/
|
||||||
.buildlog/
|
.buildlog/
|
||||||
.history
|
.history
|
||||||
.svn/
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
|
@ -27,7 +29,6 @@ migrate_working_dir/
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.flutter-plugins
|
.flutter-plugins
|
||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
.packages
|
|
||||||
.pub-cache/
|
.pub-cache/
|
||||||
.pub/
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
|
@ -46,3 +47,6 @@ app.*.map.json
|
||||||
# screenshot generation
|
# screenshot generation
|
||||||
/test_driver/assets/screenshots/
|
/test_driver/assets/screenshots/
|
||||||
/screenshots/
|
/screenshots/
|
||||||
|
|
||||||
|
# generated files
|
||||||
|
/lib/l10ngen/app_localizations*
|
||||||
|
|
29
.vscode/launch.json
vendored
Normal file
29
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "aves (main play)",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"program": "lib/main_play.dart",
|
||||||
|
"args": [
|
||||||
|
"--flavor",
|
||||||
|
"play"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aves (main play) [profile]",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"program": "lib/main_play.dart",
|
||||||
|
"args": [
|
||||||
|
"--flavor",
|
||||||
|
"play"
|
||||||
|
],
|
||||||
|
"flutterMode": "profile"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
272
CHANGELOG.md
272
CHANGELOG.md
|
@ -4,6 +4,277 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## <a id="unreleased"></a>[Unreleased]
|
## <a id="unreleased"></a>[Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Info: show matching dynamic albums
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- crash when decoding some large thumbnails
|
||||||
|
|
||||||
|
## <a id="v1.13.2"></a>[v1.13.2] - 2025-06-02
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- downgraded Flutter to stable v3.27.4
|
||||||
|
- prevent display orientation flip when device rotation is locked
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- moved file losing its extension and no longer being detected as media in some cases
|
||||||
|
- opening home when launching app as media picker
|
||||||
|
- removing groups with obsolete albums
|
||||||
|
- loading group custom covers
|
||||||
|
- crash when parsing some large media with trailing thumbnail
|
||||||
|
|
||||||
|
## <a id="v1.13.1"></a>[v1.13.1] - 2025-05-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- albums: show groups to move/copy/export items
|
||||||
|
- albums: hide grouped albums containing hidden items only
|
||||||
|
|
||||||
|
## <a id="v1.13.0"></a>[v1.13.0] - 2025-05-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Albums: groups
|
||||||
|
- Collection: sort by storage path
|
||||||
|
- Search: week day filters
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- revert to Skia rendering engine
|
||||||
|
|
||||||
|
## <a id="v1.12.10"></a>[v1.12.10] - 2025-04-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Search: format filters
|
||||||
|
- Albums: sort by path
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- upgraded Flutter to stable v3.29.3
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- region decoding failing to access decoder pool
|
||||||
|
|
||||||
|
## <a id="v1.12.9"></a>[v1.12.9] - 2025-04-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Kannada translation (thanks Chethan, Prasannakumar T Bhat)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- enable Impeller rendering engine
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- memory pressure during browsing
|
||||||
|
|
||||||
|
## <a id="v1.12.8"></a>[v1.12.8] - 2025-03-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- swiping images for some combinations of screen size, device pixel ratio, and image size
|
||||||
|
|
||||||
|
## <a id="v1.12.7"></a>[v1.12.7] - 2025-03-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- handle launch error to report and export DB
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- DB post-upgrade sanitization
|
||||||
|
- upgraded Flutter to stable v3.29.2
|
||||||
|
|
||||||
|
## <a id="v1.12.6"></a>[v1.12.6] - 2025-03-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- data loss when editing metadata of items with incorrect mime types
|
||||||
|
- metadata inconsistency in the DB due to v1.12.4 upgrade
|
||||||
|
|
||||||
|
## <a id="v1.12.5"></a>[v1.12.5] - 2025-03-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- support for Samsung HEIC motion photos embedding video in sefd box
|
||||||
|
- Cataloguing: identify video location from Apple QuickTime metadata, and 3GPP `loci` atom
|
||||||
|
- Collection: stack RAW and HEIC with same file names
|
||||||
|
- display home tile in side drawer when customized
|
||||||
|
- Galician translation (thanks Rubén Castiñeiras Lorenzo)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- increased precision of file modified date to milliseconds
|
||||||
|
- upgraded Flutter to stable v3.29.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- opening motion photo embedded video when video track is not the first one
|
||||||
|
- some SVG rendering issues
|
||||||
|
- decoding of SVG containing references to namespaces in !ATTLIST
|
||||||
|
- fallback decoding of images packed in RGBA_1010102 config
|
||||||
|
|
||||||
|
## <a id="v1.12.4"></a>[v1.12.4] - 2025-03-05 [YANKED]
|
||||||
|
|
||||||
|
## <a id="v1.12.3"></a>[v1.12.3] - 2025-02-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Metadata: edit location via GPX
|
||||||
|
- Metadata: toggle for all types in removal dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Viewer: improved subsampling and filter quality strategy
|
||||||
|
- Collection: ignore moving an item to its current directory
|
||||||
|
- Collection: keep selection when action on several items is interrupted before processing
|
||||||
|
- Collection: preserve favourite status when converting items
|
||||||
|
- upgraded Flutter to stable v3.27.4
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- editing TIFF metadata increasing file size
|
||||||
|
- region decoding for some RAW files
|
||||||
|
- incorrect video size or orientation as reported by Media Store
|
||||||
|
- corrupting image when removing video from motion photo with incorrect metadata
|
||||||
|
|
||||||
|
## <a id="v1.12.2"></a>[v1.12.2] - 2025-01-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- DDM coordinate format option
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Video: use `media-kit` instead of `ffmpeg-kit` for metadata fetch
|
||||||
|
- Info: show video chapters
|
||||||
|
- Accessibility: apply system "touch and hold delay" setting
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- crash when cataloguing some videos
|
||||||
|
- switching to PiP for any inactive app state
|
||||||
|
|
||||||
|
## <a id="v1.12.1"></a>[v1.12.1] - 2025-01-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- dynamic album decompose action
|
||||||
|
- Danish translation (thanks Grooty12, Victor M, cat)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- analysis service not triggering because of uninitialized app lifecycle
|
||||||
|
- Viewer: displaying neighbour items when the initial item of a view intent is a new one
|
||||||
|
- Search: dynamic album name filtering
|
||||||
|
|
||||||
|
## <a id="v1.12.0"></a>[v1.12.0] - 2024-12-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Countries: show states for Mexico
|
||||||
|
- Estonian translation (thanks Priit Jõerüüt)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- upgraded Flutter to stable v3.27.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- crash when loading many new items on low memory devices
|
||||||
|
|
||||||
|
## <a id="v1.11.20"></a>[v1.11.20] - 2024-12-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Albums: dynamic albums from filter sets
|
||||||
|
- Bulgarian translation (thanks Petrov)
|
||||||
|
- Tamil translation (thanks தமிழ்நேரம்)
|
||||||
|
|
||||||
|
## <a id="v1.11.19"></a>[v1.11.19] - 2024-11-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- integrate with OS app language settings on Android >=14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- remember title filter visibility by page
|
||||||
|
|
||||||
|
## <a id="v1.11.18"></a>[v1.11.18] - 2024-11-18
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Albums: improved album creation feedback
|
||||||
|
- upgraded Flutter to stable v3.24.5
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- crash when playing video with DCL restriction enabled
|
||||||
|
- cataloguing images with wrong MPF offsets
|
||||||
|
- printing multi-page items containing some unprintable pages
|
||||||
|
- English (Shavian) locale tags for store listing
|
||||||
|
|
||||||
|
## <a id="v1.11.17"></a>[v1.11.17] - 2024-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Map: create shortcut to custom region and filters
|
||||||
|
- Video: frame stepping forward/backward
|
||||||
|
- Video: custom playback buttons
|
||||||
|
- English (Shavian) translation (thanks Paranoid Android)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- upgraded Flutter to stable v3.24.4
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- crash when loading large collection
|
||||||
|
- Viewer: copying content URI item
|
||||||
|
- Albums: creating album with same name as existing empty directory
|
||||||
|
- Privacy: tagging while vaults are unlocked does not yield recent tags visible when vaults are locked
|
||||||
|
|
||||||
|
## <a id="v1.11.16"></a>[v1.11.16] - 2024-10-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- case-insensitive access to restricted directories
|
||||||
|
|
||||||
|
## <a id="v1.11.15"></a>[v1.11.15] - 2024-10-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Enterprise: do not request `INTERACT_ACROSS_PROFILES` permission (Play Store compatibility)
|
||||||
|
|
||||||
|
## <a id="v1.11.14"></a>[v1.11.14] - 2024-10-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Map: OpenTopoMap raster layer
|
||||||
|
- Map: OSM Liberty vector layer (hosted by OSM Americana)
|
||||||
|
- Interoperability: receiving `geo:` URI generally opens map page at location
|
||||||
|
- Interoperability: receiving `geo:` URI when editing item location fills in coordinates
|
||||||
|
- Map basic app shortcut
|
||||||
|
- Enterprise: support for work profile switching from the drawer
|
||||||
|
- Settings: hidden path filters are merged with others and can be toggled
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `Safe mode` basic app shortcut
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- hanging when cataloguing some JPEG MPF images
|
||||||
|
- Apple HDR image detection
|
||||||
|
|
||||||
## <a id="v1.11.13"></a>[v1.11.13] - 2024-09-17
|
## <a id="v1.11.13"></a>[v1.11.13] - 2024-09-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -1126,6 +1397,7 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
- app launching on some devices
|
- app launching on some devices
|
||||||
- corrupting motion photo exif editing (e.g. rotation)
|
- corrupting motion photo exif editing (e.g. rotation)
|
||||||
|
- accessing files in `Download` directory when not using reference case
|
||||||
|
|
||||||
## [v1.4.9] - 2021-08-20
|
## [v1.4.9] - 2021-08-20
|
||||||
|
|
||||||
|
|
87
README.md
87
README.md
|
@ -35,7 +35,7 @@ It scans your media collection to identify **motion photos**, **panoramas** (aka
|
||||||
|
|
||||||
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
|
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
|
||||||
|
|
||||||
Aves integrates with Android (from KitKat to Android 14, including Android TV) with features such as **widgets**, **app shortcuts**, **screen saver** and **global search** handling. It also works as a **media viewer and picker**.
|
Aves integrates with Android (including Android TV) with features such as **widgets**, **app shortcuts**, **screen saver** and **global search** handling. It also works as a **media viewer and picker**.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
@ -111,17 +111,96 @@ Some users have expressed the wish to financially support the project. Thanks!
|
||||||
|
|
||||||
## Project Setup
|
## Project Setup
|
||||||
|
|
||||||
|
### Install dependencies
|
||||||
|
|
||||||
Before running or building the app, update the dependencies for the desired flavor:
|
Before running or building the app, update the dependencies for the desired flavor:
|
||||||
```
|
```
|
||||||
# scripts/apply_flavor_play.sh
|
scripts/apply_flavor_play.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
To build the project, create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys.
|
To build the project, create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys.
|
||||||
|
|
||||||
To run the app:
|
### To run the app:
|
||||||
```
|
```
|
||||||
# ./flutterw run -t lib/main_play.dart --flavor play
|
./flutterw run -t lib/main_play.dart --flavor play
|
||||||
|
```
|
||||||
|
### To build the app:
|
||||||
|
|
||||||
|
creare file con le tue credenziali file.keystore
|
||||||
|
|
||||||
|
dove YOUR_ALIAS_NAME è il tuo unico alias name
|
||||||
|
|
||||||
|
e YOUR_ALIAS_PWD è la password del tuo alias
|
||||||
|
```sh
|
||||||
|
keytool -genkey -v -keystore file.keystore -alias YOUR_ALIAS_NAME -storepass YOUR_ALIAS_PWD -keypass YOUR_ALIAS_PWD -keyalg RSA -validity 36500
|
||||||
|
```
|
||||||
|
in questo caso ho inserito
|
||||||
|
```sh
|
||||||
|
cd android
|
||||||
|
keytool -genkey -v -keystore file.keystore -alias FabioMich66 -storepass Master66 -keypass Master66 -keyalg RSA -validity 36500
|
||||||
|
```
|
||||||
|
se non puoi eseguire keytool perchè non è nel path di sistema cercalo usando
|
||||||
|
```sh
|
||||||
|
cd /
|
||||||
|
sudo find -name keytool
|
||||||
|
```
|
||||||
|
compilare il file `<app dir>/android/key.properties`
|
||||||
|
```
|
||||||
|
nano android/key.properties
|
||||||
|
```
|
||||||
|
questi i miei dati utilizzando il format key_template.properties
|
||||||
|
```
|
||||||
|
storeFile=/Users/fabio/flutter_apps/aves/android/file.keystore
|
||||||
|
storePassword=Master66
|
||||||
|
keyAlias=FabioMich66
|
||||||
|
keyPassword=Master66
|
||||||
|
googleApiKey=<GOOGLE_API_KEY>
|
||||||
|
```
|
||||||
|
infine compilare l'apk
|
||||||
|
```
|
||||||
|
./flutterw build apk -t lib/main_play.dart --flavor play
|
||||||
```
|
```
|
||||||
|
|
||||||
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
|
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
|
||||||
[Build badge]: https://img.shields.io/github/actions/workflow/status/deckerst/aves/quality-check.yml?branch=develop
|
[Build badge]: https://img.shields.io/github/actions/workflow/status/deckerst/aves/quality-check.yml?branch=develop
|
||||||
|
|
||||||
|
## Android studio
|
||||||
|
|
||||||
|
caricare il file da github selezionando le mnù a tendina File-New-project from Version Control
|
||||||
|
|
||||||
|
selezionare version control tipo: git
|
||||||
|
|
||||||
|
inserire URL di aves
|
||||||
|
|
||||||
|
https://github.com/deckerst/aves
|
||||||
|
|
||||||
|
flaggare shallow clone with history troncated 1 commits
|
||||||
|
|
||||||
|
aprire la console sulla dir aves appena creata e caricare le dipendenze
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/apply_flavor_izzy.sh
|
||||||
|
```
|
||||||
|
in settings - Languages and Framework - Dart inserire il path
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/fabio/flutter/bin/cache/
|
||||||
|
```
|
||||||
|
e spuntare project aves
|
||||||
|
|
||||||
|
Edit configurations e aggiungere shell script con un nome x es izzi
|
||||||
|
|
||||||
|
poi flaggare script text e inserire
|
||||||
|
|
||||||
|
./flutterw run -t lib/main_izzy.dart --flavor izzy
|
||||||
|
|
||||||
|
la working directory sarà una cosa così
|
||||||
|
|
||||||
|
/home/fabio/StudioProjects/aves
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,11 @@ analyzer:
|
||||||
# implicit-casts: false
|
# implicit-casts: false
|
||||||
# implicit-dynamic: false
|
# implicit-dynamic: false
|
||||||
|
|
||||||
|
# cf https://github.com/dart-lang/dart_style/wiki/Configuration
|
||||||
|
formatter:
|
||||||
|
page_width: 240
|
||||||
|
trailing_commas: preserve
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
rules:
|
rules:
|
||||||
# from 'flutter_lints', excluded
|
# from 'flutter_lints', excluded
|
||||||
|
@ -36,3 +41,8 @@ linter:
|
||||||
prefer_single_quotes: true
|
prefer_single_quotes: true
|
||||||
sort_child_properties_last: true
|
sort_child_properties_last: true
|
||||||
unawaited_futures: true
|
unawaited_futures: true
|
||||||
|
|
||||||
|
# `const` related, included
|
||||||
|
prefer_const_constructors: true
|
||||||
|
prefer_const_literals_to_create_immutables: true
|
||||||
|
prefer_const_declarations: true
|
||||||
|
|
5
android/.gitignore
vendored
5
android/.gitignore
vendored
|
@ -5,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
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'com.google.devtools.ksp'
|
id 'com.google.devtools.ksp'
|
||||||
|
@ -10,18 +8,6 @@ plugins {
|
||||||
|
|
||||||
def packageName = "deckers.thibault.aves"
|
def packageName = "deckers.thibault.aves"
|
||||||
|
|
||||||
// Flutter properties
|
|
||||||
|
|
||||||
def localProperties = new Properties()
|
|
||||||
def localPropertiesFile = rootProject.file('local.properties')
|
|
||||||
if (localPropertiesFile.exists()) {
|
|
||||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
|
||||||
localProperties.load(reader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
|
||||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
|
||||||
|
|
||||||
// Keys
|
// Keys
|
||||||
|
|
||||||
def keystoreProperties = new Properties()
|
def keystoreProperties = new Properties()
|
||||||
|
@ -42,33 +28,20 @@ if (keystorePropertiesFile.exists()) {
|
||||||
keystoreProperties["googleApiKey"] = System.getenv("AVES_GOOGLE_API_KEY") ?: "<NONE>"
|
keystoreProperties["googleApiKey"] = System.getenv("AVES_GOOGLE_API_KEY") ?: "<NONE>"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain 17
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace 'deckers.thibault.aves'
|
namespace = 'deckers.thibault.aves'
|
||||||
compileSdk 35
|
compileSdk = 36
|
||||||
// cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp
|
|
||||||
ndkVersion '26.1.10909125'
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_21
|
|
||||||
targetCompatibility JavaVersion.VERSION_21
|
|
||||||
}
|
|
||||||
|
|
||||||
lint {
|
|
||||||
checkAllWarnings true
|
|
||||||
warningsAsErrors true
|
|
||||||
disable 'InvalidPackage'
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId packageName
|
applicationId packageName
|
||||||
minSdk flutter.minSdkVersion
|
minSdk flutter.minSdkVersion
|
||||||
targetSdk 35
|
targetSdk 36
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutter.versionCode
|
||||||
versionName flutterVersionName
|
versionName flutter.versionName
|
||||||
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>"]
|
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>"]
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
}
|
}
|
||||||
|
@ -155,29 +128,20 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType(KotlinCompile).configureEach {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
|
||||||
targetCompatibility = JavaVersion.VERSION_21
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
jvmToolchain(21)
|
|
||||||
}
|
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source '../..'
|
source '../..'
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
maven {
|
maven {
|
||||||
url 'https://jitpack.io'
|
url = 'https://jitpack.io'
|
||||||
content {
|
content {
|
||||||
includeGroup "com.github.deckerst"
|
includeGroup "com.github.deckerst"
|
||||||
includeGroup "com.github.deckerst.mp4parser"
|
includeGroup "com.github.deckerst.mp4parser"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
maven {
|
maven {
|
||||||
url 'https://s3.amazonaws.com/repo.commonsware.com'
|
url = 'https://s3.amazonaws.com/repo.commonsware.com'
|
||||||
content {
|
content {
|
||||||
excludeGroupByRegex "com\\.github\\.deckerst.*"
|
excludeGroupByRegex "com\\.github\\.deckerst.*"
|
||||||
}
|
}
|
||||||
|
@ -185,37 +149,38 @@ repositories {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
|
||||||
|
|
||||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
implementation "androidx.appcompat:appcompat:1.7.1"
|
||||||
implementation 'androidx.core:core-ktx:1.13.1'
|
implementation 'androidx.core:core-ktx:1.16.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.5'
|
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.9.1'
|
implementation 'androidx.work:work-runtime-ktx:2.10.1'
|
||||||
|
|
||||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
|
||||||
implementation 'com.commonsware.cwac:document:0.5.0'
|
implementation 'com.commonsware.cwac:document:0.5.0'
|
||||||
implementation 'com.drewnoakes:metadata-extractor:2.19.0'
|
implementation 'com.drewnoakes:metadata-extractor:2.19.0'
|
||||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
// SLF4J implementation for `mp4parser`
|
// SLF4J implementation for `mp4parser`
|
||||||
implementation 'org.slf4j:slf4j-simple:2.0.14'
|
implementation 'org.slf4j:slf4j-simple:2.0.17'
|
||||||
|
|
||||||
// forked, built by JitPack:
|
// forked, built by JitPack:
|
||||||
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||||
|
// - https://jitpack.io/p/deckerst/androidsvg
|
||||||
// - https://jitpack.io/p/deckerst/mp4parser
|
// - https://jitpack.io/p/deckerst/mp4parser
|
||||||
// - https://jitpack.io/p/deckerst/pixymeta-android
|
// - https://jitpack.io/p/deckerst/pixymeta-android
|
||||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:90c06eebf4'
|
implementation 'com.github.deckerst:Android-TiffBitmapFactory:d6b2b0aa4f'
|
||||||
implementation 'com.github.deckerst.mp4parser:isoparser:d5caf7a3dd'
|
implementation 'com.github.deckerst:androidsvg:67db933051'
|
||||||
implementation 'com.github.deckerst.mp4parser:muxer:d5caf7a3dd'
|
implementation 'com.github.deckerst.mp4parser:isoparser:c2898f1832'
|
||||||
implementation 'com.github.deckerst:pixymeta-android:9ec7097f17'
|
implementation 'com.github.deckerst.mp4parser:muxer:c2898f1832'
|
||||||
|
implementation 'com.github.deckerst:pixymeta-android:cb1cdc932e'
|
||||||
implementation project(':exifinterface')
|
implementation project(':exifinterface')
|
||||||
|
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.3'
|
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.13.1'
|
||||||
|
|
||||||
kapt 'androidx.annotation:annotation:1.8.2'
|
kapt 'androidx.annotation:annotation:1.9.1'
|
||||||
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
||||||
|
|
||||||
compileOnly rootProject.findProject(':streams_channel')
|
compileOnly rootProject.findProject(':streams_channel')
|
||||||
|
|
|
@ -179,6 +179,13 @@
|
||||||
<data android:scheme="content" />
|
<data android:scheme="content" />
|
||||||
<data android:scheme="file" />
|
<data android:scheme="file" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
||||||
|
<data android:scheme="geo" />
|
||||||
|
</intent-filter>
|
||||||
<!--
|
<!--
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.EDIT" />
|
<action android:name="android.intent.action.EDIT" />
|
||||||
|
@ -322,10 +329,6 @@
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
<!--
|
|
||||||
Impeller is not supported by `media_kit` v1.1.10+1:
|
|
||||||
https://github.com/media-kit/media-kit/issues/707
|
|
||||||
-->
|
|
||||||
<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(
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package deckers.thibault.aves
|
package deckers.thibault.aves
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.KeyguardManager
|
||||||
import android.app.SearchManager
|
import android.app.SearchManager
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
|
@ -18,11 +19,13 @@ import androidx.annotation.RequiresApi
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat
|
import androidx.core.content.pm.ShortcutInfoCompat
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import app.loup.streams_channel.StreamsChannel
|
import app.loup.streams_channel.StreamsChannel
|
||||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||||
import deckers.thibault.aves.channel.calls.AccessibilityHandler
|
import deckers.thibault.aves.channel.calls.AccessibilityHandler
|
||||||
import deckers.thibault.aves.channel.calls.AnalysisHandler
|
import deckers.thibault.aves.channel.calls.AnalysisHandler
|
||||||
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.AppProfileHandler
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.DebugHandler
|
import deckers.thibault.aves.channel.calls.DebugHandler
|
||||||
import deckers.thibault.aves.channel.calls.DeviceHandler
|
import deckers.thibault.aves.channel.calls.DeviceHandler
|
||||||
|
@ -54,6 +57,7 @@ import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
|
||||||
import deckers.thibault.aves.channel.streams.SettingsChangeStreamHandler
|
import deckers.thibault.aves.channel.streams.SettingsChangeStreamHandler
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
import deckers.thibault.aves.utils.anyCauseIs
|
||||||
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
@ -142,6 +146,7 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
|
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
|
||||||
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
|
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
|
||||||
// - need Activity
|
// - need Activity
|
||||||
|
MethodChannel(messenger, AppProfileHandler.CHANNEL).setMethodCallHandler(AppProfileHandler(this))
|
||||||
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))
|
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))
|
||||||
|
|
||||||
// result streaming: dart -> platform ->->-> dart
|
// result streaming: dart -> platform ->->-> dart
|
||||||
|
@ -293,14 +298,11 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
open fun extractIntentData(intent: Intent?): FieldMap {
|
open fun extractIntentData(intent: Intent?): FieldMap {
|
||||||
when (val action = intent?.action) {
|
when (val action = intent?.action) {
|
||||||
Intent.ACTION_MAIN -> {
|
Intent.ACTION_MAIN -> {
|
||||||
val fields = HashMap<String, Any?>()
|
return hashMapOf(
|
||||||
if (intent.getBooleanExtra(EXTRA_KEY_SAFE_MODE, false)) {
|
INTENT_DATA_KEY_PAGE to intent.getStringExtra(EXTRA_KEY_PAGE),
|
||||||
fields[INTENT_DATA_KEY_SAFE_MODE] = true
|
INTENT_DATA_KEY_FILTERS to extractFiltersFromIntent(intent),
|
||||||
}
|
INTENT_DATA_KEY_EXPLORER_PATH to intent.getStringExtra(EXTRA_KEY_EXPLORER_PATH),
|
||||||
fields[INTENT_DATA_KEY_PAGE] = intent.getStringExtra(EXTRA_KEY_PAGE)
|
)
|
||||||
fields[INTENT_DATA_KEY_FILTERS] = extractFiltersFromIntent(intent)
|
|
||||||
fields[INTENT_DATA_KEY_EXPLORER_PATH] = intent.getStringExtra(EXTRA_KEY_EXPLORER_PATH)
|
|
||||||
return fields
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Intent.ACTION_VIEW,
|
Intent.ACTION_VIEW,
|
||||||
|
@ -310,6 +312,14 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
"com.android.camera.action.REVIEW",
|
"com.android.camera.action.REVIEW",
|
||||||
"com.android.camera.action.SPLIT_SCREEN_REVIEW" -> {
|
"com.android.camera.action.SPLIT_SCREEN_REVIEW" -> {
|
||||||
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
|
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
|
||||||
|
if (uri.scheme == "geo") {
|
||||||
|
return hashMapOf(
|
||||||
|
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW_GEO,
|
||||||
|
INTENT_DATA_KEY_URI to uri.toString(),
|
||||||
|
INTENT_DATA_KEY_FILTERS to extractFiltersFromIntent(intent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MIME type is optional
|
// MIME type is optional
|
||||||
val type = intent.type ?: intent.resolveType(this)
|
val type = intent.type ?: intent.resolveType(this)
|
||||||
val fields = hashMapOf<String, Any?>(
|
val fields = hashMapOf<String, Any?>(
|
||||||
|
@ -318,7 +328,7 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
INTENT_DATA_KEY_URI to uri.toString(),
|
INTENT_DATA_KEY_URI to uri.toString(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as android.app.KeyguardManager
|
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
val isLocked = keyguardManager.isKeyguardLocked
|
val isLocked = keyguardManager.isKeyguardLocked
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
setShowWhenLocked(isLocked)
|
setShowWhenLocked(isLocked)
|
||||||
|
@ -363,7 +373,8 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK_ITEMS,
|
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK_ITEMS,
|
||||||
INTENT_DATA_KEY_MIME_TYPE to intent.type,
|
INTENT_DATA_KEY_MIME_TYPE to intent.type,
|
||||||
INTENT_DATA_KEY_ALLOW_MULTIPLE to (intent.extras?.getBoolean(Intent.EXTRA_ALLOW_MULTIPLE) ?: false),
|
INTENT_DATA_KEY_MIME_TYPES to intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList(),
|
||||||
|
INTENT_DATA_KEY_ALLOW_MULTIPLE to intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -425,32 +436,50 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
|
|
||||||
open fun submitPickedItems(call: MethodCall, result: MethodChannel.Result) {
|
open fun submitPickedItems(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val pickedUris = call.argument<List<String>>("uris")
|
val pickedUris = call.argument<List<String>>("uris")
|
||||||
try {
|
if (pickedUris.isNullOrEmpty()) {
|
||||||
if (!pickedUris.isNullOrEmpty()) {
|
setResult(RESULT_CANCELED)
|
||||||
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) }
|
// move code triggering `Binder` call off the main thread
|
||||||
val intent = Intent().apply {
|
defaultScope.launch { finish() }
|
||||||
val firstUri = toUri(pickedUris.first())
|
return
|
||||||
if (pickedUris.size == 1) {
|
}
|
||||||
data = firstUri
|
|
||||||
} else {
|
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this@MainActivity, uriString.toUri()) }
|
||||||
clipData = ClipData.newUri(contentResolver, null, firstUri).apply {
|
val intent = Intent().apply {
|
||||||
pickedUris.drop(1).forEach {
|
val firstUri = toUri(pickedUris.first())
|
||||||
addItem(ClipData.Item(toUri(it)))
|
if (pickedUris.size == 1) {
|
||||||
}
|
data = firstUri
|
||||||
}
|
|
||||||
}
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
setResult(RESULT_OK, intent)
|
|
||||||
} else {
|
} else {
|
||||||
setResult(RESULT_CANCELED)
|
clipData = ClipData.newUri(contentResolver, null, firstUri).apply {
|
||||||
|
pickedUris.drop(1).forEach {
|
||||||
|
addItem(ClipData.Item(toUri(it)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
// move code triggering `Binder` call off the main thread
|
||||||
|
defaultScope.launch {
|
||||||
|
submitPickedItemsIntent(intent, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun submitPickedItemsIntent(intent: Intent, result: MethodChannel.Result) {
|
||||||
|
try {
|
||||||
|
setResult(RESULT_OK, intent)
|
||||||
finish()
|
finish()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is TransactionTooLargeException || e.cause is TransactionTooLargeException) {
|
setResult(RESULT_CANCELED)
|
||||||
result.error("submitPickedItems-large", "transaction too large with ${pickedUris?.size} URIs", e)
|
if (e is SecurityException && intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
|
||||||
|
// in some environments, providing the write flag yields a `SecurityException`:
|
||||||
|
// "UID XXXX does not have permission to content://XXXX"
|
||||||
|
// so we retry without it
|
||||||
|
Log.i(LOG_TAG, "retry submitting picked items without FLAG_GRANT_WRITE_URI_PERMISSION")
|
||||||
|
intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv()
|
||||||
|
submitPickedItemsIntent(intent, result)
|
||||||
|
} else if (e.anyCauseIs<TransactionTooLargeException>()) {
|
||||||
|
result.error("submitPickedItems-large", "transaction too large with ${intent.clipData?.itemCount} URIs", e)
|
||||||
} else {
|
} else {
|
||||||
result.error("submitPickedItems-exception", "failed to pick ${pickedUris?.size} URIs", e)
|
result.error("submitPickedItems-exception", "failed to pick ${intent.clipData?.itemCount} URIs", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -481,7 +510,16 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
|
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
|
||||||
.setIntent(
|
.setIntent(
|
||||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||||
.putExtra(EXTRA_KEY_PAGE, "/search")
|
.putExtra(EXTRA_KEY_PAGE, SEARCH_PAGE_ROUTE_NAME)
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val map = ShortcutInfoCompat.Builder(this, "map")
|
||||||
|
.setShortLabel(getString(R.string.map_shortcut_short_label))
|
||||||
|
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_map else R.drawable.ic_shortcut_map))
|
||||||
|
.setIntent(
|
||||||
|
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||||
|
.putExtra(EXTRA_KEY_PAGE, MAP_PAGE_ROUTE_NAME)
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
@ -490,21 +528,12 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
|
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
|
||||||
.setIntent(
|
.setIntent(
|
||||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||||
.putExtra(EXTRA_KEY_PAGE, "/collection")
|
.putExtra(EXTRA_KEY_PAGE, COLLECTION_PAGE_ROUTE_NAME)
|
||||||
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val safeMode = ShortcutInfoCompat.Builder(this, "safeMode")
|
val shortcutInfoList = listOf(videos, search, map)
|
||||||
.setShortLabel(getString(R.string.safe_mode_shortcut_short_label))
|
|
||||||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_safe_mode else R.drawable.ic_shortcut_safe_mode))
|
|
||||||
.setIntent(
|
|
||||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
|
||||||
.putExtra(EXTRA_KEY_SAFE_MODE, true)
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val shortcutInfoList = listOf(videos, search, safeMode)
|
|
||||||
ShortcutManagerCompat.setDynamicShortcuts(this, shortcutInfoList)
|
ShortcutManagerCompat.setDynamicShortcuts(this, shortcutInfoList)
|
||||||
Log.i(LOG_TAG, "set shortcuts: ${shortcutInfoList.joinToString(", ") { v -> v.id }}")
|
Log.i(LOG_TAG, "set shortcuts: ${shortcutInfoList.joinToString(", ") { v -> v.id }}")
|
||||||
}
|
}
|
||||||
|
@ -534,6 +563,7 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
const val INTENT_ACTION_SEARCH = "search"
|
const val INTENT_ACTION_SEARCH = "search"
|
||||||
const val INTENT_ACTION_SET_WALLPAPER = "set_wallpaper"
|
const val INTENT_ACTION_SET_WALLPAPER = "set_wallpaper"
|
||||||
const val INTENT_ACTION_VIEW = "view"
|
const val INTENT_ACTION_VIEW = "view"
|
||||||
|
const val INTENT_ACTION_VIEW_GEO = "view_geo"
|
||||||
const val INTENT_ACTION_WIDGET_OPEN = "widget_open"
|
const val INTENT_ACTION_WIDGET_OPEN = "widget_open"
|
||||||
const val INTENT_ACTION_WIDGET_SETTINGS = "widget_settings"
|
const val INTENT_ACTION_WIDGET_SETTINGS = "widget_settings"
|
||||||
|
|
||||||
|
@ -543,9 +573,9 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
const val INTENT_DATA_KEY_EXPLORER_PATH = "explorerPath"
|
const val INTENT_DATA_KEY_EXPLORER_PATH = "explorerPath"
|
||||||
const val INTENT_DATA_KEY_FILTERS = "filters"
|
const val INTENT_DATA_KEY_FILTERS = "filters"
|
||||||
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
|
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
|
||||||
|
const val INTENT_DATA_KEY_MIME_TYPES = "mimeTypes"
|
||||||
const val INTENT_DATA_KEY_PAGE = "page"
|
const val INTENT_DATA_KEY_PAGE = "page"
|
||||||
const val INTENT_DATA_KEY_QUERY = "query"
|
const val INTENT_DATA_KEY_QUERY = "query"
|
||||||
const val INTENT_DATA_KEY_SAFE_MODE = "safeMode"
|
|
||||||
const val INTENT_DATA_KEY_SECURE_URIS = "secureUris"
|
const val INTENT_DATA_KEY_SECURE_URIS = "secureUris"
|
||||||
const val INTENT_DATA_KEY_URI = "uri"
|
const val INTENT_DATA_KEY_URI = "uri"
|
||||||
const val INTENT_DATA_KEY_WIDGET_ID = "widgetId"
|
const val INTENT_DATA_KEY_WIDGET_ID = "widgetId"
|
||||||
|
@ -554,9 +584,15 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
const val EXTRA_KEY_EXPLORER_PATH = "explorerPath"
|
const val EXTRA_KEY_EXPLORER_PATH = "explorerPath"
|
||||||
const val EXTRA_KEY_FILTERS_ARRAY = "filters"
|
const val EXTRA_KEY_FILTERS_ARRAY = "filters"
|
||||||
const val EXTRA_KEY_FILTERS_STRING = "filtersString"
|
const val EXTRA_KEY_FILTERS_STRING = "filtersString"
|
||||||
const val EXTRA_KEY_SAFE_MODE = "safeMode"
|
|
||||||
const val EXTRA_KEY_WIDGET_ID = "widgetId"
|
const val EXTRA_KEY_WIDGET_ID = "widgetId"
|
||||||
|
|
||||||
|
// dart page routes
|
||||||
|
const val COLLECTION_PAGE_ROUTE_NAME = "/collection"
|
||||||
|
const val ENTRY_VIEWER_PAGE_ROUTE_NAME = "/viewer"
|
||||||
|
const val EXPLORER_PAGE_ROUTE_NAME = "/explorer"
|
||||||
|
const val MAP_PAGE_ROUTE_NAME = "/map"
|
||||||
|
const val SEARCH_PAGE_ROUTE_NAME = "/search"
|
||||||
|
|
||||||
// request code to pending runnable
|
// request code to pending runnable
|
||||||
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
|
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,15 @@ import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import app.loup.streams_channel.StreamsChannel
|
import app.loup.streams_channel.StreamsChannel
|
||||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||||
import deckers.thibault.aves.channel.calls.*
|
import deckers.thibault.aves.channel.calls.AccessibilityHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.DeviceHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.EmbeddedDataHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MediaFetchBytesHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MediaSessionHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MediaStoreHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.StorageHandler
|
||||||
import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler
|
import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler
|
||||||
import deckers.thibault.aves.channel.calls.window.WindowHandler
|
import deckers.thibault.aves.channel.calls.window.WindowHandler
|
||||||
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
||||||
|
|
|
@ -16,8 +16,12 @@ import deckers.thibault.aves.utils.FlutterUtils
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import java.util.*
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
|
@ -2,6 +2,7 @@ package deckers.thibault.aves
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
||||||
|
@ -39,7 +40,7 @@ class WallpaperActivity : MainActivity() {
|
||||||
if (originalIntent != null) {
|
if (originalIntent != null) {
|
||||||
val pickedUris = call.argument<List<String>>("uris")
|
val pickedUris = call.argument<List<String>>("uris")
|
||||||
if (!pickedUris.isNullOrEmpty()) {
|
if (!pickedUris.isNullOrEmpty()) {
|
||||||
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) }
|
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, uriString.toUri()) }
|
||||||
onNewIntent(Intent().apply {
|
onNewIntent(Intent().apply {
|
||||||
action = originalIntent
|
action = originalIntent
|
||||||
data = toUri(pickedUris.first())
|
data = toUri(pickedUris.first())
|
||||||
|
|
|
@ -21,27 +21,28 @@ class AvesByteSendingMethodCodec private constructor() : MethodCodec {
|
||||||
return STANDARD.encodeMethodCall(methodCall)
|
return STANDARD.encodeMethodCall(methodCall)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun encodeErrorEnvelope(errorCode: String, errorMessage: String?, errorDetails: Any?): ByteBuffer {
|
||||||
|
return STANDARD.encodeErrorEnvelope(errorCode, errorMessage, errorDetails)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun encodeErrorEnvelopeWithStacktrace(errorCode: String, errorMessage: String?, errorDetails: Any?, errorStacktrace: String?): ByteBuffer {
|
||||||
|
return STANDARD.encodeErrorEnvelopeWithStacktrace(errorCode, errorMessage, errorDetails, errorStacktrace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `StandardMethodCodec` writes the result to a `ByteArrayOutputStream`, then writes the stream to a `ByteBuffer`.
|
||||||
|
// Here we only handle `ByteArray` results, but we avoid the intermediate stream.
|
||||||
override fun encodeSuccessEnvelope(result: Any?): ByteBuffer {
|
override fun encodeSuccessEnvelope(result: Any?): ByteBuffer {
|
||||||
if (result is ByteArray) {
|
if (result is ByteArray) {
|
||||||
val size = result.size
|
return ByteBuffer.allocateDirect(1 + result.size).apply {
|
||||||
return ByteBuffer.allocateDirect(4 + size).apply {
|
// following `StandardMethodCodec`:
|
||||||
|
// First byte is zero in success case, and non-zero otherwise.
|
||||||
put(0)
|
put(0)
|
||||||
put(result)
|
put(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.e(LOG_TAG, "encodeSuccessEnvelope failed with result=$result")
|
Log.e(LOG_TAG, "encodeSuccessEnvelope failed with result=$result")
|
||||||
return ByteBuffer.allocateDirect(0)
|
return encodeErrorEnvelope("invalid-result-type", "Called success with a result which is not a `ByteArray`, type=${result?.javaClass}", null)
|
||||||
}
|
|
||||||
|
|
||||||
override fun encodeErrorEnvelope(errorCode: String, errorMessage: String?, errorDetails: Any?): ByteBuffer {
|
|
||||||
Log.e(LOG_TAG, "encodeErrorEnvelope failed with errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails")
|
|
||||||
return ByteBuffer.allocateDirect(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun encodeErrorEnvelopeWithStacktrace(errorCode: String, errorMessage: String?, errorDetails: Any?, errorStacktrace: String?): ByteBuffer {
|
|
||||||
Log.e(LOG_TAG, "encodeErrorEnvelopeWithStacktrace failed with errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails, errorStacktrace=$errorStacktrace")
|
|
||||||
return ByteBuffer.allocateDirect(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.content.res.Configuration
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.ViewConfiguration
|
||||||
import android.view.accessibility.AccessibilityManager
|
import android.view.accessibility.AccessibilityManager
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -17,6 +18,7 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
|
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
|
||||||
|
"getLongPressTimeout" -> safe(call, result, ::getLongPressTimeout)
|
||||||
"hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts)
|
"hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts)
|
||||||
"getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis)
|
"getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis)
|
||||||
"shouldUseBoldFont" -> safe(call, result, ::shouldUseBoldFont)
|
"shouldUseBoldFont" -> safe(call, result, ::shouldUseBoldFont)
|
||||||
|
@ -34,6 +36,10 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC
|
||||||
result.success(removed)
|
result.success(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getLongPressTimeout(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
result.success(ViewConfiguration.getLongPressTimeout())
|
||||||
|
}
|
||||||
|
|
||||||
private fun hasRecommendedTimeouts(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
private fun hasRecommendedTimeouts(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
|
@ -17,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)
|
||||||
|
|
||||||
|
@ -37,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)
|
||||||
}
|
}
|
||||||
|
@ -51,15 +51,25 @@ class AnalysisHandler(private val activity: FlutterFragmentActivity, private val
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val activityManager: ActivityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||||
|
val runningAppProcesses = activityManager.runningAppProcesses
|
||||||
|
if (runningAppProcesses != null) {
|
||||||
|
val importance = runningAppProcesses[0].importance
|
||||||
|
if (importance < ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
|
||||||
|
// the app is in the background
|
||||||
|
result.error("startAnalysis-background", "app is in the background (process importance=$importance)", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// can be null or empty
|
// can be null or empty
|
||||||
val allEntryIds = call.argument<List<Int>>("entryIds")
|
val allEntryIds = call.argument<List<Int>>("entryIds")
|
||||||
|
|
||||||
// work `Data` cannot occupy more than 10240 bytes when serialized
|
// work `Data` cannot occupy more than 10240 bytes when serialized
|
||||||
// so we save the possibly long list of entry IDs to shared preferences
|
// so we save the possibly long list of entry IDs to shared preferences
|
||||||
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||||
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(
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.*
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
|
@ -15,22 +19,27 @@ import androidx.core.content.FileProvider
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat
|
import androidx.core.content.pm.ShortcutInfoCompat
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import deckers.thibault.aves.MainActivity
|
import deckers.thibault.aves.MainActivity
|
||||||
|
import deckers.thibault.aves.MainActivity.Companion.COLLECTION_PAGE_ROUTE_NAME
|
||||||
|
import deckers.thibault.aves.MainActivity.Companion.ENTRY_VIEWER_PAGE_ROUTE_NAME
|
||||||
|
import deckers.thibault.aves.MainActivity.Companion.EXPLORER_PAGE_ROUTE_NAME
|
||||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_EXPLORER_PATH
|
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_EXPLORER_PATH
|
||||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_ARRAY
|
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_ARRAY
|
||||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_STRING
|
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_STRING
|
||||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_PAGE
|
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_PAGE
|
||||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR
|
import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR
|
||||||
|
import deckers.thibault.aves.MainActivity.Companion.MAP_PAGE_ROUTE_NAME
|
||||||
import deckers.thibault.aves.R
|
import deckers.thibault.aves.R
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
import deckers.thibault.aves.utils.anyCauseIs
|
||||||
import deckers.thibault.aves.utils.getApplicationInfoCompat
|
import deckers.thibault.aves.utils.getApplicationInfoCompat
|
||||||
import deckers.thibault.aves.utils.queryIntentActivitiesCompat
|
import deckers.thibault.aves.utils.queryIntentActivitiesCompat
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
@ -42,7 +51,8 @@ import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.*
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
@ -143,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) {
|
||||||
|
@ -164,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)
|
||||||
}
|
}
|
||||||
|
@ -174,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)
|
||||||
|
@ -209,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) {
|
||||||
|
@ -226,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
|
||||||
|
@ -240,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)
|
||||||
|
@ -263,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
|
||||||
|
@ -298,7 +310,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
val started = safeStartActivityChooser(title, intent)
|
val started = safeStartActivityChooser(title, intent)
|
||||||
result.success(started)
|
result.success(started)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is TransactionTooLargeException || e.cause is TransactionTooLargeException) {
|
if (e.anyCauseIs<TransactionTooLargeException>()) {
|
||||||
result.error("share-large", "transaction too large with ${uriList.size} URIs", e)
|
result.error("share-large", "transaction too large with ${uriList.size} URIs", e)
|
||||||
} else {
|
} else {
|
||||||
result.error("share-exception", "failed to share ${uriList.size} URIs", e)
|
result.error("share-exception", "failed to share ${uriList.size} URIs", e)
|
||||||
|
@ -349,12 +361,17 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
// shortcuts
|
// shortcuts
|
||||||
|
|
||||||
private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
|
private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
// common arguments
|
||||||
val label = call.argument<String>("label")
|
val label = call.argument<String>("label")
|
||||||
val iconBytes = call.argument<ByteArray>("iconBytes")
|
val iconBytes = call.argument<ByteArray>("iconBytes")
|
||||||
|
val route = call.argument<String>("route")
|
||||||
|
// route dependent arguments
|
||||||
val filters = call.argument<List<String>>("filters")
|
val filters = call.argument<List<String>>("filters")
|
||||||
val explorerPath = call.argument<String>("explorerPath")
|
val explorerPath = call.argument<String>("path")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val viewUri = call.argument<String>("viewUri")?.toUri()
|
||||||
if (label == null) {
|
val geoUri = call.argument<String>("geoUri")?.toUri()
|
||||||
|
|
||||||
|
if (label == null || route == null) {
|
||||||
result.error("pin-args", "missing arguments", null)
|
result.error("pin-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -378,24 +395,60 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
// so that foreground is rendered at the intended scale
|
// so that foreground is rendered at the intended scale
|
||||||
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||||
|
|
||||||
icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection)
|
val resId = when (route) {
|
||||||
|
MAP_PAGE_ROUTE_NAME -> if (supportAdaptiveIcon) R.mipmap.ic_shortcut_map else R.drawable.ic_shortcut_map
|
||||||
|
else -> if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection
|
||||||
|
}
|
||||||
|
icon = IconCompat.createWithResource(context, resId)
|
||||||
}
|
}
|
||||||
|
|
||||||
val intent = when {
|
val intent: Intent = when (route) {
|
||||||
filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
COLLECTION_PAGE_ROUTE_NAME -> {
|
||||||
.putExtra(EXTRA_KEY_PAGE, "/collection")
|
if (filters == null) {
|
||||||
.putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray())
|
result.error("pin-filters", "collection shortcut requires filters", null)
|
||||||
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
return
|
||||||
// so we use a joined `String` as fallback
|
}
|
||||||
.putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
|
Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||||
|
.putExtra(EXTRA_KEY_PAGE, route)
|
||||||
|
.putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray())
|
||||||
|
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
||||||
|
// so we use a joined `String` as fallback
|
||||||
|
.putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
|
||||||
|
}
|
||||||
|
|
||||||
explorerPath != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
ENTRY_VIEWER_PAGE_ROUTE_NAME -> {
|
||||||
.putExtra(EXTRA_KEY_PAGE, "/explorer")
|
if (viewUri == null) {
|
||||||
.putExtra(EXTRA_KEY_EXPLORER_PATH, explorerPath)
|
result.error("pin-viewUri", "viewer shortcut requires URI", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Intent(Intent.ACTION_VIEW, viewUri, context, MainActivity::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPLORER_PAGE_ROUTE_NAME -> {
|
||||||
|
Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||||
|
.putExtra(EXTRA_KEY_PAGE, route)
|
||||||
|
.putExtra(EXTRA_KEY_EXPLORER_PATH, explorerPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
MAP_PAGE_ROUTE_NAME -> {
|
||||||
|
if (geoUri == null) {
|
||||||
|
result.error("pin-geoUri", "map shortcut requires URI", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Intent(Intent.ACTION_VIEW, geoUri, context, MainActivity::class.java).apply {
|
||||||
|
putExtra(EXTRA_KEY_PAGE, route)
|
||||||
|
// filters are optional
|
||||||
|
filters?.let {
|
||||||
|
putExtra(EXTRA_KEY_FILTERS_ARRAY, it.toTypedArray())
|
||||||
|
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
||||||
|
// so we use a joined `String` as fallback
|
||||||
|
putExtra(EXTRA_KEY_FILTERS_STRING, it.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
|
|
||||||
else -> {
|
else -> {
|
||||||
result.error("pin-intent", "failed to build intent", null)
|
result.error("pin-route", "unsupported shortcut route=$route", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.CrossProfileApps
|
||||||
|
import android.os.Build
|
||||||
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
|
|
||||||
|
class AppProfileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
when (call.method) {
|
||||||
|
"canInteractAcrossProfiles" -> safe(call, result, ::canInteractAcrossProfiles)
|
||||||
|
"canRequestInteractAcrossProfiles" -> safe(call, result, ::canRequestInteractAcrossProfiles)
|
||||||
|
"requestInteractAcrossProfiles" -> safe(call, result, ::requestInteractAcrossProfiles)
|
||||||
|
"switchProfile" -> safe(call, result, ::switchProfile)
|
||||||
|
"getProfileSwitchingLabel" -> safe(call, result, ::getProfileSwitchingLabel)
|
||||||
|
"getTargetUserProfiles" -> safe(call, result, ::getTargetUserProfiles)
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canInteractAcrossProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||||
|
result.success(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
||||||
|
result.success(crossProfileApps.canInteractAcrossProfiles())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canRequestInteractAcrossProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||||
|
result.success(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
||||||
|
result.success(crossProfileApps.canRequestInteractAcrossProfiles())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestInteractAcrossProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||||
|
result.success(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
||||||
|
val intent = crossProfileApps.createRequestInteractAcrossProfilesIntent()
|
||||||
|
val started = activity.startActivity(intent)
|
||||||
|
|
||||||
|
result.success(started)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun switchProfile(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||||
|
result.success(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
||||||
|
val userHandles = crossProfileApps.targetUserProfiles
|
||||||
|
crossProfileApps.startMainActivity(activity.componentName, userHandles.first())
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getProfileSwitchingLabel(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||||
|
result.success(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
||||||
|
val userHandles = crossProfileApps.targetUserProfiles
|
||||||
|
val label = if (userHandles.isEmpty()) "" else crossProfileApps.getProfileSwitchingLabel(userHandles.first())
|
||||||
|
|
||||||
|
result.success(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTargetUserProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||||
|
result.success(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
||||||
|
val userProfiles = crossProfileApps.targetUserProfiles.map { it.toString() }.toList()
|
||||||
|
result.success(userProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL = "deckers.thibault/aves/app_profile"
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,7 +12,7 @@ import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.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,14 +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.LocaleManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.location.Geocoder
|
import android.location.Geocoder
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.LocaleList
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -21,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)
|
||||||
|
@ -30,8 +34,8 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"canManageMedia" -> safe(call, result, ::canManageMedia)
|
"canManageMedia" -> safe(call, result, ::canManageMedia)
|
||||||
"getCapabilities" -> defaultScope.launch { safe(call, result, ::getCapabilities) }
|
"getCapabilities" -> defaultScope.launch { safe(call, result, ::getCapabilities) }
|
||||||
"getDefaultTimeZoneRawOffsetMillis" -> safe(call, result, ::getDefaultTimeZoneRawOffsetMillis)
|
|
||||||
"getLocales" -> safe(call, result, ::getLocales)
|
"getLocales" -> safe(call, result, ::getLocales)
|
||||||
|
"setLocaleConfig" -> safe(call, result, ::setLocaleConfig)
|
||||||
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
|
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
|
||||||
"isLocked" -> safe(call, result, ::isLocked)
|
"isLocked" -> safe(call, result, ::isLocked)
|
||||||
"isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled)
|
"isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled)
|
||||||
|
@ -59,12 +63,15 @@ 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 getDefaultTimeZoneRawOffsetMillis(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
private fun supportPictureInPicture(): Boolean {
|
||||||
result.success(TimeZone.getDefault().rawOffset)
|
// 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) {
|
||||||
|
@ -88,6 +95,22 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(locales)
|
result.success(locales)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setLocaleConfig(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val locales = call.argument<List<String>>("locales")
|
||||||
|
if (locales.isNullOrEmpty()) {
|
||||||
|
result.error("setLocaleConfig-args", "missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
@SuppressLint("WrongConstant")
|
||||||
|
val lm = context.getSystemService(Context.LOCALE_SERVICE) as? LocaleManager
|
||||||
|
lm?.overrideLocaleConfig = LocaleConfig(LocaleList.forLanguageTags(locales.joinToString(",")))
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
|
||||||
private fun getPerformanceClass(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
private fun getPerformanceClass(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
val performanceClass = Build.VERSION.MEDIA_PERFORMANCE_CLASS
|
val performanceClass = Build.VERSION.MEDIA_PERFORMANCE_CLASS
|
||||||
|
@ -116,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
|
||||||
|
@ -16,6 +17,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Date
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
@ -26,25 +28,25 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getThumbnail" -> ioScope.launch { 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")
|
||||||
val defaultSizeDip = call.argument<Number>("defaultSizeDip")?.toDouble()
|
val defaultSizeDip = call.argument<Number>("defaultSizeDip")?.toDouble()
|
||||||
val quality = call.argument<Int>("quality")
|
val quality = call.argument<Int>("quality")
|
||||||
|
|
||||||
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null || quality == null) {
|
if (uri == null || mimeType == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null || quality == null) {
|
||||||
result.error("getThumbnail-args", "missing arguments", null)
|
result.error("getThumbnail-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -54,7 +56,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
context = context,
|
context = context,
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
dateModifiedSecs = dateModifiedSecs,
|
dateModifiedMillis = dateModifiedMillis ?: (Date().time),
|
||||||
rotationDegrees = rotationDegrees,
|
rotationDegrees = rotationDegrees,
|
||||||
isFlipped = isFlipped,
|
isFlipped = isFlipped,
|
||||||
width = (widthDip * density).roundToInt(),
|
width = (widthDip * density).roundToInt(),
|
||||||
|
@ -66,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()
|
||||||
|
@ -95,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,
|
||||||
|
@ -102,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,7 +6,7 @@ import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
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
|
||||||
|
@ -20,10 +20,10 @@ import com.drew.metadata.exif.ExifDirectoryBase
|
||||||
import com.drew.metadata.exif.ExifIFD0Directory
|
import com.drew.metadata.exif.ExifIFD0Directory
|
||||||
import com.drew.metadata.exif.ExifSubIFDDirectory
|
import com.drew.metadata.exif.ExifSubIFDDirectory
|
||||||
import com.drew.metadata.exif.GpsDirectory
|
import com.drew.metadata.exif.GpsDirectory
|
||||||
import com.drew.metadata.exif.makernotes.AppleMakernoteDirectory
|
|
||||||
import com.drew.metadata.file.FileTypeDirectory
|
import com.drew.metadata.file.FileTypeDirectory
|
||||||
import com.drew.metadata.gif.GifAnimationDirectory
|
import com.drew.metadata.gif.GifAnimationDirectory
|
||||||
import com.drew.metadata.iptc.IptcDirectory
|
import com.drew.metadata.iptc.IptcDirectory
|
||||||
|
import com.drew.metadata.mov.metadata.QuickTimeMetadataDirectory
|
||||||
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
||||||
import com.drew.metadata.png.PngDirectory
|
import com.drew.metadata.png.PngDirectory
|
||||||
import com.drew.metadata.webp.WebpDirectory
|
import com.drew.metadata.webp.WebpDirectory
|
||||||
|
@ -102,12 +102,15 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.mp4parser.boxes.threegpp.ts26244.LocationInformationBox
|
||||||
|
import org.mp4parser.tools.Path
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
import java.text.ParseException
|
import java.text.ParseException
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
|
||||||
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
@ -132,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)
|
||||||
|
@ -164,9 +167,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
// remove this stat as it is not actual XMP data
|
// remove this stat as it is not actual XMP data
|
||||||
dirMap.remove(XmpDirectory().getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
|
dirMap.remove(XmpDirectory().getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
|
||||||
// add schema prefixes for namespace resolution
|
if (dirMap.isNotEmpty()) {
|
||||||
val prefixes = XMPMetaFactory.getSchemaRegistry().prefixes
|
// add schema prefixes for namespace resolution
|
||||||
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
|
val prefixes = XMPMetaFactory.getSchemaRegistry().prefixes
|
||||||
|
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val mp4UuidDirCount = HashMap<String, Int>()
|
val mp4UuidDirCount = HashMap<String, Int>()
|
||||||
|
@ -447,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
|
||||||
|
@ -468,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()) {
|
||||||
|
@ -515,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) {
|
||||||
|
@ -525,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
|
||||||
|
@ -660,7 +695,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
if (metadata.getDirectoriesOfType(MpEntryDirectory::class.java).count { !it.entry.isThumbnail } > 1) {
|
if (metadata.getDirectoriesOfType(MpEntryDirectory::class.java).count { !it.entry.isThumbnail } > 1) {
|
||||||
flags = flags or MASK_IS_MULTIPAGE
|
flags = flags or MASK_IS_MULTIPAGE
|
||||||
|
|
||||||
if (hasAppleHdrGainMap(uri, sizeBytes, metadata)) {
|
if (hasAppleHdrGainMap(uri, sizeBytes)) {
|
||||||
flags = flags or MASK_IS_HDR
|
flags = flags or MASK_IS_HDR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -684,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
|
||||||
|
@ -789,11 +840,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
metadataMap[KEY_FLAGS] = flags
|
metadataMap[KEY_FLAGS] = flags
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasAppleHdrGainMap(uri: Uri, sizeBytes: Long?, primaryMetadata: com.drew.metadata.Metadata): Boolean {
|
private fun hasAppleHdrGainMap(uri: Uri, sizeBytes: Long?): Boolean {
|
||||||
if (!primaryMetadata.containsDirectoryOfType(AppleMakernoteDirectory::class.java)) return false
|
|
||||||
|
|
||||||
val mpEntries = MultiPage.getJpegMpfEntries(context, uri, sizeBytes) ?: return false
|
val mpEntries = MultiPage.getJpegMpfEntries(context, uri, sizeBytes) ?: return false
|
||||||
mpEntries.filter { it.type == MpEntry.TYPE_UNDEFINED }.forEach { mpEntry ->
|
mpEntries.filter { it.type == MpEntry.TYPE_UNDEFINED }.forEachIndexed { mpIndex, mpEntry ->
|
||||||
var dataOffset = mpEntry.dataOffset
|
var dataOffset = mpEntry.dataOffset
|
||||||
if (dataOffset > 0) {
|
if (dataOffset > 0) {
|
||||||
val baseOffset = MultiPage.getJpegMpfBaseOffset(context, uri, sizeBytes)
|
val baseOffset = MultiPage.getJpegMpfBaseOffset(context, uri, sizeBytes)
|
||||||
|
@ -803,9 +852,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
input.skip(dataOffset)
|
input.skip(dataOffset)
|
||||||
val pageMetadata = Helper.safeRead(input, sizeBytes)
|
try {
|
||||||
if (pageMetadata.getDirectoriesOfType(XmpDirectory::class.java).any { it.xmpMeta.hasHdrGainMap() }) {
|
val pageMetadata = Helper.safeRead(input, sizeBytes)
|
||||||
return true
|
if (pageMetadata.getDirectoriesOfType(XmpDirectory::class.java).any { it.xmpMeta.hasHdrGainMap() }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for uri=$uri mpIndex=$mpIndex mpEntry=$mpEntry", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -826,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)
|
||||||
|
@ -866,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) {
|
||||||
|
@ -954,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
|
||||||
|
@ -997,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)
|
||||||
|
@ -1038,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) {
|
||||||
|
@ -1065,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)
|
||||||
|
@ -1117,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
|
||||||
|
@ -1139,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)
|
||||||
|
@ -1215,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)
|
||||||
|
@ -1232,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) {
|
||||||
|
@ -1301,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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getDataUsage" -> ioScope.launch { safe(call, result, ::getDataUsage) }
|
"getDataUsage" -> ioScope.launch { safe(call, result, ::getDataUsage) }
|
||||||
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
|
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
|
||||||
|
"getCacheDirectory" -> ioScope.launch { safe(call, result, ::getCacheDirectory) }
|
||||||
"getUntrackedTrashPaths" -> ioScope.launch { safe(call, result, ::getUntrackedTrashPaths) }
|
"getUntrackedTrashPaths" -> ioScope.launch { safe(call, result, ::getUntrackedTrashPaths) }
|
||||||
"getUntrackedVaultPaths" -> ioScope.launch { safe(call, result, ::getUntrackedVaultPaths) }
|
"getUntrackedVaultPaths" -> ioScope.launch { safe(call, result, ::getUntrackedVaultPaths) }
|
||||||
"getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) }
|
"getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) }
|
||||||
|
@ -122,6 +123,18 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(volumes)
|
result.success(volumes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getCacheDirectory(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val external = call.argument<Boolean>("external")
|
||||||
|
if (external == null) {
|
||||||
|
result.error("getCacheDirectory-args", "missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val dir = (if (external) context.externalCacheDir else context.cacheDir)
|
||||||
|
result.success(dir!!.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun getUntrackedTrashPaths(call: MethodCall, result: MethodChannel.Result) {
|
private fun getUntrackedTrashPaths(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val knownPaths = call.argument<List<String>>("knownPaths")
|
val knownPaths = call.argument<List<String>>("knownPaths")
|
||||||
if (knownPaths == null) {
|
if (knownPaths == null) {
|
||||||
|
|
|
@ -4,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,17 +19,14 @@ 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
|
||||||
private var safe: Boolean = false
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (arguments is Map<*, *>) {
|
if (arguments is Map<*, *>) {
|
||||||
knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to it.value as 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?
|
||||||
// do not use kotlin.collections `getOrDefault` as it crashes on API <24
|
|
||||||
// and there is no warning from Android Studio
|
|
||||||
safe = arguments["safe"] as Boolean? ?: false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,7 +60,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchAll() {
|
private fun fetchAll() {
|
||||||
MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap(), directory, safe) { success(it) }
|
MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap(), directory) { success(it) }
|
||||||
endOfStream()
|
endOfStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.ViewConfiguration
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
@ -21,6 +22,7 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
||||||
private val contentObserver = object : ContentObserver(null) {
|
private val contentObserver = object : ContentObserver(null) {
|
||||||
private var accelerometerRotation: Int = 0
|
private var accelerometerRotation: Int = 0
|
||||||
private var transitionAnimationScale: Float = 1f
|
private var transitionAnimationScale: Float = 1f
|
||||||
|
private var longPressTimeoutMillis: Int = 0
|
||||||
|
|
||||||
init {
|
init {
|
||||||
update()
|
update()
|
||||||
|
@ -36,6 +38,7 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
||||||
hashMapOf(
|
hashMapOf(
|
||||||
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
|
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
|
||||||
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
|
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
|
||||||
|
KEY_LONG_PRESS_TIMEOUT_MILLIS to longPressTimeoutMillis,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -54,6 +57,11 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
||||||
transitionAnimationScale = newTransitionAnimationScale
|
transitionAnimationScale = newTransitionAnimationScale
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
val newLongPressTimeout = ViewConfiguration.getLongPressTimeout()
|
||||||
|
if (longPressTimeoutMillis != newLongPressTimeout) {
|
||||||
|
longPressTimeoutMillis = newLongPressTimeout
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
||||||
}
|
}
|
||||||
|
@ -93,5 +101,8 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<SettingsChangeStreamHandler>()
|
private val LOG_TAG = LogUtils.createTag<SettingsChangeStreamHandler>()
|
||||||
const val CHANNEL = "deckers.thibault/aves/settings_change"
|
const val CHANNEL = "deckers.thibault/aves/settings_change"
|
||||||
|
|
||||||
|
// cf `Settings.Secure.LONG_PRESS_TIMEOUT`
|
||||||
|
const val KEY_LONG_PRESS_TIMEOUT_MILLIS = "long_press_timeout"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,14 +1,30 @@
|
||||||
package deckers.thibault.aves.decoder
|
package deckers.thibault.aves.decoder
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.text.format.Formatter
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.GlideBuilder
|
import com.bumptech.glide.GlideBuilder
|
||||||
import com.bumptech.glide.Registry
|
import com.bumptech.glide.Registry
|
||||||
import com.bumptech.glide.annotation.GlideModule
|
import com.bumptech.glide.annotation.GlideModule
|
||||||
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.load.ImageHeaderParser
|
import com.bumptech.glide.load.ImageHeaderParser
|
||||||
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPoolAdapter
|
||||||
|
import com.bumptech.glide.load.engine.bitmap_recycle.LruArrayPool
|
||||||
|
import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool
|
||||||
|
import com.bumptech.glide.load.engine.cache.DiskCache
|
||||||
|
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
|
||||||
|
import com.bumptech.glide.load.engine.cache.LruResourceCache
|
||||||
|
import com.bumptech.glide.load.engine.cache.MemorySizeCalculator
|
||||||
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
|
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
|
||||||
import com.bumptech.glide.module.AppGlideModule
|
import com.bumptech.glide.module.AppGlideModule
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.compatRemoveIf
|
import deckers.thibault.aves.utils.compatRemoveIf
|
||||||
|
|
||||||
@GlideModule
|
@GlideModule
|
||||||
|
@ -16,6 +32,30 @@ class AvesAppGlideModule : AppGlideModule() {
|
||||||
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
||||||
// hide noisy warning (e.g. for images that can't be decoded)
|
// hide noisy warning (e.g. for images that can't be decoded)
|
||||||
builder.setLogLevel(Log.ERROR)
|
builder.setLogLevel(Log.ERROR)
|
||||||
|
|
||||||
|
// sizing
|
||||||
|
val memorySizeCalculator = MemorySizeCalculator.Builder(context).build()
|
||||||
|
builder.setMemorySizeCalculator(memorySizeCalculator)
|
||||||
|
val size: Int = memorySizeCalculator.bitmapPoolSize
|
||||||
|
if (size > 0) {
|
||||||
|
builder.setBitmapPool(LruBitmapPool(size.toLong()))
|
||||||
|
} else {
|
||||||
|
builder.setBitmapPool(BitmapPoolAdapter())
|
||||||
|
}
|
||||||
|
builder.setArrayPool(LruArrayPool(memorySizeCalculator.arrayPoolSizeInBytes))
|
||||||
|
builder.setMemoryCache(LruResourceCache(memorySizeCalculator.memoryCacheSize.toLong()))
|
||||||
|
|
||||||
|
val diskCacheSize = DiskCache.Factory.DEFAULT_DISK_CACHE_SIZE
|
||||||
|
val internalCacheDiskCacheFactory = InternalCacheDiskCacheFactory(context, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR, diskCacheSize.toLong())
|
||||||
|
builder.setDiskCache(internalCacheDiskCacheFactory)
|
||||||
|
|
||||||
|
fun toMb(bytes: Int) = Formatter.formatFileSize(context, bytes.toLong())
|
||||||
|
Log.d(
|
||||||
|
LOG_TAG, "Glide disk cache size=${toMb(diskCacheSize)}" +
|
||||||
|
", memory cache size=${toMb(memorySizeCalculator.memoryCacheSize)}" +
|
||||||
|
", bitmap pool size=${toMb(memorySizeCalculator.bitmapPoolSize)}" +
|
||||||
|
", array pool size=${toMb(memorySizeCalculator.arrayPoolSizeInBytes)}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||||
|
@ -25,4 +65,28 @@ class AvesAppGlideModule : AppGlideModule() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isManifestParsingEnabled(): Boolean = false
|
override fun isManifestParsingEnabled(): Boolean = false
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LOG_TAG = LogUtils.createTag<AvesAppGlideModule>()
|
||||||
|
|
||||||
|
// request a fresh image with the highest quality format
|
||||||
|
val uncachedFullImageOptions = RequestOptions()
|
||||||
|
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
|
.skipMemoryCache(true)
|
||||||
|
|
||||||
|
fun getModel(context: Context, uri: Uri, mimeType: String, pageId: Int?, sizeBytes: Long? = null): Any {
|
||||||
|
return if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
||||||
|
MultiPageImage(context, uri, mimeType, pageId)
|
||||||
|
} else if (mimeType == MimeTypes.TIFF) {
|
||||||
|
TiffImage(context, uri, pageId)
|
||||||
|
} else if (mimeType == MimeTypes.SVG) {
|
||||||
|
SvgImage(context, uri)
|
||||||
|
} else if (isVideo(mimeType)) {
|
||||||
|
VideoThumbnail(context, uri)
|
||||||
|
} else {
|
||||||
|
StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.Priority
|
import com.bumptech.glide.Priority
|
||||||
import com.bumptech.glide.Registry
|
import com.bumptech.glide.Registry
|
||||||
|
@ -68,7 +69,7 @@ internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int)
|
||||||
bitmapWidth = width
|
bitmapWidth = width
|
||||||
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
|
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
|
||||||
}
|
}
|
||||||
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
|
val bitmap = createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
|
||||||
|
|
||||||
val canvas = Canvas(bitmap)
|
val canvas = Canvas(bitmap)
|
||||||
svg.renderToCanvas(canvas)
|
svg.renderToCanvas(canvas)
|
||||||
|
|
|
@ -3,6 +3,7 @@ package deckers.thibault.aves.decoder
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.graphics.scale
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.Priority
|
import com.bumptech.glide.Priority
|
||||||
import com.bumptech.glide.Registry
|
import com.bumptech.glide.Registry
|
||||||
|
@ -82,7 +83,9 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
|
||||||
inSampleSize = sampleSize
|
inSampleSize = sampleSize
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
val bitmap: Bitmap? = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
|
// calling `TiffBitmapFactory.closeFd(fd)` after decoding yields a segmentation fault
|
||||||
|
|
||||||
if (bitmap == null) {
|
if (bitmap == null) {
|
||||||
callback.onLoadFailed(Exception("Decoding full TIFF yielded null bitmap"))
|
callback.onLoadFailed(Exception("Decoding full TIFF yielded null bitmap"))
|
||||||
} else if (customSize) {
|
} else if (customSize) {
|
||||||
|
@ -96,7 +99,7 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
|
||||||
dstWidth = width
|
dstWidth = width
|
||||||
dstHeight = (width / aspectRatio).toInt()
|
dstHeight = (width / aspectRatio).toInt()
|
||||||
}
|
}
|
||||||
callback.onDataReady(Bitmap.createScaledBitmap(bitmap, dstWidth, dstHeight, true))
|
callback.onDataReady(bitmap.scale(dstWidth, dstHeight))
|
||||||
} else {
|
} else {
|
||||||
callback.onDataReady(bitmap)
|
callback.onDataReady(bitmap)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package deckers.thibault.aves.decoder
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
@ -20,7 +21,6 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||||
import com.bumptech.glide.module.LibraryGlideModule
|
import com.bumptech.glide.module.LibraryGlideModule
|
||||||
import com.bumptech.glide.signature.ObjectKey
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
|
||||||
import deckers.thibault.aves.utils.MemoryUtils
|
import deckers.thibault.aves.utils.MemoryUtils
|
||||||
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
|
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
@ -28,45 +28,54 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.IOException
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@GlideModule
|
@GlideModule
|
||||||
class VideoThumbnailGlideModule : LibraryGlideModule() {
|
class VideoThumbnailGlideModule : LibraryGlideModule() {
|
||||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||||
registry.append(VideoThumbnail::class.java, InputStream::class.java, VideoThumbnailLoader.Factory())
|
registry.append(VideoThumbnail::class.java, Bitmap::class.java, VideoThumbnailLoader.Factory())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class VideoThumbnail(val context: Context, val uri: Uri)
|
class VideoThumbnail(val context: Context, val uri: Uri)
|
||||||
|
|
||||||
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
|
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, Bitmap> {
|
||||||
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
|
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
|
||||||
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
|
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handles(model: VideoThumbnail): Boolean = true
|
override fun handles(model: VideoThumbnail): Boolean = true
|
||||||
|
|
||||||
internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> {
|
internal class Factory : ModelLoaderFactory<VideoThumbnail, Bitmap> {
|
||||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader()
|
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, Bitmap> = VideoThumbnailLoader()
|
||||||
|
|
||||||
override fun teardown() {}
|
override fun teardown() {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<InputStream> {
|
internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<Bitmap> {
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
|
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
|
||||||
ioScope.launch {
|
ioScope.launch {
|
||||||
val retriever = openMetadataRetriever(model.context, model.uri)
|
val retriever = openMetadataRetriever(model.context, model.uri)
|
||||||
if (retriever == null) {
|
if (retriever == null) {
|
||||||
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))
|
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
var bytes = retriever.embeddedPicture
|
var bitmap: Bitmap? = null
|
||||||
if (bytes == null) {
|
|
||||||
|
retriever.embeddedPicture?.let { bytes ->
|
||||||
|
try {
|
||||||
|
bitmap = BitmapFactory.decodeStream(ByteArrayInputStream(bytes))
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitmap == null) {
|
||||||
// there is no consistent strategy across devices to match
|
// there is no consistent strategy across devices to match
|
||||||
// the thumbnails returned by the content resolver / Media Store
|
// the thumbnails returned by the content resolver / Media Store
|
||||||
// so we derive one in an arbitrary way
|
// so we derive one in an arbitrary way
|
||||||
|
@ -111,8 +120,9 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
}
|
}
|
||||||
|
|
||||||
// the returned frame is already rotated according to the video metadata
|
// the returned frame is already rotated according to the video metadata
|
||||||
val frame = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
bitmap = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
val targetBitmapSizeBytes: Long = FORMAT_BYTE_SIZE.toLong() * dstWidth * dstHeight
|
val pixelCount = dstWidth * dstHeight
|
||||||
|
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
|
||||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||||
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the scaled frame at $dstWidth x $dstHeight")
|
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the scaled frame at $dstWidth x $dstHeight")
|
||||||
}
|
}
|
||||||
|
@ -122,7 +132,8 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight)
|
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val targetBitmapSizeBytes: Long = (FORMAT_BYTE_SIZE.toLong() * videoWidth * videoHeight).toLong()
|
val pixelCount = videoWidth * videoHeight
|
||||||
|
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
|
||||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||||
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the full frame at $videoWidth x $videoHeight")
|
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the full frame at $videoWidth x $videoHeight")
|
||||||
}
|
}
|
||||||
|
@ -132,13 +143,12 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
retriever.getFrameAtTime(timeMicros, option)
|
retriever.getFrameAtTime(timeMicros, option)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bytes = frame?.getBytes(canHaveAlpha = false, recycle = false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bytes != null) {
|
if (bitmap == null) {
|
||||||
callback.onDataReady(ByteArrayInputStream(bytes))
|
callback.onLoadFailed(Exception("failed to get embedded picture or any frame for uri=${model.uri}"))
|
||||||
} else {
|
} else {
|
||||||
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
|
callback.onDataReady(bitmap)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
callback.onLoadFailed(e)
|
callback.onLoadFailed(e)
|
||||||
|
@ -151,8 +161,14 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.P)
|
@RequiresApi(Build.VERSION_CODES.P)
|
||||||
private fun getBitmapParams() = MediaMetadataRetriever.BitmapParams().apply {
|
private fun getBitmapParams(): MediaMetadataRetriever.BitmapParams {
|
||||||
preferredConfig = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
val params = MediaMetadataRetriever.BitmapParams()
|
||||||
|
params.preferredConfig = this.getPreferredConfig()
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPreferredConfig(): Bitmap.Config {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
// improved precision with the same memory cost as `ARGB_8888` (4 bytes per pixel)
|
// improved precision with the same memory cost as `ARGB_8888` (4 bytes per pixel)
|
||||||
// for wide-gamut and HDR content which does not require alpha blending
|
// for wide-gamut and HDR content which does not require alpha blending
|
||||||
Bitmap.Config.RGBA_1010102
|
Bitmap.Config.RGBA_1010102
|
||||||
|
@ -167,12 +183,7 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
// cannot cancel
|
// cannot cancel
|
||||||
override fun cancel() {}
|
override fun cancel() {}
|
||||||
|
|
||||||
override fun getDataClass(): Class<InputStream> = InputStream::class.java
|
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
|
||||||
|
|
||||||
override fun getDataSource(): DataSource = DataSource.LOCAL
|
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||||
|
|
||||||
companion object {
|
|
||||||
// same for either `ARGB_8888` or `RGBA_1010102`
|
|
||||||
private const val FORMAT_BYTE_SIZE = BitmapUtils.ARGB_8888_BYTE_SIZE
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
package deckers.thibault.aves.metadata
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
import com.drew.lang.Rational
|
import com.drew.lang.Rational
|
||||||
import com.drew.metadata.Directory
|
import com.drew.metadata.Directory
|
||||||
import com.drew.metadata.exif.ExifDirectoryBase
|
import com.drew.metadata.exif.ExifDirectoryBase
|
||||||
|
@ -19,6 +18,7 @@ import java.util.Locale
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
|
||||||
object ExifInterfaceHelper {
|
object ExifInterfaceHelper {
|
||||||
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
||||||
|
|
|
@ -111,20 +111,25 @@ object MediaMetadataRetrieverHelper {
|
||||||
// format
|
// format
|
||||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
|
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
|
||||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH,
|
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH,
|
||||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
|
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
|
||||||
val bitrate = value.toLongOrNull() ?: 0
|
val bitrate = value.toLongOrNull() ?: 0
|
||||||
if (bitrate > 0) formatBitrate(bitrate) else null
|
if (bitrate > 0) formatBitrate(bitrate) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
|
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
|
||||||
val framerate = value.toDoubleOrNull() ?: 0.0
|
val framerate = value.toDoubleOrNull() ?: 0.0
|
||||||
if (framerate > 0.0) "$framerate" else null
|
if (framerate > 0.0) "$framerate" else null
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_DURATION -> {
|
MediaMetadataRetriever.METADATA_KEY_DURATION -> {
|
||||||
val dateMillis = value.toLongOrNull() ?: 0
|
val dateMillis = value.toLongOrNull() ?: 0
|
||||||
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
|
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
|
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
|
||||||
when (value.toIntOrNull()) {
|
when (value.toIntOrNull()) {
|
||||||
MediaFormat.COLOR_RANGE_FULL -> "Full"
|
MediaFormat.COLOR_RANGE_FULL -> "Full"
|
||||||
|
@ -132,6 +137,7 @@ object MediaMetadataRetrieverHelper {
|
||||||
else -> value
|
else -> value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
|
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
|
||||||
when (value.toIntOrNull()) {
|
when (value.toIntOrNull()) {
|
||||||
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
|
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
|
||||||
|
@ -141,6 +147,7 @@ object MediaMetadataRetrieverHelper {
|
||||||
else -> value
|
else -> value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
|
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
|
||||||
when (value.toIntOrNull()) {
|
when (value.toIntOrNull()) {
|
||||||
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
|
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
|
||||||
|
@ -154,6 +161,7 @@ object MediaMetadataRetrieverHelper {
|
||||||
MediaMetadataRetriever.METADATA_KEY_COMPILATION,
|
MediaMetadataRetriever.METADATA_KEY_COMPILATION,
|
||||||
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
|
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
|
||||||
MediaMetadataRetriever.METADATA_KEY_YEAR -> if (value != "0") value else null
|
MediaMetadataRetriever.METADATA_KEY_YEAR -> if (value != "0") value else null
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER -> if (value != "0/0") value else null
|
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER -> if (value != "0/0") value else null
|
||||||
MediaMetadataRetriever.METADATA_KEY_DATE -> {
|
MediaMetadataRetriever.METADATA_KEY_DATE -> {
|
||||||
val dateMillis = Metadata.parseVideoMetadataDate(value)
|
val dateMillis = Metadata.parseVideoMetadataDate(value)
|
||||||
|
@ -168,4 +176,12 @@ object MediaMetadataRetrieverHelper {
|
||||||
}?.let { save(it) }
|
}?.let { save(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
||||||
|
if (this.containsKey(key)) save(this.getInteger(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
||||||
|
if (this.containsKey(key)) save(this.getLong(key))
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -2,8 +2,9 @@ package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
import android.util.Log
|
||||||
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
||||||
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
@ -14,8 +15,11 @@ import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
|
||||||
object Metadata {
|
object Metadata {
|
||||||
|
private val LOG_TAG = LogUtils.createTag<Metadata>()
|
||||||
|
|
||||||
const val IPTC_MARKER_BYTE: Byte = 0x1c
|
const val IPTC_MARKER_BYTE: Byte = 0x1c
|
||||||
|
|
||||||
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
|
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
|
||||||
|
@ -135,29 +139,42 @@ object Metadata {
|
||||||
|
|
||||||
private fun getSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): Uri {
|
private fun getSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): Uri {
|
||||||
// formats known to yield OOM for large files
|
// formats known to yield OOM for large files
|
||||||
return if ((MimeTypes.isImage(mimeType) || mimeType == MimeTypes.MP4)) {
|
return when (mimeType) {
|
||||||
if (isDangerouslyLarge(sizeBytes)) {
|
// formats known to yield OOM for large files
|
||||||
// make a preview from the beginning of the file,
|
MimeTypes.DNG,
|
||||||
// hoping the metadata is accessible in the copied chunk
|
MimeTypes.DNG_ADOBE,
|
||||||
var previewFile = previewFiles[uri]
|
MimeTypes.HEIC,
|
||||||
if (previewFile == null) {
|
MimeTypes.HEIF,
|
||||||
previewFile = createPreviewFile(context, uri)
|
MimeTypes.MP4,
|
||||||
previewFiles[uri] = previewFile
|
MimeTypes.PSD_VND,
|
||||||
|
MimeTypes.PSD_X,
|
||||||
|
MimeTypes.TIFF ->
|
||||||
|
if (isDangerouslyLarge(sizeBytes)) {
|
||||||
|
Log.d(LOG_TAG, "Dangerously large file with uri=$uri, mimeType=$mimeType, size=$sizeBytes")
|
||||||
|
// make a preview from the beginning of the file,
|
||||||
|
// hoping the metadata is accessible in the copied chunk
|
||||||
|
var previewFile = previewFiles[uri]
|
||||||
|
if (previewFile == null) {
|
||||||
|
previewFile = createPreviewFile(context, uri)
|
||||||
|
previewFiles[uri] = previewFile
|
||||||
|
}
|
||||||
|
Uri.fromFile(previewFile)
|
||||||
|
} else {
|
||||||
|
// small enough to be safe as it is
|
||||||
|
uri
|
||||||
}
|
}
|
||||||
Uri.fromFile(previewFile)
|
|
||||||
} else {
|
else ->
|
||||||
// small enough to be safe as it is
|
// *probably* safe
|
||||||
uri
|
uri
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// *probably* safe
|
|
||||||
uri
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createPreviewFile(context: Context, uri: Uri): File {
|
fun createPreviewFile(context: Context, uri: Uri): File {
|
||||||
|
val size = PREVIEW_SIZE
|
||||||
|
Log.d(LOG_TAG, "create preview of size=$size for uri=$uri")
|
||||||
return StorageUtils.createTempFile(context).apply {
|
return StorageUtils.createTempFile(context).apply {
|
||||||
transferFrom(StorageUtils.openInputStream(context, uri), PREVIEW_SIZE)
|
transferFrom(StorageUtils.openInputStream(context, uri), size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -9,13 +9,14 @@ import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
import com.adobe.internal.xmp.XMPMeta
|
import com.adobe.internal.xmp.XMPMeta
|
||||||
import com.drew.imaging.jpeg.JpegSegmentType
|
import com.drew.imaging.jpeg.JpegSegmentType
|
||||||
import com.drew.metadata.exif.ExifDirectoryBase
|
import com.drew.metadata.exif.ExifDirectoryBase
|
||||||
import com.drew.metadata.exif.ExifIFD0Directory
|
import com.drew.metadata.exif.ExifIFD0Directory
|
||||||
import com.drew.metadata.xmp.XmpDirectory
|
import com.drew.metadata.xmp.XmpDirectory
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
||||||
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
|
||||||
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
|
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
|
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
|
||||||
|
@ -30,10 +31,14 @@ import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.indexOfBytes
|
import deckers.thibault.aves.utils.indexOfBytes
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
import java.io.DataInputStream
|
import java.io.DataInputStream
|
||||||
|
import java.io.EOFException
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
|
||||||
object MultiPage {
|
object MultiPage {
|
||||||
private val LOG_TAG = LogUtils.createTag<MultiPage>()
|
private val LOG_TAG = LogUtils.createTag<MultiPage>()
|
||||||
|
|
||||||
|
// TODO TLAD more generic support, (e.g. 0x00000014 + `ftyp` + `qt `)
|
||||||
|
// atom length (variable, e.g. `0x00000018`) + atom type (`ftyp`) + type (variable, e.g. `mp42`, `qt`)
|
||||||
private val heicMotionPhotoVideoStartIndicator = byteArrayOf(0x00, 0x00, 0x00, 0x18) + "ftypmp42".toByteArray()
|
private val heicMotionPhotoVideoStartIndicator = byteArrayOf(0x00, 0x00, 0x00, 0x18) + "ftypmp42".toByteArray()
|
||||||
|
|
||||||
// page info
|
// page info
|
||||||
|
@ -46,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)
|
||||||
|
@ -89,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
|
||||||
|
@ -136,21 +153,31 @@ object MultiPage {
|
||||||
// starts after `[APP2 marker (1 byte)] [segment size (2 bytes)] [MPF marker (4 bytes)]`
|
// starts after `[APP2 marker (1 byte)] [segment size (2 bytes)] [MPF marker (4 bytes)]`
|
||||||
fun getJpegMpfBaseOffset(context: Context, uri: Uri, sizeBytes: Long?): Int? {
|
fun getJpegMpfBaseOffset(context: Context, uri: Uri, sizeBytes: Long?): Int? {
|
||||||
val mimeType = MimeTypes.JPEG
|
val mimeType = MimeTypes.JPEG
|
||||||
|
val endMarker = 0xFF
|
||||||
val app2Marker = JpegSegmentType.APP2.byteValue
|
val app2Marker = JpegSegmentType.APP2.byteValue
|
||||||
val mpfMarker = "MPF".toByteArray() + 0x00
|
val mpfMarker = "MPF".toByteArray() + 0x00
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
var offset = 0
|
var offset = 0
|
||||||
|
val marker = ByteArray(4)
|
||||||
while (true) {
|
while (true) {
|
||||||
do {
|
// look for APP2 marker (0xFFE2)
|
||||||
val b = input.read().toByte()
|
var found = false
|
||||||
|
while (!found) {
|
||||||
|
var i = input.read()
|
||||||
|
if (i == -1) throw EOFException()
|
||||||
offset++
|
offset++
|
||||||
} while (b != app2Marker)
|
if (i == endMarker) {
|
||||||
|
i = input.read()
|
||||||
|
if (i == -1) throw EOFException()
|
||||||
|
offset++
|
||||||
|
found = i.toByte() == app2Marker
|
||||||
|
}
|
||||||
|
}
|
||||||
// skip 2 bytes for segment size
|
// skip 2 bytes for segment size
|
||||||
input.skip(2)
|
input.skip(2)
|
||||||
offset += 2
|
offset += 2
|
||||||
val marker = ByteArray(4)
|
|
||||||
input.read(marker, 0, marker.size)
|
input.read(marker, 0, marker.size)
|
||||||
offset += 4
|
offset += 4
|
||||||
if (marker.contentEquals(mpfMarker)) {
|
if (marker.contentEquals(mpfMarker)) {
|
||||||
|
@ -239,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),
|
||||||
|
@ -349,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,7 +5,7 @@ import android.content.Context
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
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
|
||||||
|
@ -29,6 +29,7 @@ import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
|
||||||
class SourceEntry {
|
class SourceEntry {
|
||||||
private val origin: Int
|
private val origin: Int
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,8 +117,8 @@ class SourceEntry {
|
||||||
// metadata retrieval
|
// metadata retrieval
|
||||||
// expects entry with: uri, mimeType
|
// expects entry with: uri, mimeType
|
||||||
// finds: width, height, orientation/rotation, date, title, duration
|
// finds: width, height, orientation/rotation, date, title, duration
|
||||||
fun fillPreCatalogMetadata(context: Context, safe: Boolean): SourceEntry {
|
fun fillPreCatalogMetadata(context: Context): SourceEntry {
|
||||||
if (isSvg || safe) return this
|
if (isSvg) return this
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
fillVideoByMediaMetadataRetriever(context)
|
fillVideoByMediaMetadataRetriever(context)
|
||||||
if (isSized && hasDuration) return this
|
if (isSized && hasDuration) return this
|
||||||
|
|
|
@ -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) {
|
||||||
|
@ -53,7 +54,7 @@ internal class FileImageProvider : ImageProvider() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry.fillPreCatalogMetadata(context, safe = false)
|
entry.fillPreCatalogMetadata(context)
|
||||||
|
|
||||||
if (allowUnsized || entry.isSized || entry.isSvg || entry.isVideo) {
|
if (allowUnsized || entry.isSized || entry.isSvg || entry.isVideo) {
|
||||||
callback.onSuccess(entry.toMap())
|
callback.onSuccess(entry.toMap())
|
||||||
|
@ -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)
|
||||||
|
@ -137,8 +134,7 @@ abstract class ImageProvider {
|
||||||
"success" to false,
|
"success" to false,
|
||||||
)
|
)
|
||||||
|
|
||||||
// prevent naming with a `.` prefix as it would hide the file and remove it from the Media Store
|
if (sourcePath != null) {
|
||||||
if (sourcePath != null && !desiredName.startsWith('.')) {
|
|
||||||
try {
|
try {
|
||||||
var newFields: FieldMap = skippedFieldMap
|
var newFields: FieldMap = skippedFieldMap
|
||||||
if (!isCancelledOp()) {
|
if (!isCancelledOp()) {
|
||||||
|
@ -146,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(
|
||||||
|
@ -281,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
|
||||||
|
@ -318,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)
|
||||||
|
@ -377,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,
|
||||||
|
@ -484,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) {
|
||||||
|
@ -570,26 +561,42 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createTimeStampFileName() = Date().time.toString()
|
||||||
|
|
||||||
|
private fun sanitizeDesiredFileName(desiredName: String): String {
|
||||||
|
var name = desiredName
|
||||||
|
// prevent creating hidden files
|
||||||
|
while (name.isNotEmpty() && name.startsWith(".")) {
|
||||||
|
name = name.substring(1)
|
||||||
|
}
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
name = createTimeStampFileName()
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
// returns available name to use, or `null` to skip it
|
// returns available name to use, or `null` to skip it
|
||||||
suspend fun resolveTargetFileNameWithoutExtension(
|
suspend fun resolveTargetFileNameWithoutExtension(
|
||||||
contextWrapper: ContextWrapper,
|
contextWrapper: ContextWrapper,
|
||||||
dir: String,
|
dir: String,
|
||||||
desiredNameWithoutExtension: String,
|
desiredNameWithoutExtension: String,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
|
defaultExtension: String?,
|
||||||
conflictStrategy: NameConflictStrategy,
|
conflictStrategy: NameConflictStrategy,
|
||||||
): NameConflictResolution {
|
): NameConflictResolution {
|
||||||
var resolvedName: String? = desiredNameWithoutExtension
|
val sanitizedNameWithoutExtension = sanitizeDesiredFileName(desiredNameWithoutExtension)
|
||||||
|
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, "$desiredNameWithoutExtension$extension")
|
val targetFile = File(dir, "$sanitizedNameWithoutExtension$extension")
|
||||||
when (conflictStrategy) {
|
when (conflictStrategy) {
|
||||||
NameConflictStrategy.RENAME -> {
|
NameConflictStrategy.RENAME -> {
|
||||||
var nameWithoutExtension = desiredNameWithoutExtension
|
var nameWithoutExtension = sanitizedNameWithoutExtension
|
||||||
var i = 0
|
var i = 0
|
||||||
while (File(dir, "$nameWithoutExtension$extension").exists()) {
|
while (File(dir, "$nameWithoutExtension$extension").exists()) {
|
||||||
i++
|
i++
|
||||||
nameWithoutExtension = "$desiredNameWithoutExtension ($i)"
|
nameWithoutExtension = "$sanitizedNameWithoutExtension ($i)"
|
||||||
}
|
}
|
||||||
resolvedName = nameWithoutExtension
|
resolvedName = nameWithoutExtension
|
||||||
}
|
}
|
||||||
|
@ -650,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
|
||||||
|
@ -682,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()
|
||||||
|
@ -733,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
|
||||||
|
@ -781,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()
|
||||||
|
@ -899,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(
|
||||||
|
@ -917,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()
|
||||||
|
@ -982,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 {
|
||||||
|
@ -997,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)
|
||||||
})
|
})
|
||||||
|
@ -1262,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)
|
||||||
|
@ -1307,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 ->
|
||||||
|
@ -1323,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()
|
||||||
|
|
|
@ -12,6 +12,7 @@ import android.graphics.BitmapFactory
|
||||||
import android.media.MediaScannerConnection
|
import android.media.MediaScannerConnection
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
@ -19,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
|
||||||
|
@ -49,15 +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?,
|
||||||
safe: Boolean,
|
|
||||||
handleNewEntry: NewEntryHandler,
|
handleNewEntry: NewEntryHandler,
|
||||||
) {
|
) {
|
||||||
Log.d(LOG_TAG, "fetching all media store items for ${knownEntries.size} known entries, directory=$directory safe=$safe")
|
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
|
||||||
|
@ -76,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)
|
||||||
}
|
}
|
||||||
|
@ -84,8 +85,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
} else {
|
} else {
|
||||||
handleNew = handleNewEntry
|
handleNew = handleNewEntry
|
||||||
}
|
}
|
||||||
fetchFrom(context, isModified, handleNew, IMAGE_CONTENT_URI, IMAGE_PROJECTION, selection, selectionArgs, safe = safe)
|
fetchFrom(context, isModified, handleNew, IMAGE_CONTENT_URI, IMAGE_PROJECTION, selection, selectionArgs)
|
||||||
fetchFrom(context, isModified, handleNew, VIDEO_CONTENT_URI, VIDEO_PROJECTION, selection, selectionArgs, safe = safe)
|
fetchFrom(context, isModified, handleNew, VIDEO_CONTENT_URI, VIDEO_PROJECTION, selection, selectionArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// the provided URI can point to the wrong media collection,
|
// the provided URI can point to the wrong media collection,
|
||||||
|
@ -95,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)) {
|
||||||
|
@ -208,7 +209,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
selection: String? = null,
|
selection: String? = null,
|
||||||
selectionArgs: Array<String>? = null,
|
selectionArgs: Array<String>? = null,
|
||||||
fileMimeType: String? = null,
|
fileMimeType: String? = null,
|
||||||
safe: Boolean = false,
|
|
||||||
): Boolean {
|
): Boolean {
|
||||||
var found = false
|
var found = false
|
||||||
val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
|
val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
|
||||||
|
@ -227,8 +227,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
||||||
val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
||||||
val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
||||||
val 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, safe)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -454,10 +457,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
effectiveTargetDir = targetDir
|
effectiveTargetDir = targetDir
|
||||||
targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
||||||
if (!File(targetDir).exists()) {
|
if (!File(targetDir).exists()) {
|
||||||
val downloadDirPath = StorageUtils.getDownloadDirPath(activity, targetDir)
|
|
||||||
val isDownloadSubdir = downloadDirPath != null && targetDir.startsWith(downloadDirPath)
|
|
||||||
// download subdirectories can be created later by Media Store insertion
|
// download subdirectories can be created later by Media Store insertion
|
||||||
if (!isDownloadSubdir) {
|
if (!isDownloadSubdir(activity, targetDir)) {
|
||||||
callback.onFailure(Exception("failed to create directory at path=$targetDir"))
|
callback.onFailure(Exception("failed to create directory at path=$targetDir"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -481,64 +482,62 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
"success" to false,
|
"success" to false,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (sourcePath != null) {
|
// on API 30 we cannot get access granted directly to a volume root from its document tree,
|
||||||
// on API 30 we cannot get access granted directly to a volume root from its document tree,
|
// but it is still less constraining to use tree document files than to rely on the Media Store
|
||||||
// but it is still less constraining to use tree document files than to rely on the Media Store
|
//
|
||||||
//
|
// Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
|
||||||
// Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
|
// - we need to scan the file to get the Media Store content URI
|
||||||
// - we need to scan the file to get the Media Store content URI
|
// - the underlying document provider controls the new file name
|
||||||
// - the underlying document provider controls the new file name
|
//
|
||||||
//
|
// Relying on the Media Store, we can create an item via `ContentResolver.insert()`
|
||||||
// Relying on the Media Store, we can create an item via `ContentResolver.insert()`
|
// with a path, and retrieve its content URI, but:
|
||||||
// with a path, and retrieve its content URI, but:
|
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
|
||||||
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
|
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
|
||||||
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
|
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
|
||||||
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
|
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
|
||||||
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
|
// - there is no documentation regarding support for usage with removable storage
|
||||||
// - there is no documentation regarding support for usage with removable storage
|
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
|
||||||
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
|
try {
|
||||||
try {
|
val appDir = when {
|
||||||
val appDir = when {
|
toBin -> StorageUtils.trashDirFor(activity, sourcePath ?: StorageUtils.getPrimaryVolumePath(activity))
|
||||||
toBin -> StorageUtils.trashDirFor(activity, sourcePath)
|
toVault -> File(targetDir)
|
||||||
toVault -> File(targetDir)
|
else -> null
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (appDir != null) {
|
|
||||||
effectiveTargetDir = ensureTrailingSeparator(appDir.path)
|
|
||||||
targetDirDocFile = DocumentFileCompat.fromFile(appDir)
|
|
||||||
|
|
||||||
if (toVault) {
|
|
||||||
appDir.mkdirs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effectiveTargetDir != null) {
|
|
||||||
val newFields = if (isCancelledOp()) skippedFieldMap else {
|
|
||||||
val sourceFile = File(sourcePath)
|
|
||||||
if (!sourceFile.exists() && toBin) {
|
|
||||||
delete(activity, sourceUri, sourcePath, mimeType = mimeType)
|
|
||||||
deletedFieldMap
|
|
||||||
} else {
|
|
||||||
moveSingle(
|
|
||||||
activity = activity,
|
|
||||||
sourceFile = sourceFile,
|
|
||||||
sourceUri = sourceUri,
|
|
||||||
targetDir = effectiveTargetDir,
|
|
||||||
targetDirDocFile = targetDirDocFile,
|
|
||||||
desiredName = desiredName ?: sourceFile.name,
|
|
||||||
nameConflictStrategy = nameConflictStrategy,
|
|
||||||
mimeType = mimeType,
|
|
||||||
copy = copy,
|
|
||||||
toBin = toBin,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result["newFields"] = newFields
|
|
||||||
result["success"] = true
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
|
|
||||||
}
|
}
|
||||||
|
if (appDir != null) {
|
||||||
|
effectiveTargetDir = ensureTrailingSeparator(appDir.path)
|
||||||
|
targetDirDocFile = DocumentFileCompat.fromFile(appDir)
|
||||||
|
|
||||||
|
if (toVault) {
|
||||||
|
appDir.mkdirs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveTargetDir != null) {
|
||||||
|
val newFields = if (isCancelledOp()) skippedFieldMap else {
|
||||||
|
val sourceFile = if (sourcePath != null) File(sourcePath) else null
|
||||||
|
if (sourceFile != null && !sourceFile.exists() && toBin) {
|
||||||
|
delete(activity, sourceUri, sourcePath, mimeType = mimeType)
|
||||||
|
deletedFieldMap
|
||||||
|
} else {
|
||||||
|
moveSingle(
|
||||||
|
activity = activity,
|
||||||
|
sourceFile = sourceFile,
|
||||||
|
sourceUri = sourceUri,
|
||||||
|
targetDir = effectiveTargetDir,
|
||||||
|
targetDirDocFile = targetDirDocFile,
|
||||||
|
desiredName = desiredName ?: sourceFile?.name ?: sourceUri.lastPathSegment ?: createTimeStampFileName(),
|
||||||
|
nameConflictStrategy = nameConflictStrategy,
|
||||||
|
mimeType = mimeType,
|
||||||
|
copy = copy,
|
||||||
|
toBin = toBin,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result["newFields"] = newFields
|
||||||
|
result["success"] = true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
|
||||||
}
|
}
|
||||||
callback.onSuccess(result)
|
callback.onSuccess(result)
|
||||||
}
|
}
|
||||||
|
@ -547,7 +546,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
|
|
||||||
private suspend fun moveSingle(
|
private suspend fun moveSingle(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
sourceFile: File,
|
sourceFile: File?,
|
||||||
sourceUri: Uri,
|
sourceUri: Uri,
|
||||||
targetDir: String,
|
targetDir: String,
|
||||||
targetDirDocFile: DocumentFileCompat?,
|
targetDirDocFile: DocumentFileCompat?,
|
||||||
|
@ -557,8 +556,9 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
copy: Boolean,
|
copy: Boolean,
|
||||||
toBin: Boolean,
|
toBin: Boolean,
|
||||||
): FieldMap {
|
): FieldMap {
|
||||||
val sourcePath = sourceFile.path
|
val sourcePath = sourceFile?.path
|
||||||
val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) }
|
val sourceExtension = sourceFile?.extension
|
||||||
|
val sourceDir = sourceFile?.parent?.let { ensureTrailingSeparator(it) }
|
||||||
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
|
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
|
||||||
// nothing to do unless it's a renamed copy
|
// nothing to do unless it's a renamed copy
|
||||||
return skippedFieldMap
|
return skippedFieldMap
|
||||||
|
@ -570,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
|
||||||
|
@ -581,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)
|
||||||
|
@ -602,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)
|
||||||
|
@ -616,24 +618,23 @@ 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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
val downloadDirPath = StorageUtils.getDownloadDirPath(activity, targetDir)
|
if (isDownloadSubdir(activity, targetDir)) {
|
||||||
val isDownloadSubdir = downloadDirPath != null && targetDir.startsWith(downloadDirPath)
|
|
||||||
if (isDownloadSubdir) {
|
|
||||||
return insertByMediaStore(
|
return insertByMediaStore(
|
||||||
activity = activity,
|
activity = activity,
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
|
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}",
|
||||||
write = write,
|
write = write,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -645,10 +646,18 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetDirDocFile = targetDirDocFile,
|
targetDirDocFile = targetDirDocFile,
|
||||||
targetNameWithoutExtension = targetNameWithoutExtension,
|
targetNameWithoutExtension = targetNameWithoutExtension,
|
||||||
|
defaultExtension = defaultExtension,
|
||||||
write = write,
|
write = write,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isDownloadSubdir(context: Context, dir: String): Boolean {
|
||||||
|
val volumePath = StorageUtils.getVolumePath(context, dir) ?: return false
|
||||||
|
val downloadDirPath = ensureTrailingSeparator(File(volumePath, Environment.DIRECTORY_DOWNLOADS).path)
|
||||||
|
// effective download path may have a different case
|
||||||
|
return dir.lowercase().startsWith(downloadDirPath.lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
private fun insertByFile(
|
private fun insertByFile(
|
||||||
targetDir: String,
|
targetDir: String,
|
||||||
targetFileName: String,
|
targetFileName: String,
|
||||||
|
@ -696,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")
|
||||||
|
@ -704,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)
|
||||||
|
@ -822,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
|
||||||
|
@ -911,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) {
|
||||||
|
@ -1029,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,12 +66,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
val entry = SourceEntry(fields).fillPreCatalogMetadata(context, safe = false)
|
val entry = SourceEntry(fields).fillPreCatalogMetadata(context)
|
||||||
if (allowUnsized || entry.isSized || entry.isSvg || entry.isVideo) {
|
if (allowUnsized || entry.isSized || entry.isSvg || entry.isVideo) {
|
||||||
callback.onSuccess(entry.toMap())
|
callback.onSuccess(entry.toMap())
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -2,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
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
package deckers.thibault.aves.utils
|
||||||
|
|
||||||
|
inline fun <reified T : Throwable> Exception.anyCauseIs(): Boolean {
|
||||||
|
var cause: Throwable? = this
|
||||||
|
while (cause != null) {
|
||||||
|
if (cause is T) return true
|
||||||
|
cause = cause.cause
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
|
@ -5,5 +5,5 @@ import kotlin.math.pow
|
||||||
|
|
||||||
object MathUtils {
|
object MathUtils {
|
||||||
fun highestPowerOf2(x: Int): Int = highestPowerOf2(x.toDouble())
|
fun highestPowerOf2(x: Int): Int = highestPowerOf2(x.toDouble())
|
||||||
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 = "*/*"
|
||||||
|
@ -17,8 +17,8 @@ object MimeTypes {
|
||||||
private const val ICO = "image/x-icon"
|
private const val ICO = "image/x-icon"
|
||||||
const val JPEG = "image/jpeg"
|
const val JPEG = "image/jpeg"
|
||||||
const val PNG = "image/png"
|
const val PNG = "image/png"
|
||||||
private const val PSD_VND = "image/vnd.adobe.photoshop"
|
const val PSD_VND = "image/vnd.adobe.photoshop"
|
||||||
private const val PSD_X = "image/x-photoshop"
|
const val PSD_X = "image/x-photoshop"
|
||||||
const val TIFF = "image/tiff"
|
const val TIFF = "image/tiff"
|
||||||
private const val WBMP = "image/vnd.wap.wbmp"
|
private const val WBMP = "image/vnd.wap.wbmp"
|
||||||
const val WEBP = "image/webp"
|
const val WEBP = "image/webp"
|
||||||
|
@ -84,11 +84,11 @@ object MimeTypes {
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
// as of Flutter v3.16.4, with additional custom handling for SVG
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,9 +99,12 @@ object MimeTypes {
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
// as of `ExifInterface` v1.3.1, `isSupportedMimeType` reports
|
// as of `ExifInterface` v1.4.0-alpha01, `isSupportedMimeType` reports
|
||||||
// no support for TIFF images, but it can actually open them (maybe other formats too)
|
// no support for AVIF/TIFF images, but it can actually open them (maybe other formats too)
|
||||||
fun canReadWithExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict
|
fun canReadWithExifInterface(mimeType: String, strict: Boolean = true): Boolean {
|
||||||
|
if (!strict) return true
|
||||||
|
return ExifInterface.isSupportedMimeType(mimeType) || mimeType == AVIF
|
||||||
|
}
|
||||||
|
|
||||||
// as of latest PixyMeta
|
// as of latest PixyMeta
|
||||||
fun canReadWithPixyMeta(mimeType: String) = when (mimeType) {
|
fun canReadWithPixyMeta(mimeType: String) = when (mimeType) {
|
||||||
|
@ -143,7 +146,7 @@ object MimeTypes {
|
||||||
return if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
return if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
||||||
true
|
true
|
||||||
} else when (mimeType) {
|
} else when (mimeType) {
|
||||||
DNG, DNG_ADOBE, HEIC, HEIF, PNG, WEBP -> true
|
AVIF, DNG, DNG_ADOBE, HEIC, HEIF, PNG, WEBP -> true
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -160,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)
|
||||||
|
|
|
@ -17,6 +17,7 @@ import deckers.thibault.aves.PendingStorageAccessResultHandler
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
|
|
||||||
object PermissionManager {
|
object PermissionManager {
|
||||||
|
@ -86,6 +87,7 @@ object PermissionManager {
|
||||||
fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> {
|
fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> {
|
||||||
val concreteDirPaths = dirPaths.filter { it != StorageUtils.TRASH_PATH_PLACEHOLDER }
|
val concreteDirPaths = dirPaths.filter { it != StorageUtils.TRASH_PATH_PLACEHOLDER }
|
||||||
val accessibleDirs = getAccessibleDirs(context)
|
val accessibleDirs = getAccessibleDirs(context)
|
||||||
|
val restrictedPrimaryDirectoriesLower = getRestrictedPrimaryDirectories().map { it.lowercase(Locale.ROOT) }
|
||||||
|
|
||||||
// find set of inaccessible directories for each volume
|
// find set of inaccessible directories for each volume
|
||||||
val dirsPerVolume = HashMap<String, MutableSet<String>>()
|
val dirsPerVolume = HashMap<String, MutableSet<String>>()
|
||||||
|
@ -101,7 +103,7 @@ object PermissionManager {
|
||||||
if (relativeDir != null) {
|
if (relativeDir != null) {
|
||||||
val dirSegments = relativeDir.split(File.separator).takeWhile { it.isNotEmpty() }
|
val dirSegments = relativeDir.split(File.separator).takeWhile { it.isNotEmpty() }
|
||||||
val primaryDir = dirSegments.firstOrNull()
|
val primaryDir = dirSegments.firstOrNull()
|
||||||
if (getRestrictedPrimaryDirectories().contains(primaryDir) && dirSegments.size > 1) {
|
if (dirSegments.size > 1 && restrictedPrimaryDirectoriesLower.contains(primaryDir?.lowercase(Locale.ROOT))) {
|
||||||
// request secondary directory (if any) for restricted primary directory
|
// request secondary directory (if any) for restricted primary directory
|
||||||
val dir = dirSegments.take(2).joinToString(File.separator)
|
val dir = dirSegments.take(2).joinToString(File.separator)
|
||||||
// only register directories that exist on storage, so they can be selected for access grant
|
// only register directories that exist on storage, so they can be selected for access grant
|
||||||
|
@ -140,10 +142,11 @@ object PermissionManager {
|
||||||
|
|
||||||
fun canInsertByMediaStore(directories: List<FieldMap>): Boolean {
|
fun canInsertByMediaStore(directories: List<FieldMap>): Boolean {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val insertionDirsLower = MEDIA_STORE_INSERTION_PRIMARY_DIRS.map { it.lowercase(Locale.ROOT) }
|
||||||
directories.all {
|
directories.all {
|
||||||
val relativeDir = it["relativeDir"] as String
|
val relativeDir = it["relativeDir"] as String
|
||||||
val segments = relativeDir.split(File.separator)
|
val segments = relativeDir.split(File.separator)
|
||||||
segments.isNotEmpty() && MEDIA_STORE_INSERTION_PRIMARY_DIRS.contains(segments.first())
|
segments.isNotEmpty() && insertionDirsLower.contains(segments.first().lowercase(Locale.ROOT))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -120,10 +122,6 @@ object StorageUtils {
|
||||||
return getVolumePaths(context).firstOrNull { anyPath.startsWith(it) }
|
return getVolumePaths(context).firstOrNull { anyPath.startsWith(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDownloadDirPath(context: Context, anyPath: String): String? {
|
|
||||||
return getVolumePath(context, anyPath)?.let { volumePath -> ensureTrailingSeparator(File(volumePath, Environment.DIRECTORY_DOWNLOADS).path) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPathStepIterator(context: Context, anyPath: String, root: String?): Iterator<String?>? {
|
private fun getPathStepIterator(context: Context, anyPath: String, root: String?): Iterator<String?>? {
|
||||||
val rootLength = (root ?: getVolumePath(context, anyPath))?.length ?: return null
|
val rootLength = (root ?: getVolumePath(context, anyPath))?.length ?: return null
|
||||||
|
|
||||||
|
@ -232,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 {
|
||||||
|
@ -503,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
|
||||||
}
|
}
|
||||||
|
@ -640,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)
|
||||||
|
@ -716,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)
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:tint="@color/ic_shortcut_foreground"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<group
|
|
||||||
android:scaleX="1.7226"
|
|
||||||
android:scaleY="1.7226"
|
|
||||||
android:translateX="33.3288"
|
|
||||||
android:translateY="33.3288">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M12,2C6.48,2 2,6.48 2,12c0,5.52 4.48,10 10,10s10,-4.48 10,-10C22,6.48 17.52,2 12,2zM19.46,9.12l-2.78,1.15c-0.51,-1.36 -1.58,-2.44 -2.95,-2.94l1.15,-2.78C16.98,5.35 18.65,7.02 19.46,9.12zM12,15c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3s3,1.34 3,3S13.66,15 12,15zM9.13,4.54l1.17,2.78c-1.38,0.5 -2.47,1.59 -2.98,2.97L4.54,9.13C5.35,7.02 7.02,5.35 9.13,4.54zM4.54,14.87l2.78,-1.15c0.51,1.38 1.59,2.46 2.97,2.96l-1.17,2.78C7.02,18.65 5.35,16.98 4.54,14.87zM14.88,19.46l-1.15,-2.78c1.37,-0.51 2.45,-1.59 2.95,-2.97l2.78,1.17C18.65,16.98 16.98,18.65 14.88,19.46z" />
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
16
android/app/src/main/res/drawable/ic_shortcut_map.xml
Normal file
16
android/app/src/main/res/drawable/ic_shortcut_map.xml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="48dp"
|
||||||
|
android:height="48dp"
|
||||||
|
android:viewportWidth="48"
|
||||||
|
android:viewportHeight="48">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/ic_shortcut_background"
|
||||||
|
android:pathData="M0,24 A1,1 0 1,1 48,24 A1,1 0 1,1 0,24" />
|
||||||
|
<group
|
||||||
|
android:translateX="12"
|
||||||
|
android:translateY="12">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/ic_shortcut_foreground"
|
||||||
|
android:pathData="M20.5,3l-0.16,0.03L15,5.1 9,3 3.36,4.9c-0.21,0.07 -0.36,0.25 -0.36,0.48L3,20.5c0,0.28 0.22,0.5 0.5,0.5l0.16,-0.03L9,18.9l6,2.1 5.64,-1.9c0.21,-0.07 0.36,-0.25 0.36,-0.48L21,3.5c0,-0.28 -0.22,-0.5 -0.5,-0.5zM10,5.47l4,1.4v11.66l-4,-1.4L10,5.47zM5,6.46l3,-1.01v11.7l-3,1.16L5,6.46zM19,17.54l-3,1.01L16,6.86l3,-1.16v11.84z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
|
@ -0,0 +1,16 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:tint="@color/ic_shortcut_foreground"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<group
|
||||||
|
android:scaleX="1.7226"
|
||||||
|
android:scaleY="1.7226"
|
||||||
|
android:translateX="33.3288"
|
||||||
|
android:translateY="33.3288">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20.5,3l-0.16,0.03L15,5.1 9,3 3.36,4.9c-0.21,0.07 -0.36,0.25 -0.36,0.48L3,20.5c0,0.28 0.22,0.5 0.5,0.5l0.16,-0.03L9,18.9l6,2.1 5.64,-1.9c0.21,-0.07 0.36,-0.25 0.36,-0.48L21,3.5c0,-0.28 -0.22,-0.5 -0.5,-0.5zM10,5.47l4,1.4v11.66l-4,-1.4L10,5.47zM5,6.46l3,-1.01v11.7l-3,1.16L5,6.46zM19,17.54l-3,1.01L16,6.86l3,-1.16v11.84z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
|
@ -1,16 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="48dp"
|
|
||||||
android:height="48dp"
|
|
||||||
android:viewportWidth="48"
|
|
||||||
android:viewportHeight="48">
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/ic_shortcut_background"
|
|
||||||
android:pathData="M0,24 A1,1 0 1,1 48,24 A1,1 0 1,1 0,24" />
|
|
||||||
<group
|
|
||||||
android:translateX="12"
|
|
||||||
android:translateY="12">
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/ic_shortcut_foreground"
|
|
||||||
android:pathData="M12,2C6.48,2 2,6.48 2,12c0,5.52 4.48,10 10,10s10,-4.48 10,-10C22,6.48 17.52,2 12,2zM19.46,9.12l-2.78,1.15c-0.51,-1.36 -1.58,-2.44 -2.95,-2.94l1.15,-2.78C16.98,5.35 18.65,7.02 19.46,9.12zM12,15c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3s3,1.34 3,3S13.66,15 12,15zM9.13,4.54l1.17,2.78c-1.38,0.5 -2.47,1.59 -2.98,2.97L4.54,9.13C5.35,7.02 7.02,5.35 9.13,4.54zM4.54,14.87l2.78,-1.15c0.51,1.38 1.59,2.46 2.97,2.96l-1.17,2.78C7.02,18.65 5.35,16.98 4.54,14.87zM14.88,19.46l-1.15,-2.78c1.37,-0.51 2.45,-1.59 2.95,-2.97l2.78,1.17C18.65,16.98 16.98,18.65 14.88,19.46z" />
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_shortcut_background" />
|
<background android:drawable="@color/ic_shortcut_background" />
|
||||||
<foreground android:drawable="@drawable/ic_shortcut_safe_mode_foreground" />
|
<foreground android:drawable="@drawable/ic_shortcut_map_foreground" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
|
@ -7,6 +7,6 @@
|
||||||
<string name="analysis_channel_name">فحص الوسائط</string>
|
<string name="analysis_channel_name">فحص الوسائط</string>
|
||||||
<string name="analysis_notification_default_title">يتم فحص الوسائط</string>
|
<string name="analysis_notification_default_title">يتم فحص الوسائط</string>
|
||||||
<string name="analysis_notification_action_stop">إيقاف</string>
|
<string name="analysis_notification_action_stop">إيقاف</string>
|
||||||
<string name="safe_mode_shortcut_short_label">الوضع الآمن</string>
|
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
|
<string name="map_shortcut_short_label">خريطة</string>
|
||||||
</resources>
|
</resources>
|
12
android/app/src/main/res/values-az/strings.xml
Normal file
12
android/app/src/main/res/values-az/strings.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Aves</string>
|
||||||
|
<string name="app_widget_label">Şəkil Çərçivəsi</string>
|
||||||
|
<string name="wallpaper">Divar kağızı</string>
|
||||||
|
<string name="map_shortcut_short_label">Xəritə</string>
|
||||||
|
<string name="search_shortcut_short_label">Axtarış</string>
|
||||||
|
<string name="videos_shortcut_short_label">Videolar</string>
|
||||||
|
<string name="analysis_channel_name">Medianı yoxla</string>
|
||||||
|
<string name="analysis_notification_default_title">Media yoxlanılır</string>
|
||||||
|
<string name="analysis_notification_action_stop">Dayandır</string>
|
||||||
|
</resources>
|
12
android/app/src/main/res/values-b+zxx+Shaw/strings.xml
Normal file
12
android/app/src/main/res/values-b+zxx+Shaw/strings.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">𐑱𐑝𐑰𐑟</string>
|
||||||
|
<string name="app_widget_label">𐑓𐑴𐑑𐑴 𐑓𐑮𐑱𐑥</string>
|
||||||
|
<string name="wallpaper">𐑢𐑷𐑤𐑐𐑱𐑐𐑼</string>
|
||||||
|
<string name="map_shortcut_short_label">𐑥𐑨𐑐</string>
|
||||||
|
<string name="search_shortcut_short_label">𐑕𐑻𐑗</string>
|
||||||
|
<string name="videos_shortcut_short_label">𐑝𐑦𐑛𐑦𐑴𐑟</string>
|
||||||
|
<string name="analysis_channel_name">𐑥𐑰𐑛𐑾 𐑕𐑒𐑨𐑯</string>
|
||||||
|
<string name="analysis_notification_default_title">𐑕𐑒𐑨𐑯𐑦𐑙 𐑥𐑰𐑛𐑾</string>
|
||||||
|
<string name="analysis_notification_action_stop">𐑕𐑑𐑪𐑐</string>
|
||||||
|
</resources>
|
|
@ -6,7 +6,6 @@
|
||||||
<string name="analysis_notification_default_title">Сканаванне носьбітаў</string>
|
<string name="analysis_notification_default_title">Сканаванне носьбітаў</string>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="app_widget_label">Фотарамка</string>
|
<string name="app_widget_label">Фотарамка</string>
|
||||||
<string name="safe_mode_shortcut_short_label">Бяспечны рэжым</string>
|
|
||||||
<string name="search_shortcut_short_label">Пошук</string>
|
<string name="search_shortcut_short_label">Пошук</string>
|
||||||
<string name="analysis_notification_action_stop">Стоп</string>
|
<string name="analysis_notification_action_stop">Стоп</string>
|
||||||
</resources>
|
</resources>
|
12
android/app/src/main/res/values-bg/strings.xml
Normal file
12
android/app/src/main/res/values-bg/strings.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Aves</string>
|
||||||
|
<string name="app_widget_label">Фоторамка</string>
|
||||||
|
<string name="wallpaper">Тапет</string>
|
||||||
|
<string name="map_shortcut_short_label">Карта</string>
|
||||||
|
<string name="search_shortcut_short_label">Търсене</string>
|
||||||
|
<string name="analysis_channel_name">Сканиране медия</string>
|
||||||
|
<string name="analysis_notification_default_title">Сканиране медия</string>
|
||||||
|
<string name="analysis_notification_action_stop">Стоп</string>
|
||||||
|
<string name="videos_shortcut_short_label">Видео</string>
|
||||||
|
</resources>
|
|
@ -8,5 +8,4 @@
|
||||||
<string name="analysis_notification_action_stop">থামান</string>
|
<string name="analysis_notification_action_stop">থামান</string>
|
||||||
<string name="app_name">আভেস</string>
|
<string name="app_name">আভেস</string>
|
||||||
<string name="app_widget_label">ছবির ফ্রেম</string>
|
<string name="app_widget_label">ছবির ফ্রেম</string>
|
||||||
<string name="safe_mode_shortcut_short_label">নিরাপদ মোড</string>
|
|
||||||
</resources>
|
</resources>
|
|
@ -3,10 +3,10 @@
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="app_widget_label">Marc de foto</string>
|
<string name="app_widget_label">Marc de foto</string>
|
||||||
<string name="wallpaper">Fons de pantalla</string>
|
<string name="wallpaper">Fons de pantalla</string>
|
||||||
<string name="safe_mode_shortcut_short_label">Mode segur</string>
|
|
||||||
<string name="search_shortcut_short_label">Buscar</string>
|
<string name="search_shortcut_short_label">Buscar</string>
|
||||||
<string name="videos_shortcut_short_label">Vídeos</string>
|
<string name="videos_shortcut_short_label">Vídeos</string>
|
||||||
<string name="analysis_channel_name">Exploració de mitjans</string>
|
<string name="analysis_channel_name">Exploració de mitjans</string>
|
||||||
<string name="analysis_notification_default_title">Explorant mitjans</string>
|
<string name="analysis_notification_default_title">Explorant mitjans</string>
|
||||||
<string name="analysis_notification_action_stop">Atura</string>
|
<string name="analysis_notification_action_stop">Atura</string>
|
||||||
</resources>
|
<string name="map_shortcut_short_label">Mapa</string>
|
||||||
|
</resources>
|
||||||
|
|
|
@ -8,5 +8,4 @@
|
||||||
<string name="analysis_channel_name">گەڕان لە پەڕگە</string>
|
<string name="analysis_channel_name">گەڕان لە پەڕگە</string>
|
||||||
<string name="analysis_notification_default_title">لە پەڕگەکان دەگەڕێت</string>
|
<string name="analysis_notification_default_title">لە پەڕگەکان دەگەڕێت</string>
|
||||||
<string name="analysis_notification_action_stop">وەستاندن</string>
|
<string name="analysis_notification_action_stop">وەستاندن</string>
|
||||||
<string name="safe_mode_shortcut_short_label">دۆخی پارێزراو</string>
|
|
||||||
</resources>
|
</resources>
|
|
@ -8,5 +8,5 @@
|
||||||
<string name="analysis_notification_default_title">Prohledávání médií</string>
|
<string name="analysis_notification_default_title">Prohledávání médií</string>
|
||||||
<string name="analysis_notification_action_stop">Zastavit</string>
|
<string name="analysis_notification_action_stop">Zastavit</string>
|
||||||
<string name="app_widget_label">Fotorámeček</string>
|
<string name="app_widget_label">Fotorámeček</string>
|
||||||
<string name="safe_mode_shortcut_short_label">Bezpečný režim</string>
|
<string name="map_shortcut_short_label">Mapa</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -3,10 +3,10 @@
|
||||||
<string name="app_widget_label">Fotoramme</string>
|
<string name="app_widget_label">Fotoramme</string>
|
||||||
<string name="wallpaper">Baggrund</string>
|
<string name="wallpaper">Baggrund</string>
|
||||||
<string name="videos_shortcut_short_label">Videoer</string>
|
<string name="videos_shortcut_short_label">Videoer</string>
|
||||||
<string name="analysis_channel_name">Mediascanning</string>
|
<string name="analysis_channel_name">Mediescanning</string>
|
||||||
<string name="analysis_notification_default_title">Scanner medier</string>
|
<string name="analysis_notification_default_title">Scanner medier</string>
|
||||||
<string name="analysis_notification_action_stop">Stop</string>
|
<string name="analysis_notification_action_stop">Stop</string>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="safe_mode_shortcut_short_label">Sikker tilstand</string>
|
|
||||||
<string name="search_shortcut_short_label">Søg</string>
|
<string name="search_shortcut_short_label">Søg</string>
|
||||||
|
<string name="map_shortcut_short_label">Kort</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,5 +8,5 @@
|
||||||
<string name="analysis_channel_name">Analyse von Medien</string>
|
<string name="analysis_channel_name">Analyse von Medien</string>
|
||||||
<string name="analysis_notification_default_title">Medien scannen</string>
|
<string name="analysis_notification_default_title">Medien scannen</string>
|
||||||
<string name="analysis_notification_action_stop">Abbrechen</string>
|
<string name="analysis_notification_action_stop">Abbrechen</string>
|
||||||
<string name="safe_mode_shortcut_short_label">Sicherer Modus</string>
|
<string name="map_shortcut_short_label">Karte</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,5 +8,5 @@
|
||||||
<string name="analysis_channel_name">Σάρωση πολυμέσων</string>
|
<string name="analysis_channel_name">Σάρωση πολυμέσων</string>
|
||||||
<string name="analysis_notification_default_title">Σάρωση στοιχείων</string>
|
<string name="analysis_notification_default_title">Σάρωση στοιχείων</string>
|
||||||
<string name="analysis_notification_action_stop">Διακοπή</string>
|
<string name="analysis_notification_action_stop">Διακοπή</string>
|
||||||
<string name="safe_mode_shortcut_short_label">Ασφαλής κατάσταση λειτουργίας</string>
|
<string name="map_shortcut_short_label">Χάρτης</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -8,5 +8,5 @@
|
||||||
<string name="analysis_channel_name">Explorar medios</string>
|
<string name="analysis_channel_name">Explorar medios</string>
|
||||||
<string name="analysis_notification_default_title">Explorando medios</string>
|
<string name="analysis_notification_default_title">Explorando medios</string>
|
||||||
<string name="analysis_notification_action_stop">Anular</string>
|
<string name="analysis_notification_action_stop">Anular</string>
|
||||||
<string name="safe_mode_shortcut_short_label">Modo seguro</string>
|
<string name="map_shortcut_short_label">Mapa</string>
|
||||||
</resources>
|
</resources>
|
12
android/app/src/main/res/values-et/strings.xml
Normal file
12
android/app/src/main/res/values-et/strings.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Aves</string>
|
||||||
|
<string name="app_widget_label">Fotoraam</string>
|
||||||
|
<string name="wallpaper">Taustapilt</string>
|
||||||
|
<string name="map_shortcut_short_label">Kaart</string>
|
||||||
|
<string name="search_shortcut_short_label">Otsi</string>
|
||||||
|
<string name="videos_shortcut_short_label">Videod</string>
|
||||||
|
<string name="analysis_channel_name">Meedia tuvastamine</string>
|
||||||
|
<string name="analysis_notification_default_title">Tuvastame meediat</string>
|
||||||
|
<string name="analysis_notification_action_stop">Peata</string>
|
||||||
|
</resources>
|
|
@ -8,5 +8,5 @@
|
||||||
<string name="analysis_notification_action_stop">Gelditu</string>
|
<string name="analysis_notification_action_stop">Gelditu</string>
|
||||||
<string name="analysis_notification_default_title">Media eskaneatzen</string>
|
<string name="analysis_notification_default_title">Media eskaneatzen</string>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="safe_mode_shortcut_short_label">Modu segurua</string>
|
<string name="map_shortcut_short_label">Mapa</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,5 +8,5 @@
|
||||||
<string name="analysis_notification_action_stop">توقف کردن</string>
|
<string name="analysis_notification_action_stop">توقف کردن</string>
|
||||||
<string name="app_widget_label">قاب عکس</string>
|
<string name="app_widget_label">قاب عکس</string>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="safe_mode_shortcut_short_label">حالت امن</string>
|
<string name="map_shortcut_short_label">نقشه</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -4,9 +4,9 @@
|
||||||
<string name="app_widget_label">Valokuvakehys</string>
|
<string name="app_widget_label">Valokuvakehys</string>
|
||||||
<string name="analysis_notification_default_title">Mediaan skannataan</string>
|
<string name="analysis_notification_default_title">Mediaan skannataan</string>
|
||||||
<string name="videos_shortcut_short_label">Videot</string>
|
<string name="videos_shortcut_short_label">Videot</string>
|
||||||
<string name="safe_mode_shortcut_short_label">Turva tila</string>
|
|
||||||
<string name="wallpaper">Taustakuva</string>
|
<string name="wallpaper">Taustakuva</string>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="analysis_channel_name">Median skannaus</string>
|
<string name="analysis_channel_name">Median skannaus</string>
|
||||||
<string name="search_shortcut_short_label">Hae</string>
|
<string name="search_shortcut_short_label">Hae</string>
|
||||||
|
<string name="map_shortcut_short_label">Kartta</string>
|
||||||
</resources>
|
</resources>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue