Compare commits
526 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bcbc5f42b | ||
|
|
290b7fb158 | ||
|
|
9f103dcffe | ||
|
|
68ec919ae4 | ||
|
|
b0e194898e | ||
|
|
03b88c5b4b | ||
|
|
a5d4cbfaec | ||
|
|
19450f66a0 | ||
|
|
5b36fd9257 | ||
|
|
a3158d320b | ||
|
|
38494c9fbc | ||
|
|
114158cf73 | ||
|
|
6d108dd7ff | ||
|
|
f36b7f1dbe | ||
|
|
0a22ebd8e9 | ||
|
|
3682eeaf94 | ||
|
|
7df2ae4ba7 | ||
|
|
c9519ec681 | ||
|
|
b146ed684d | ||
|
|
d2787c36d7 | ||
|
|
3ff663114a | ||
|
|
573e62f310 | ||
|
|
f9af670b82 | ||
|
|
bf461475c6 | ||
|
|
bdea6e0cc1 | ||
|
|
57f0ec4e5d | ||
|
|
d663092363 | ||
|
|
edf6188e36 | ||
|
|
f3f3395e68 | ||
|
|
ac9dc347e3 | ||
|
|
8721d85946 | ||
|
|
a0bd1a8738 | ||
|
|
35fdf3e3b0 | ||
|
|
aced8293f1 | ||
|
|
3f516faad8 | ||
|
|
824f7b9602 | ||
|
|
95aeeaa16f | ||
|
|
63f08f0230 | ||
|
|
3b241fe857 | ||
|
|
75bc104f43 | ||
|
|
30afd56324 | ||
|
|
5ee1bb11a0 | ||
|
|
c1de45abce | ||
|
|
8805033c8d | ||
|
|
0ed59bb8a9 | ||
|
|
8163f2fd28 | ||
|
|
521a65c9d2 | ||
|
|
eb98424668 | ||
|
|
961c731743 | ||
|
|
5188769fb6 | ||
|
|
8f27d9e30f | ||
|
|
b58566999e | ||
|
|
117d6dcd2b | ||
|
|
2608796929 | ||
|
|
792f5b5a7f | ||
|
|
a77b1db749 | ||
|
|
9d984d92af | ||
|
|
e303f25991 | ||
|
|
85973d2305 | ||
|
|
13f8d7b747 | ||
|
|
e198860edb | ||
|
|
fc8355467b | ||
|
|
67abc15442 | ||
|
|
e94cddb86a | ||
|
|
700f7a33a5 | ||
|
|
41e952144d | ||
|
|
910ed65937 | ||
|
|
e06701a2fb | ||
|
|
62dce26c73 | ||
|
|
ac0cff62d4 | ||
|
|
655c060814 | ||
|
|
36d27895e7 | ||
|
|
803481f74c | ||
|
|
b3ca1686e3 | ||
|
|
8f220eb0cb | ||
|
|
51d5f42e8b | ||
|
|
8d8c5ace61 | ||
|
|
4bb6b8ccc9 | ||
|
|
6bebd36e83 | ||
|
|
edc7053e50 | ||
|
|
55e6ef5f78 | ||
|
|
9781d7a5dc | ||
|
|
b83cf87cd8 | ||
|
|
430864512d | ||
|
|
16eeef1878 | ||
|
|
994d4b589b | ||
|
|
43adba6f13 | ||
|
|
e4fbd21731 | ||
|
|
8be64745fc | ||
|
|
b66f376729 | ||
|
|
cc40416e1e | ||
|
|
5073352366 | ||
|
|
9ae12a2c4c | ||
|
|
843b8412a9 | ||
|
|
4f67578371 | ||
|
|
37f2227093 | ||
|
|
1833c0bde5 | ||
|
|
aa3aeca3f2 | ||
|
|
152d4e248f | ||
|
|
7461c8d201 | ||
|
|
0902fdf981 | ||
|
|
0fd2cf4fd7 | ||
|
|
679558106f | ||
|
|
e498efc493 | ||
|
|
74bbc71741 | ||
|
|
502b4890e3 | ||
|
|
dfb60461e4 | ||
|
|
bd6bc418e6 | ||
|
|
a284143ce1 | ||
|
|
1f1c782772 | ||
|
|
5c0f5fe333 | ||
|
|
748e023fde | ||
|
|
30104bacd2 | ||
|
|
f33d1a1bfa | ||
|
|
3c08331441 | ||
|
|
3eaa38247b | ||
|
|
304ce643f9 | ||
|
|
b4ad994f95 | ||
|
|
03c5cfa791 | ||
|
|
e8056072b8 | ||
|
|
d134639a5f | ||
|
|
b4745d76b8 | ||
|
|
c5fd674020 | ||
|
|
9b821dd7cb | ||
|
|
1b441f6aea | ||
|
|
213902c854 | ||
|
|
2054922586 | ||
|
|
a17b7355f5 | ||
|
|
066a1e1f3a | ||
|
|
b10cbfbd63 | ||
|
|
fcd72bb8bd | ||
|
|
37cd99731c | ||
|
|
ed97773f24 | ||
|
|
0424ba3e87 | ||
|
|
9539c4e7bf | ||
|
|
248b378f01 | ||
|
|
1c40575665 | ||
|
|
ac67c648be | ||
|
|
42cc026acc | ||
|
|
23a74edfad | ||
|
|
5da1804f17 | ||
|
|
75f0c35017 | ||
|
|
0e6b02d260 | ||
|
|
d5a0ce55f0 | ||
|
|
09fc6fe8ef | ||
|
|
ff30be879a | ||
|
|
309fe4d831 | ||
|
|
dff0c817a7 | ||
|
|
04313981d4 | ||
|
|
810cb4d13a | ||
|
|
969e32e744 | ||
|
|
980909df9b | ||
|
|
e6753088a4 | ||
|
|
cbdb6cb63a | ||
|
|
3cdf1a899e | ||
|
|
c796be5de5 | ||
|
|
db301cb0c3 | ||
|
|
f00421ef23 | ||
|
|
b324654967 | ||
|
|
aa10ada3ee | ||
|
|
10c97987fb | ||
|
|
b532615bbd | ||
|
|
3066f41af3 | ||
|
|
0c401c6741 | ||
|
|
1a21d1c937 | ||
|
|
525b49a5c9 | ||
|
|
34c074bf7b | ||
|
|
b4dc961cdc | ||
|
|
93374d2cfe | ||
|
|
4009b10549 | ||
|
|
db1864205f | ||
|
|
bf39ccabbd | ||
|
|
0e8e7767ee | ||
|
|
5b6c86e34f | ||
|
|
6bbaca3686 | ||
|
|
35eae90df1 | ||
|
|
488d43e076 | ||
|
|
7c5e93c171 | ||
|
|
a20ef783e1 | ||
|
|
8ae0dce0ed | ||
|
|
44aea606b7 | ||
|
|
a05dc8c661 | ||
|
|
1f80e36017 | ||
|
|
1efca40744 | ||
|
|
86e3131afa | ||
|
|
4910b4a4b0 | ||
|
|
9c7320c0a0 | ||
|
|
02c17c3b75 | ||
|
|
49a47f4b4f | ||
|
|
68280f4a62 | ||
|
|
0e3669b247 | ||
|
|
4c9aa29d46 | ||
|
|
66fbf10f2d | ||
|
|
15ad806eb8 | ||
|
|
b7f80b9c82 | ||
|
|
9b511d2f8f | ||
|
|
6ebce2deb3 | ||
|
|
95dade13f4 | ||
|
|
ba4449d003 | ||
|
|
7632fe5e86 | ||
|
|
2c56bcacee | ||
|
|
c8202db3c6 | ||
|
|
223d689b0c | ||
|
|
4f0e7d9696 | ||
|
|
f4ce911de9 | ||
|
|
d0ad7effa0 | ||
|
|
a032beecbf | ||
|
|
46ec9e48d9 | ||
|
|
26bcef1cc0 | ||
|
|
bfb2f44f8f | ||
|
|
28b19b6774 | ||
|
|
8d72f4a3aa | ||
|
|
9c62e0399d | ||
|
|
65ea09854e | ||
|
|
9f9a4c81b3 | ||
|
|
d567b30f4b | ||
|
|
6d7c4ce0ab | ||
|
|
e062b8f9e9 | ||
|
|
08403b7a4e | ||
|
|
c6ed5d35e7 | ||
|
|
dba3460b60 | ||
|
|
f07f624fcf | ||
|
|
48ff2f328f | ||
|
|
9ae2423a40 | ||
|
|
2bc3c78c75 | ||
|
|
18e9fe75fb | ||
|
|
880a741a44 | ||
|
|
2c6ddcc64b | ||
|
|
8f2e757b77 | ||
|
|
ff177955b3 | ||
|
|
8bb8066a98 | ||
|
|
2747ddbf65 | ||
|
|
b939e9424d | ||
|
|
fb9dea5d1e | ||
|
|
da4d5d711b | ||
|
|
331cbec5f1 | ||
|
|
7f02284285 | ||
|
|
ac2c3a6d97 | ||
|
|
c3bc80fec6 | ||
|
|
09779a0710 | ||
|
|
e82c6ef866 | ||
|
|
861ae9be64 | ||
|
|
96108bc1ec | ||
|
|
016f217db0 | ||
|
|
0688294f18 | ||
|
|
9ad008255d | ||
|
|
4c5a862dd6 | ||
|
|
b165a2308f | ||
|
|
8757b08cd2 | ||
|
|
3800543fba | ||
|
|
02ef60c818 | ||
|
|
88f3b30266 | ||
|
|
9203dc0112 | ||
|
|
4c683bec68 | ||
|
|
0cfd1eb453 | ||
|
|
19744dab37 | ||
|
|
12d58e5aa7 | ||
|
|
e46dd37a26 | ||
|
|
49c3ebc36b | ||
|
|
11e9bc2235 | ||
|
|
3029b3bf0e | ||
|
|
9a6c6f67ce | ||
|
|
a6ed0baef2 | ||
|
|
d3b43d80da | ||
|
|
46d4316d49 | ||
|
|
ade2864351 | ||
|
|
365fc56e9d | ||
|
|
54a5cd21ea | ||
|
|
38c0399b09 | ||
|
|
2b67858453 | ||
|
|
87fdbdbb6e | ||
|
|
bab77a4116 | ||
|
|
d20756ab96 | ||
|
|
dc75a753c3 | ||
|
|
4712d47903 | ||
|
|
c5561801e1 | ||
|
|
5c259fa07a | ||
|
|
60e8b18702 | ||
|
|
a8317824a9 | ||
|
|
0c3c78cc72 | ||
|
|
cfd4a8faac | ||
|
|
7f3fb0db0d | ||
|
|
385d3f0d1b | ||
|
|
8fa6bd12a2 | ||
|
|
57c2004e46 | ||
|
|
c6b069bbfb | ||
|
|
c18bffd08f | ||
|
|
47ec181439 | ||
|
|
90ad40b1b7 | ||
|
|
4d3f20cf98 | ||
|
|
86df9d52bc | ||
|
|
1bd025e070 | ||
|
|
86ee239c71 | ||
|
|
27d0c01e1f | ||
|
|
7a9507be01 | ||
|
|
1490035893 | ||
|
|
a6afcb0ed0 | ||
|
|
ea7e8584cb | ||
|
|
608c6e6a1d | ||
|
|
bb2c91145f | ||
|
|
db074df0f7 | ||
|
|
f7c45df9a6 | ||
|
|
44e3d16cd6 | ||
|
|
a973cdfe0b | ||
|
|
fca42c79a8 | ||
|
|
f236775599 | ||
|
|
360decd37c | ||
|
|
998433479b | ||
|
|
c7e75aacf0 | ||
|
|
690338273a | ||
|
|
4207ea494d | ||
|
|
265473a15a | ||
|
|
b907d36770 | ||
|
|
fee280341a | ||
|
|
0f1ef70752 | ||
|
|
0f8c68b22e | ||
|
|
701017d2ca | ||
|
|
be6903ca12 | ||
|
|
1521bc1223 | ||
|
|
7ed66b827f | ||
|
|
df3a478ef3 | ||
|
|
974ddf69d5 | ||
|
|
56a91268de | ||
|
|
3dda2f9a1c | ||
|
|
ed20456f9f | ||
|
|
281d4a0023 | ||
|
|
2170403662 | ||
|
|
b1c1e96135 | ||
|
|
a8de1429c1 | ||
|
|
3ba6cb81ae | ||
|
|
acc85da80f | ||
|
|
b53de8624d | ||
|
|
6e2eeb29cc | ||
|
|
62eb28ac01 | ||
|
|
fd298529bf | ||
|
|
297ce506b1 | ||
|
|
18c6954be3 | ||
|
|
cea3fb1e65 | ||
|
|
7f274fd238 | ||
|
|
439a8e93ec | ||
|
|
83801feee9 | ||
|
|
8a6860c96e | ||
|
|
5c959f2987 | ||
|
|
4e4397287a | ||
|
|
fe02abc9e8 | ||
|
|
59347ab317 | ||
|
|
f408a91176 | ||
|
|
6f6956ce27 | ||
|
|
4ecad8eccc | ||
|
|
486fbe46a0 | ||
|
|
1ddb636dd0 | ||
|
|
081c890b4e | ||
|
|
86d528ba13 | ||
|
|
6bda3cb75a | ||
|
|
12d8949c9e | ||
|
|
ffc7c2aa67 | ||
|
|
5ec67488eb | ||
|
|
be64703d3c | ||
|
|
705925a050 | ||
|
|
29665be34d | ||
|
|
1edf986acf | ||
|
|
37be8ccf7f | ||
|
|
ead68b5201 | ||
|
|
4409664698 | ||
|
|
fc6bc7965c | ||
|
|
f70eccb1da | ||
|
|
861994e804 | ||
|
|
2b8facfb97 | ||
|
|
9583897ada | ||
|
|
7704c96955 | ||
|
|
c96d609803 | ||
|
|
aa0e5000ab | ||
|
|
7ca4418a50 | ||
|
|
fdd9b02388 | ||
|
|
ece127e982 | ||
|
|
5488e14f32 | ||
|
|
3558d826fb | ||
|
|
68c94d1d8b | ||
|
|
1a4ae5dfc6 | ||
|
|
1a95afe266 | ||
|
|
6579db3cc8 | ||
|
|
ceac01533a | ||
|
|
216914882c | ||
|
|
735dbab695 | ||
|
|
dbaab152ef | ||
|
|
9da1b30984 | ||
|
|
9415ab4ef9 | ||
|
|
647294daf2 | ||
|
|
6ebc386474 | ||
|
|
3e657bdc09 | ||
|
|
0b0adb76a1 | ||
|
|
17b3e010aa | ||
|
|
20003acd73 | ||
|
|
2ab7672092 | ||
|
|
c317abe64b | ||
|
|
bc33ce1ebc | ||
|
|
684c5cf38b | ||
|
|
c34e15f0a1 | ||
|
|
bad004f892 | ||
|
|
828d3de020 | ||
|
|
132b3b9be1 | ||
|
|
388bc6fda5 | ||
|
|
a93edc184d | ||
|
|
08672d10ac | ||
|
|
b563dae3a8 | ||
|
|
917f9672dd | ||
|
|
9ddb19530b | ||
|
|
431e56a9f1 | ||
|
|
71093aac4c | ||
|
|
47c9e8127e | ||
|
|
24b801b346 | ||
|
|
70608c3ed9 | ||
|
|
f185196e85 | ||
|
|
a8766a8bbe | ||
|
|
27a8c93cfe | ||
|
|
a3cd29fda9 | ||
|
|
adda8ab640 | ||
|
|
1538ea5fc8 | ||
|
|
2367a97a54 | ||
|
|
090ec0e4af | ||
|
|
de7f552e5c | ||
|
|
d763f5dca0 | ||
|
|
9f41116241 | ||
|
|
57faada201 | ||
|
|
1edb95f0c5 | ||
|
|
9f363d8900 | ||
|
|
0bf2f1b6e1 | ||
|
|
68c7a38390 | ||
|
|
841c8a7a15 | ||
|
|
6c9688183b | ||
|
|
ccd84c91f6 | ||
|
|
318d6f9b52 | ||
|
|
8f5d612ee0 | ||
|
|
56b2a05596 | ||
|
|
4db0022d6a | ||
|
|
67f37d3188 | ||
|
|
ed81cc7207 | ||
|
|
065845f1be | ||
|
|
902f705e89 | ||
|
|
ec2e0ef773 | ||
|
|
d28c5741d0 | ||
|
|
e6e3f9e8f8 | ||
|
|
90e1dc59bd | ||
|
|
0b1c9b097c | ||
|
|
2b553d1116 | ||
|
|
567eec8bc5 | ||
|
|
293ca5b31d | ||
|
|
0d0f2bd827 | ||
|
|
5bc4610061 | ||
|
|
e6b7c107f2 | ||
|
|
51a9bf2570 | ||
|
|
8385f6f390 | ||
|
|
772e9daf57 | ||
|
|
8adc4405c5 | ||
|
|
349da7aa81 | ||
|
|
01a01d481d | ||
|
|
2f8445fb83 | ||
|
|
b04a5fc150 | ||
|
|
bbe29941df | ||
|
|
2720e445ea | ||
|
|
49ba579a59 | ||
|
|
3198c6cbfd | ||
|
|
b3feee6d9d | ||
|
|
f0f53e6bce | ||
|
|
24486d13f2 | ||
|
|
20bc9461de | ||
|
|
c8e94cc295 | ||
|
|
b2bfb0c237 | ||
|
|
0a003da724 | ||
|
|
b4f2a33016 | ||
|
|
ee7ede2885 | ||
|
|
6abc404eb7 | ||
|
|
61afe01e36 | ||
|
|
c3e60f9988 | ||
|
|
593197cd7e | ||
|
|
ee1592b478 | ||
|
|
dfe435c4f3 | ||
|
|
69e85f8b90 | ||
|
|
c9bde3c487 | ||
|
|
65e9557d9f | ||
|
|
4f249c07e7 | ||
|
|
5fd35b492c | ||
|
|
9bddf95013 | ||
|
|
03444f070f | ||
|
|
2f1a63eb64 | ||
|
|
9d0898b26c | ||
|
|
994aa99797 | ||
|
|
8204a15276 | ||
|
|
4a8bff0b98 | ||
|
|
a4336cd954 | ||
|
|
4f0dbead79 | ||
|
|
c0e7c87ca4 | ||
|
|
b967bf9a26 | ||
|
|
764a265053 | ||
|
|
68c2b2dbfa | ||
|
|
061f1263f4 | ||
|
|
2a27355479 | ||
|
|
ae2a8e8ada | ||
|
|
68dcc2333b | ||
|
|
66fb2e9a62 | ||
|
|
1dbfc64f37 | ||
|
|
98d1f88579 | ||
|
|
bb6fadc182 | ||
|
|
ac1ca71299 | ||
|
|
0d93785581 | ||
|
|
69a9d63e1d | ||
|
|
5dea35343b | ||
|
|
5c768d2121 | ||
|
|
4d5834821a | ||
|
|
ca077c4fee | ||
|
|
85d01f60f1 | ||
|
|
066d73b217 | ||
|
|
ba069d8f8e | ||
|
|
275684c9ce | ||
|
|
49d87a08d2 | ||
|
|
04c500f3d8 | ||
|
|
d05c1e4d08 | ||
|
|
bb63959678 | ||
|
|
842148647f | ||
|
|
19308d840a | ||
|
|
46bd1318cd | ||
|
|
9d1998fe52 | ||
|
|
a714a8230b | ||
|
|
b5432cd0b4 | ||
|
|
5634e94f3e | ||
|
|
c1a71b0db3 |
46
.gitignore
vendored
46
.gitignore
vendored
@@ -1,19 +1,33 @@
|
|||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Log/OS Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio generated files and folders
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.apk
|
||||||
|
output.json
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
*.iml
|
*.iml
|
||||||
.gradle
|
.idea/
|
||||||
/local.properties
|
misc.xml
|
||||||
/.idea/caches
|
deploymentTargetDropDown.xml
|
||||||
/.idea/libraries
|
render.experimental.xml
|
||||||
/.idea/modules.xml
|
|
||||||
/.idea/workspace.xml
|
|
||||||
/.idea/navEditor.xml
|
|
||||||
/.idea/assetWizardSettings.xml
|
|
||||||
.DS_Store
|
|
||||||
/build
|
|
||||||
/captures
|
|
||||||
.externalNativeBuild
|
|
||||||
|
|
||||||
#Github pages
|
# Keystore files
|
||||||
/gh-pages
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
#Private files
|
# Google Services (e.g. APIs or Firebase)
|
||||||
**/google-services.json
|
google-services.json
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|||||||
126
.idea/codeStyles/Project.xml
generated
126
.idea/codeStyles/Project.xml
generated
@@ -1,126 +0,0 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
|
||||||
<code_scheme name="Project" version="173">
|
|
||||||
<option name="RIGHT_MARGIN" value="120" />
|
|
||||||
<AndroidXmlCodeStyleSettings>
|
|
||||||
<option name="ARRANGEMENT_SETTINGS_MIGRATED_TO_191" value="true" />
|
|
||||||
</AndroidXmlCodeStyleSettings>
|
|
||||||
<JetCodeStyleSettings>
|
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
|
||||||
</JetCodeStyleSettings>
|
|
||||||
<codeStyleSettings language="XML">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
|
||||||
</indentOptions>
|
|
||||||
<arrangement>
|
|
||||||
<rules>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>xmlns:android</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>xmlns:.*</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
<order>BY_NAME</order>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*:id</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*:name</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>name</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>style</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
<order>BY_NAME</order>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*</NAME>
|
|
||||||
<XML_ATTRIBUTE />
|
|
||||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
<order>BY_NAME</order>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
</rules>
|
|
||||||
</arrangement>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="kotlin">
|
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
|
||||||
</codeStyleSettings>
|
|
||||||
</code_scheme>
|
|
||||||
</component>
|
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
|
||||||
<state>
|
|
||||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
|
||||||
</state>
|
|
||||||
</component>
|
|
||||||
6
.idea/copyright/Apache.xml
generated
6
.idea/copyright/Apache.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<component name="CopyrightManager">
|
|
||||||
<copyright>
|
|
||||||
<option name="notice" value=" Copyright &#36;today.year tom5079 Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License." />
|
|
||||||
<option name="myName" value="Apache" />
|
|
||||||
</copyright>
|
|
||||||
</component>
|
|
||||||
6
.idea/copyright/GPL.xml
generated
6
.idea/copyright/GPL.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<component name="CopyrightManager">
|
|
||||||
<copyright>
|
|
||||||
<option name="notice" value=" Pupil, Hitomi.la viewer for Android Copyright (C) &#36;today.year tom5079 This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>." />
|
|
||||||
<option name="myName" value="GPL" />
|
|
||||||
</copyright>
|
|
||||||
</component>
|
|
||||||
8
.idea/copyright/profiles_settings.xml
generated
8
.idea/copyright/profiles_settings.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<component name="CopyrightManager">
|
|
||||||
<settings>
|
|
||||||
<module2copyright>
|
|
||||||
<element module="Pupil" copyright="GPL" />
|
|
||||||
<element module="libpupil" copyright="Apache" />
|
|
||||||
</module2copyright>
|
|
||||||
</settings>
|
|
||||||
</component>
|
|
||||||
4
.idea/encodings.xml
generated
4
.idea/encodings.xml
generated
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
|
|
||||||
</project>
|
|
||||||
19
.idea/gradle.xml
generated
19
.idea/gradle.xml
generated
@@ -1,19 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="GradleSettings">
|
|
||||||
<option name="linkedExternalProjectsSettings">
|
|
||||||
<GradleProjectSettings>
|
|
||||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
|
||||||
<option name="modules">
|
|
||||||
<set>
|
|
||||||
<option value="$PROJECT_DIR$" />
|
|
||||||
<option value="$PROJECT_DIR$/app" />
|
|
||||||
<option value="$PROJECT_DIR$/libpupil" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
<option name="resolveModulePerSourceSet" value="false" />
|
|
||||||
</GradleProjectSettings>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
7
.idea/kotlinCodeInsightSettings.xml
generated
7
.idea/kotlinCodeInsightSettings.xml
generated
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="KotlinCodeInsightWorkspaceSettings">
|
|
||||||
<option name="addUnambiguousImportsOnTheFly" value="true" />
|
|
||||||
<option name="optimizeImportsOnTheFly" value="true" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/kotlinc.xml
generated
6
.idea/kotlinc.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Kotlin2JvmCompilerArguments">
|
|
||||||
<option name="jvmTarget" value="1.8" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
9
.idea/misc.xml
generated
9
.idea/misc.xml
generated
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
|
|
||||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectType">
|
|
||||||
<option name="id" value="Android" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
12
.idea/runConfigurations.xml
generated
12
.idea/runConfigurations.xml
generated
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="RunConfigurationProducerService">
|
|
||||||
<option name="ignoredProducers">
|
|
||||||
<set>
|
|
||||||
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
|
|
||||||
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
|
|
||||||
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
3
.idea/scopes/Pupil.xml
generated
3
.idea/scopes/Pupil.xml
generated
@@ -1,3 +0,0 @@
|
|||||||
<component name="DependencyValidationManager">
|
|
||||||
<scope name="Pupil" pattern="file[app]:*/" />
|
|
||||||
</component>
|
|
||||||
3
.idea/scopes/libpupil.xml
generated
3
.idea/scopes/libpupil.xml
generated
@@ -1,3 +0,0 @@
|
|||||||
<component name="DependencyValidationManager">
|
|
||||||
<scope name="libpupil" pattern="file[libpupil]:*/" />
|
|
||||||
</component>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
19
README.md
19
README.md
@@ -1,16 +1,12 @@
|
|||||||
# Pupil
|
|
||||||
|
|
||||||

|

|
||||||
*Pupil, Hitomi.la viewer for Android*
|
*Pupil, Hitomi.la viewer for Android*
|
||||||
|
|
||||||
# Screenshot
|

|
||||||

|
[](https://github.com/tom5079/Pupil/releases/download/5.3.13/Pupil-v5.3.13.apk)
|
||||||
*Main Screen*
|
[](https://discord.gg/Stj4b5v)
|
||||||
|
|
||||||

|
# Features
|
||||||
*Reader Screen*
|

|
||||||
|
|
||||||
Images are censored to be SFW
|
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
@@ -24,4 +20,7 @@ or Build app yourself
|
|||||||
|
|
||||||
# Contribution
|
# Contribution
|
||||||
|
|
||||||
Any kind of contribution is appriciated. Feel free to leave PR!
|
Any kind of contribution is appreciated. Feel free to leave PR!
|
||||||
|
|
||||||
|
## Tag Translation
|
||||||
|
Head over to [tags branch](https://github.com/tom5079/Pupil/tree/tags)
|
||||||
|
|||||||
180
app/build.gradle
180
app/build.gradle
@@ -1,95 +1,145 @@
|
|||||||
apply plugin: 'com.android.application'
|
apply plugin: "com.android.application"
|
||||||
apply plugin: 'kotlin-android'
|
apply plugin: "kotlin-android"
|
||||||
apply plugin: 'kotlin-kapt'
|
apply plugin: "kotlin-kapt"
|
||||||
apply plugin: 'kotlin-android-extensions'
|
apply plugin: "kotlin-parcelize"
|
||||||
apply plugin: 'kotlinx-serialization'
|
apply plugin: "kotlinx-serialization"
|
||||||
|
apply plugin: "com.google.android.gms.oss-licenses-plugin"
|
||||||
|
|
||||||
if (file("google-services.json").exists()) {
|
if (file("google-services.json").exists()) {
|
||||||
logger.lifecycle("Firebase Enabled")
|
logger.lifecycle("Firebase Enabled")
|
||||||
apply plugin: 'com.google.gms.google-services'
|
apply plugin: "com.google.gms.google-services"
|
||||||
apply plugin: 'io.fabric'
|
apply plugin: "com.google.firebase.crashlytics"
|
||||||
apply plugin: 'com.google.firebase.firebase-perf'
|
apply plugin: "com.google.firebase.firebase-perf"
|
||||||
} else {
|
} else {
|
||||||
logger.lifecycle("Firebase Disabled")
|
logger.lifecycle("Firebase Disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ext {
|
||||||
|
okhttp_version = "3.12.12"
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations {
|
||||||
|
all {
|
||||||
|
resolutionStrategy {
|
||||||
|
eachDependency { DependencyResolveDetails details ->
|
||||||
|
if (details.requested.group == "com.squareup.okhttp3" && details.requested.name == "okhttp") {
|
||||||
|
// OkHttp drops support before 5.0 since 3.13.0
|
||||||
|
details.useVersion okhttp_version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 29
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "xyz.quaver.pupil"
|
applicationId "xyz.quaver.pupil"
|
||||||
minSdkVersion 16
|
minSdkVersion 16
|
||||||
targetSdkVersion 29
|
compileSdk 34
|
||||||
versionCode 37
|
targetSdkVersion 34
|
||||||
versionName "5.3-beta4"
|
versionCode 69
|
||||||
|
versionName "5.3.15"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
multiDexEnabled true
|
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
debug {
|
||||||
|
defaultConfig.minSdkVersion 21
|
||||||
|
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
shrinkResources false
|
||||||
|
|
||||||
|
debuggable true
|
||||||
|
applicationIdSuffix ".debug"
|
||||||
|
versionNameSuffix "-DEBUG"
|
||||||
|
|
||||||
|
ext.enableCrashlytics = false
|
||||||
|
ext.alwaysUpdateBuildId = false
|
||||||
}
|
}
|
||||||
buildTypes.each {
|
release {
|
||||||
it.buildConfigField('boolean', 'CENSOR', 'false')
|
minifyEnabled true
|
||||||
|
shrinkResources true
|
||||||
|
|
||||||
|
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
freeCompilerArgs += '-Xuse-experimental=kotlin.Experimental'
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
|
freeCompilerArgs += "-Xuse-experimental=kotlin.Experimental"
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
|
||||||
}
|
|
||||||
buildToolsVersion = '29.0.2'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
def markwonVersion = "3.0.1"
|
implementation fileTree(dir: "libs", include: ["*.jar", "*.aar"])
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1"
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2"
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.3.2"
|
||||||
|
|
||||||
|
implementation "androidx.core:core-ktx:1.12.0"
|
||||||
|
implementation "androidx.appcompat:appcompat:1.4.1"
|
||||||
|
implementation "androidx.activity:activity-ktx:1.4.0"
|
||||||
|
implementation "androidx.fragment:fragment-ktx:1.4.1"
|
||||||
|
implementation "androidx.preference:preference-ktx:1.2.0"
|
||||||
|
implementation "androidx.recyclerview:recyclerview:1.2.1"
|
||||||
|
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
|
||||||
|
implementation "androidx.gridlayout:gridlayout:1.0.0"
|
||||||
|
implementation "androidx.biometric:biometric:1.1.0"
|
||||||
|
implementation "androidx.work:work-runtime-ktx:2.7.1"
|
||||||
|
|
||||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3'
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3'
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.14.0"
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.1.0'
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
|
||||||
implementation 'androidx.preference:preference:1.1.0'
|
|
||||||
implementation 'androidx.gridlayout:gridlayout:1.0.0'
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
|
|
||||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
|
||||||
implementation "androidx.biometric:biometric:1.0.1"
|
|
||||||
implementation 'com.android.support:multidex:1.0.3'
|
|
||||||
implementation "com.daimajia.swipelayout:library:1.2.0@aar"
|
implementation "com.daimajia.swipelayout:library:1.2.0@aar"
|
||||||
implementation 'com.google.android.material:material:1.2.0-alpha04'
|
|
||||||
implementation 'com.google.firebase:firebase-core:17.2.2'
|
|
||||||
implementation 'com.google.firebase:firebase-perf:19.0.5'
|
|
||||||
implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1'
|
|
||||||
implementation 'com.github.arimorty:floatingsearchview:2.1.1'
|
|
||||||
implementation 'com.github.clans:fab:1.6.4'
|
|
||||||
implementation 'com.github.bumptech.glide:glide:4.10.0'
|
|
||||||
implementation('com.github.bumptech.glide:recyclerview-integration:4.11.0') {
|
|
||||||
transitive = false
|
|
||||||
}
|
|
||||||
implementation 'net.rdrei.android.dirchooser:library:3.2@aar'
|
|
||||||
implementation 'com.gu:option:1.3'
|
|
||||||
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
|
|
||||||
implementation 'com.andrognito.patternlockview:patternlockview:1.0.0'
|
|
||||||
implementation "ru.noties.markwon:core:${markwonVersion}"
|
|
||||||
kapt 'com.github.bumptech.glide:compiler:4.10.0'
|
|
||||||
testImplementation 'junit:junit:4.13'
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
|
||||||
androidTestImplementation 'androidx.test:rules:1.2.0'
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.2.0'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
|
||||||
implementation project(path: ':libpupil')
|
|
||||||
}
|
|
||||||
|
|
||||||
androidExtensions {
|
implementation "com.google.android.material:material:1.11.0"
|
||||||
experimental = true
|
|
||||||
|
implementation platform('com.google.firebase:firebase-bom:32.7.0')
|
||||||
|
implementation "com.google.firebase:firebase-analytics-ktx"
|
||||||
|
implementation "com.google.firebase:firebase-crashlytics-ktx"
|
||||||
|
implementation "com.google.firebase:firebase-perf-ktx"
|
||||||
|
|
||||||
|
implementation "com.google.android.gms:play-services-oss-licenses:17.0.1"
|
||||||
|
implementation "com.google.android.gms:play-services-mlkit-face-detection:17.1.0"
|
||||||
|
|
||||||
|
implementation "com.github.clans:fab:1.6.4"
|
||||||
|
|
||||||
|
//implementation "com.quiph.ui:recyclerviewfastscroller:0.2.1"
|
||||||
|
|
||||||
|
implementation 'com.github.piasy:BigImageViewer:1.8.1'
|
||||||
|
implementation 'com.github.piasy:FrescoImageLoader:1.8.1'
|
||||||
|
implementation 'com.github.piasy:FrescoImageViewFactory:1.8.1'
|
||||||
|
implementation 'com.facebook.fresco:imagepipeline-okhttp3:2.6.0'
|
||||||
|
|
||||||
|
//noinspection GradleDependency
|
||||||
|
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
||||||
|
implementation "io.ktor:ktor-network:2.3.10"
|
||||||
|
|
||||||
|
implementation "com.tbuonomo.andrui:viewpagerdotsindicator:4.1.2"
|
||||||
|
|
||||||
|
implementation "net.rdrei.android.dirchooser:library:3.2@aar"
|
||||||
|
implementation "com.gu:option:1.3"
|
||||||
|
|
||||||
|
implementation "com.andrognito.patternlockview:patternlockview:1.0.0"
|
||||||
|
//implementation "com.andrognito.pinlockview:pinlockview:2.1.0"
|
||||||
|
|
||||||
|
implementation "ru.noties.markwon:core:3.1.0"
|
||||||
|
|
||||||
|
implementation "com.skyfishjy.ripplebackground:library:1.0.1"
|
||||||
|
|
||||||
|
implementation "org.jsoup:jsoup:1.14.3"
|
||||||
|
|
||||||
|
implementation "xyz.quaver:documentfilex:0.7.2"
|
||||||
|
implementation "xyz.quaver:floatingsearchview:1.1.7"
|
||||||
|
|
||||||
|
testImplementation "junit:junit:4.13.2"
|
||||||
|
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:1.1.3"
|
||||||
|
androidTestImplementation "androidx.test:rules:1.4.0"
|
||||||
|
androidTestImplementation "androidx.test:runner:1.4.0"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
|
||||||
}
|
}
|
||||||
BIN
app/libs/pinlockview-release.aar
Normal file
BIN
app/libs/pinlockview-release.aar
Normal file
Binary file not shown.
BIN
app/libs/recyclerviewfastscroller-release.aar
Normal file
BIN
app/libs/recyclerviewfastscroller-release.aar
Normal file
Binary file not shown.
16
app/proguard-rules.pro
vendored
16
app/proguard-rules.pro
vendored
@@ -19,3 +19,19 @@
|
|||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
|
-dontobfuscate
|
||||||
|
|
||||||
|
-keepattributes *Annotation*, InnerClasses
|
||||||
|
-dontnote kotlinx.serialization.SerializationKt
|
||||||
|
-keep,includedescriptorclasses class xyz.quaver.**$$serializer { *; } # <-- change package name to your app's
|
||||||
|
-keepclassmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
|
||||||
|
*** Companion;
|
||||||
|
}
|
||||||
|
-keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
|
||||||
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
|
}
|
||||||
|
-keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment
|
||||||
|
-keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment
|
||||||
|
-keep class xyz.quaver.pupil.** { *; }
|
||||||
|
-keep class app.cash.zipline.** { *; }
|
||||||
20
app/release/output-metadata.json
Normal file
20
app/release/output-metadata.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"artifactType": {
|
||||||
|
"type": "APK",
|
||||||
|
"kind": "Directory"
|
||||||
|
},
|
||||||
|
"applicationId": "xyz.quaver.pupil",
|
||||||
|
"variantName": "release",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "SINGLE",
|
||||||
|
"filters": [],
|
||||||
|
"attributes": [],
|
||||||
|
"versionCode": 69,
|
||||||
|
"versionName": "5.3.14",
|
||||||
|
"outputFile": "app-release.apk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"elementType": "File"
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
[{"outputType":{"type":"APK"},"apkData":{"type":"MAIN","splits":[],"versionCode":37,"versionName":"5.3-beta4","enabled":true,"outputFile":"app-release.apk","fullName":"release","baseName":"release"},"path":"app-release.apk","properties":{}}]
|
|
||||||
@@ -21,30 +21,17 @@
|
|||||||
package xyz.quaver.pupil
|
package xyz.quaver.pupil
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import androidx.test.rule.ActivityTestRule
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.ImplicitReflectionSerializer
|
import okhttp3.OkHttpClient
|
||||||
import kotlinx.serialization.json.Json
|
import okhttp3.Request
|
||||||
import kotlinx.serialization.json.JsonConfiguration
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import xyz.quaver.hiyobi.cookie
|
import xyz.quaver.pupil.hitomi.*
|
||||||
import xyz.quaver.hiyobi.createImgList
|
import java.util.*
|
||||||
import xyz.quaver.hiyobi.getReader
|
import java.util.concurrent.TimeUnit
|
||||||
import xyz.quaver.hiyobi.user_agent
|
|
||||||
import xyz.quaver.pupil.ui.LockActivity
|
|
||||||
import xyz.quaver.pupil.util.download.Cache
|
|
||||||
import xyz.quaver.pupil.util.download.DownloadWorker
|
|
||||||
import xyz.quaver.pupil.util.getDownloadDirectory
|
|
||||||
import xyz.quaver.pupil.util.updateOldReaderGalleries
|
|
||||||
import java.io.File
|
|
||||||
import java.net.URL
|
|
||||||
import javax.net.ssl.HttpsURLConnection
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instrumented test, which will execute on an Android device.
|
* Instrumented test, which will execute on an Android device.
|
||||||
@@ -53,105 +40,144 @@ import javax.net.ssl.HttpsURLConnection
|
|||||||
*/
|
*/
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class ExampleInstrumentedTest {
|
class ExampleInstrumentedTest {
|
||||||
|
// @Before
|
||||||
|
// fun init() {
|
||||||
|
// val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
// }
|
||||||
|
|
||||||
@Test
|
@Before
|
||||||
fun useAppContext() {
|
fun init() {
|
||||||
// Context of the app under test.
|
clientBuilder = OkHttpClient.Builder()
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
.readTimeout(0, TimeUnit.SECONDS)
|
||||||
Log.i("PUPILD", getDownloadDirectory(appContext).absolutePath ?: "")
|
.writeTimeout(0, TimeUnit.SECONDS)
|
||||||
assertEquals("xyz.quaver.pupil", appContext.packageName)
|
.callTimeout(0, TimeUnit.SECONDS)
|
||||||
|
.connectTimeout(0, TimeUnit.SECONDS)
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val request = chain.request().newBuilder()
|
||||||
|
.header("Referer", "https://hitomi.la/")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
chain.proceed(request)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun checkCacheDir() {
|
fun test_empty() {
|
||||||
val activityTestRule = ActivityTestRule(LockActivity::class.java)
|
print(
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
"".trim()
|
||||||
|
.replace(Regex("""^\?"""), "")
|
||||||
ContextCompat.getExternalFilesDirs(appContext, null).forEachIndexed { index, file ->
|
.lowercase(Locale.getDefault())
|
||||||
Log.i("PUPILD", "$index: ${file?.absolutePath}")
|
.split(Regex("\\s+"))
|
||||||
|
.map {
|
||||||
|
it.replace('_', ' ')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
@Test
|
||||||
|
fun test_nozomi() {
|
||||||
|
val nozomi = getGalleryIDsFromNozomi(null, "index", "all")
|
||||||
|
|
||||||
|
Log.d("PUPILD", nozomi.size.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_search() {
|
||||||
|
val ids = getGalleryIDsForQuery("language:korean").reversed()
|
||||||
|
|
||||||
|
print(ids.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_suggestions() {
|
||||||
|
val suggestions = getSuggestionsForQuery("language:g")
|
||||||
|
|
||||||
|
print(suggestions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_doSearch() {
|
fun test_doSearch() {
|
||||||
val reader = getReader( 1426382)
|
val r = runBlocking {
|
||||||
|
doSearch("language:korean")
|
||||||
val data: ByteArray
|
|
||||||
|
|
||||||
with(URL(createImgList(1426382, reader)[0].path).openConnection() as HttpsURLConnection) {
|
|
||||||
setRequestProperty("User-Agent", user_agent)
|
|
||||||
setRequestProperty("Cookie", cookie)
|
|
||||||
|
|
||||||
data = inputStream.readBytes()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d("Pupil", data.size.toString())
|
Log.d("PUPILD", r.take(10).toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
// @Test
|
||||||
|
// fun test_getBlock() {
|
||||||
|
// val galleryBlock = getGalleryBlock(2097576)
|
||||||
|
//
|
||||||
|
// print(galleryBlock)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @Test
|
||||||
|
// fun test_getGallery() {
|
||||||
|
// val gallery = getGallery(2097751)
|
||||||
|
//
|
||||||
|
// print(gallery)
|
||||||
|
// }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_deleteCodeFromReader() {
|
fun test_getGalleryInfo() {
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
val info = getGalleryInfo(1469394)
|
||||||
|
|
||||||
val json = Json(JsonConfiguration.Stable)
|
print(info)
|
||||||
|
|
||||||
listOf(
|
|
||||||
getDownloadDirectory(context),
|
|
||||||
File(context.cacheDir, "imageCache")
|
|
||||||
).forEach { root ->
|
|
||||||
root.listFiles()?.forEach gallery@{ gallery ->
|
|
||||||
val reader = json.parseJson(File(gallery, "reader.json").apply {
|
|
||||||
if (!exists())
|
|
||||||
return@gallery
|
|
||||||
}.readText())
|
|
||||||
.jsonObject.toMutableMap()
|
|
||||||
|
|
||||||
Log.d("PUPILD", gallery.name)
|
|
||||||
|
|
||||||
reader.remove("code")
|
|
||||||
|
|
||||||
File(gallery, "reader.json").writeText(JsonObject(reader).toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_updateOldReader() {
|
fun test_getReader() {
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
val reader = getGalleryInfo(2128654)
|
||||||
|
|
||||||
updateOldReaderGalleries(context)
|
Log.d("PUPILD", reader.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_downloadWorker() {
|
fun test_getImages() { runBlocking {
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
val galleryID = 2128654
|
||||||
|
|
||||||
val galleryID = 515515
|
val images = getGalleryInfo(galleryID).files.map {
|
||||||
|
imageUrlFromImage(galleryID, it,false)
|
||||||
val worker = DownloadWorker.getInstance(context)
|
|
||||||
|
|
||||||
worker.queue.add(galleryID)
|
|
||||||
|
|
||||||
while(worker.progress.indexOfKey(galleryID) < 0 || worker.progress[galleryID] != null) {
|
|
||||||
Log.i("PUPILD", worker.progress[galleryID]?.joinToString(" ") ?: "null")
|
|
||||||
|
|
||||||
if (worker.progress[galleryID]?.all { !it.isFinite() } == true)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.i("PUPILD", "DONE!!")
|
Log.d("PUPILD", images.toString())
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
// images.forEachIndexed { index, image ->
|
||||||
fun test_getReaderOrNull() {
|
// println("Testing $index/${images.size}: $image")
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
// val response = client.newCall(
|
||||||
|
// Request.Builder()
|
||||||
|
// .url(image)
|
||||||
|
// .header("Referer", "https://hitomi.la/")
|
||||||
|
// .build()
|
||||||
|
// ).execute()
|
||||||
|
//
|
||||||
|
// assertEquals(200, response.code())
|
||||||
|
//
|
||||||
|
// println("$index/${images.size} Passed")
|
||||||
|
// }
|
||||||
|
} }
|
||||||
|
|
||||||
val galleryID = 1561552
|
// @Test
|
||||||
|
// fun test_urlFromUrlFromHash() {
|
||||||
|
// val url = urlFromUrlFromHash(1531795, GalleryFiles(
|
||||||
|
// 212, "719d46a7556be0d0021c5105878507129b5b3308b02cf67f18901b69dbb3b5ef", 1, "00.jpg", 300
|
||||||
|
// ), "webp")
|
||||||
|
//
|
||||||
|
// print(url)
|
||||||
|
// }
|
||||||
|
|
||||||
runBlocking {
|
// @Test
|
||||||
Log.i("PUPILD", Cache(context).getReader(galleryID)?.title ?: "null")
|
// suspend fun test_doSearch_extreme() {
|
||||||
}
|
// val query = "language:korean -tag:sample -female:humiliation -female:diaper -female:strap-on -female:squirting -female:lizard_girl -female:voyeurism -type:artistcg -female:blood -female:ryona -male:blood -male:ryona -female:crotch_tattoo -male:urethra_insertion -female:living_clothes -male:tentacles -female:slave -female:gag -male:gag -female:wooden_horse -male:exhibitionism -male:miniguy -female:mind_break -male:mind_break -male:unbirth -tag:scanmark -tag:no_penetration -tag:nudity_only -female:enema -female:brain_fuck -female:navel_fuck -tag:novel -tag:mosaic_censorship -tag:webtoon -male:rape -female:rape -female:yuri -male:anal -female:anal -female:futanari -female:huge_breasts -female:big_areolae -male:torture -male:stuck_in_wall -female:stuck_in_wall -female:torture -female:birth -female:pregnant -female:drugs -female:bdsm -female:body_writing -female:cbt -male:dark_skin -male:insect -female:insect -male:vore -female:vore -female:vomit -female:urination -female:urethra_insertion -tag:mmf_threesome -female:sex_toys -female:double_penetration -female:eggs -female:prolapse -male:smell -male:bestiality -female:bestiality -female:big_ass -female:milf -female:mother -male:dilf -male:netorare -female:netorare -female:cosplaying -female:filming -female:armpit_sex -female:armpit_licking -female:tickling -female:lactation -male:skinsuit -female:skinsuit -male:bbm -female:prostitution -female:double_penetration -female:females_only -male:males_only -female:tentacles -female:tentacles -female:stomach_deformation -female:hairy_armpits -female:large_insertions -female:mind_control -male:orc -female:dark_skin -male:yandere -female:yandere -female:scat -female:toddlercon -female:bbw -female:hairy -male:cuntboy -male:lactation -male:drugs -female:body_modification -female:monoeye -female:chikan -female:long_tongue -female:harness -female:fisting -female:glory_hole -female:latex -male:latex -female:unbirth -female:giantess -female:sole_dickgirl -female:robot -female:doll_joints -female:machine -tag:artbook -male:cbt -female:farting -male:farting -male:midget -female:midget -female:exhibitionism -male:monster -female:big_nipples -female:big_clit -female:gyaru -female:piercing -female:necrophilia -female:snuff -female:smell -male:cheating -female:cheating -male:snuff -female:harem -male:harem"
|
||||||
|
// print(doSearch(query).size)
|
||||||
|
// }
|
||||||
|
|
||||||
Log.i("PUPILD", Cache(context).getReaderOrNull(galleryID)?.title ?: "null")
|
// @Test
|
||||||
}
|
// suspend fun test_parse() {
|
||||||
|
// print(doSearch("-male:yaoi -female:yaoi -female:loli").size)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Test
|
||||||
|
// fun test_subdomainFromUrl() {
|
||||||
|
// val galleryInfo = getGalleryInfo(1929109).files[2]
|
||||||
|
// print(urlFromUrlFromHash(1929109, galleryInfo, "webp", null, "a"))
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!--
|
<!--
|
||||||
~ Pupil, Hitomi.la viewer for Android
|
~ Pupil, Hitomi.la viewer for Android
|
||||||
~ Copyright (C) 2019 tom5079
|
~ Copyright (C) 2020 tom5079
|
||||||
~
|
~
|
||||||
~ This program is free software: you can redistribute it and/or modify
|
~ This program is free software: you can redistribute it and/or modify
|
||||||
~ it under the terms of the GNU General Public License as published by
|
~ it under the terms of the GNU General Public License as published by
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<GridLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
android:layout_width="match_parent"
|
<string name="app_name" translatable="false" tools:override="true">Pupil-Debug</string>
|
||||||
android:layout_height="wrap_content"
|
</resources>
|
||||||
android:columnCount="3"/>
|
|
||||||
@@ -6,9 +6,22 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<uses-permission
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
|
||||||
android:maxSdkVersion="21" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation"
|
||||||
|
tools:targetApi="s" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="32"
|
||||||
|
tools:ignore="CoarseFineLocation" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".Pupil"
|
android:name=".Pupil"
|
||||||
@@ -19,7 +32,13 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
tools:replace="android:theme">
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
tools:replace="android:theme"
|
||||||
|
tools:ignore="UnusedAttribute">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||||
|
android:value="face" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:authorities="${applicationId}.provider"
|
android:authorities="${applicationId}.provider"
|
||||||
@@ -33,85 +52,139 @@
|
|||||||
|
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<service android:name=".services.DownloadService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<service android:name=".services.TransferClientService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<service android:name=".services.TransferServerService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receiver.UpdateBroadcastReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<activity android:name=".ui.LockActivity" />
|
<activity android:name=".ui.LockActivity" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.ReaderActivity"
|
android:name=".ui.ReaderActivity"
|
||||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||||
android:parentActivityName=".ui.MainActivity">
|
android:parentActivityName=".ui.MainActivity"
|
||||||
<intent-filter>
|
android:exported="true">
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data
|
<data android:scheme="http" />
|
||||||
android:host="hitomi.la"
|
<data android:scheme="https" />
|
||||||
android:pathPrefix="/galleries"
|
<data android:host="*.hasha.in"/>
|
||||||
android:scheme="https" />
|
<data android:pathPrefix="/reader"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data
|
<data android:scheme="http" />
|
||||||
android:host="hiyobi.me"
|
<data android:scheme="https" />
|
||||||
android:pathPrefix="/reader"
|
<data android:host="hitomi.la"/>
|
||||||
android:scheme="https" />
|
<data android:pathPrefix="/galleries"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data
|
<data android:scheme="http" />
|
||||||
android:host="e-hentai.org"
|
<data android:scheme="https" />
|
||||||
android:pathPrefix="/g"
|
<data android:host="hitomi.la" />
|
||||||
android:scheme="https" />
|
<data android:pathPrefix="/manga" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data
|
<data android:scheme="http" />
|
||||||
android:host="hitomi.la"
|
<data android:scheme="https" />
|
||||||
android:pathPrefix="/galleries"
|
<data android:host="hitomi.la" />
|
||||||
android:scheme="http" />
|
<data android:pathPrefix="/doujinshi" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data
|
<data android:scheme="http" />
|
||||||
android:host="hiyobi.me"
|
<data android:scheme="https" />
|
||||||
android:scheme="http"
|
<data android:host="hitomi.la" />
|
||||||
android:pathPrefix="/reader" />
|
<data android:pathPrefix="/cg" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data
|
<data android:scheme="http" />
|
||||||
android:host="e-hentai.org"
|
<data android:scheme="https" />
|
||||||
android:pathPrefix="/g"
|
<data android:host="hitomi.la" />
|
||||||
android:scheme="http" />
|
<data android:pathPrefix="/imageset" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data android:scheme="http" />
|
||||||
|
<data android:scheme="https" />
|
||||||
|
<data android:host="hitomi.la" />
|
||||||
|
<data android:pathPrefix="/reader" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data android:scheme="http" />
|
||||||
|
<data android:host="e-hentai.org" />
|
||||||
|
<data android:pathPrefix="/g" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data android:scheme="https" />
|
||||||
|
<data android:host="e-hentai.org" />
|
||||||
|
<data android:pathPrefix="/g" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.SettingsActivity"
|
android:name=".ui.SettingsActivity"
|
||||||
android:label="@string/settings_title" />
|
android:label="@string/settings_title">
|
||||||
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||||
android:theme="@style/NoActionBarAppTheme">
|
android:theme="@style/NoActionBarAppTheme"
|
||||||
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
@@ -120,6 +193,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" />
|
<activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" />
|
||||||
|
<activity android:name=".ui.TransferActivity" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -18,48 +18,177 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil
|
package xyz.quaver.pupil
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.multidex.MultiDexApplication
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.facebook.imagepipeline.backends.okhttp3.OkHttpImagePipelineConfigFactory
|
||||||
|
import com.github.piasy.biv.BigImageViewer
|
||||||
|
import com.github.piasy.biv.loader.fresco.FrescoImageLoader
|
||||||
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
|
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
|
||||||
import com.google.android.gms.common.GooglePlayServicesRepairableException
|
import com.google.android.gms.common.GooglePlayServicesRepairableException
|
||||||
import com.google.android.gms.security.ProviderInstaller
|
import com.google.android.gms.security.ProviderInstaller
|
||||||
import xyz.quaver.pupil.util.Histories
|
import com.google.firebase.FirebaseApp
|
||||||
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import okhttp3.Dispatcher
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Response
|
||||||
|
import xyz.quaver.io.FileX
|
||||||
|
import xyz.quaver.pupil.hitomi.evaluationContext
|
||||||
|
import xyz.quaver.pupil.hitomi.readText
|
||||||
|
import xyz.quaver.pupil.types.Tag
|
||||||
|
import xyz.quaver.pupil.util.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.net.URL
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
class Pupil : MultiDexApplication() {
|
typealias PupilInterceptor = (Interceptor.Chain) -> Response
|
||||||
|
|
||||||
lateinit var histories: Histories
|
lateinit var histories: SavedSet<Int>
|
||||||
lateinit var favorites: Histories
|
private set
|
||||||
|
lateinit var favorites: SavedSet<Int>
|
||||||
|
private set
|
||||||
|
lateinit var favoriteTags: SavedSet<Tag>
|
||||||
|
private set
|
||||||
|
lateinit var searchHistory: SavedSet<String>
|
||||||
|
private set
|
||||||
|
|
||||||
init {
|
val interceptors = mutableMapOf<KClass<out Any>, PupilInterceptor>()
|
||||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
|
||||||
|
lateinit var clientBuilder: OkHttpClient.Builder
|
||||||
|
|
||||||
|
var clientHolder: OkHttpClient? = null
|
||||||
|
val client: OkHttpClient
|
||||||
|
get() = clientHolder ?: clientBuilder.build().also {
|
||||||
|
clientHolder = it
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSSLContext(context: Context): SSLContext {
|
||||||
|
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||||
|
keyStore.load(null, null)
|
||||||
|
|
||||||
|
val certificateFactory = CertificateFactory.getInstance("X.509")
|
||||||
|
|
||||||
|
val certificate = context.resources.openRawResource(R.raw.isrgrootx1).use {
|
||||||
|
certificateFactory.generateCertificate(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyStore.setCertificateEntry("isrgrootx1", certificate)
|
||||||
|
|
||||||
|
val defaultTrustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
defaultTrustManagerFactory.init(null as KeyStore?)
|
||||||
|
|
||||||
|
defaultTrustManagerFactory.trustManagers.filterIsInstance(X509TrustManager::class.java).forEach { trustManager ->
|
||||||
|
trustManager.acceptedIssuers.forEach { acceptedIssuer ->
|
||||||
|
keyStore.setCertificateEntry(acceptedIssuer.subjectDN.name, acceptedIssuer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
trustManagerFactory.init(keyStore)
|
||||||
|
|
||||||
|
val sslContext = SSLContext.getInstance("TLS")
|
||||||
|
sslContext.init(null, trustManagerFactory.trustManagers, SecureRandom())
|
||||||
|
|
||||||
|
return sslContext
|
||||||
|
}
|
||||||
|
|
||||||
|
class Pupil : Application() {
|
||||||
|
companion object {
|
||||||
|
lateinit var instance: Pupil
|
||||||
|
private set
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
val preference = PreferenceManager.getDefaultSharedPreferences(this)
|
instance = this
|
||||||
|
|
||||||
histories = Histories(File(ContextCompat.getDataDir(this), "histories.json"))
|
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
||||||
favorites = Histories(File(ContextCompat.getDataDir(this), "favorites.json"))
|
|
||||||
|
|
||||||
val download = try {
|
preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
preference.getString("dl_location", null)
|
|
||||||
|
val userID = Preferences["user_id", ""].let { userID ->
|
||||||
|
if (userID.isEmpty()) UUID.randomUUID().toString().also { Preferences["user_id"] = it }
|
||||||
|
else userID
|
||||||
|
}
|
||||||
|
|
||||||
|
FirebaseApp.initializeApp(this)
|
||||||
|
FirebaseCrashlytics.getInstance().setUserId(userID)
|
||||||
|
|
||||||
|
val proxyInfo = getProxyInfo()
|
||||||
|
|
||||||
|
clientBuilder = OkHttpClient.Builder()
|
||||||
|
// .connectTimeout(0, TimeUnit.SECONDS)
|
||||||
|
.sslSocketFactory(getSSLContext(this).socketFactory)
|
||||||
|
.readTimeout(0, TimeUnit.SECONDS)
|
||||||
|
.proxyInfo(proxyInfo)
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val request = chain.request().newBuilder()
|
||||||
|
.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36")
|
||||||
|
.header("Referer", "https://hitomi.la/")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val tag = request.tag() ?: return@addInterceptor chain.proceed(request)
|
||||||
|
|
||||||
|
interceptors[tag::class]?.invoke(chain) ?: chain.proceed(request)
|
||||||
|
}.apply {
|
||||||
|
(Preferences.get<String>("max_concurrent_download").toIntOrNull() ?: 0).let {
|
||||||
|
if (it != 0)
|
||||||
|
dispatcher(Dispatcher(Executors.newFixedThreadPool(it)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Preferences.get<String>("download_folder").also {
|
||||||
|
if (it.startsWith("content://"))
|
||||||
|
contentResolver.takePersistableUriPermission(
|
||||||
|
Uri.parse(it),
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!FileX(this, it).canWrite())
|
||||||
|
throw Exception()
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
preference.edit().remove("dl_location").apply()
|
Preferences.remove("download_folder")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (download == null) {
|
if (!Preferences["reset_secure", false]) {
|
||||||
val default = ContextCompat.getExternalFilesDirs(this, null)[0]
|
Preferences["security_mode"] = false
|
||||||
preference.edit().putString("dl_location", Uri.fromFile(default).toString()).apply()
|
Preferences["reset_secure"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
histories = SavedSet(File(ContextCompat.getDataDir(this), "histories.json"), 0)
|
||||||
|
favorites = SavedSet(File(ContextCompat.getDataDir(this), "favorites.json"), 0)
|
||||||
|
favoriteTags = SavedSet(File(ContextCompat.getDataDir(this), "favorites_tags.json"), Tag.parse(""))
|
||||||
|
searchHistory = SavedSet(File(ContextCompat.getDataDir(this), "search_histories.json"), "")
|
||||||
|
|
||||||
|
favoriteTags.filter { it.tag.contains('_') }.forEach {
|
||||||
|
favoriteTags.remove(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (BuildConfig.DEBUG)
|
||||||
|
FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false)*/
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ProviderInstaller.installIfNeeded(this)
|
ProviderInstaller.installIfNeeded(this)
|
||||||
} catch (e: GooglePlayServicesRepairableException) {
|
} catch (e: GooglePlayServicesRepairableException) {
|
||||||
@@ -68,19 +197,55 @@ class Pupil : MultiDexApplication() {
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BigImageViewer.initialize(
|
||||||
|
FrescoImageLoader.with(
|
||||||
|
this,
|
||||||
|
OkHttpImagePipelineConfigFactory
|
||||||
|
.newBuilder(this, client)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
val channel = NotificationChannel("download", getString(R.string.channel_download), NotificationManager.IMPORTANCE_MIN).apply {
|
|
||||||
|
manager.createNotificationChannel(NotificationChannel("download", getString(R.string.channel_download), NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
description = getString(R.string.channel_download_description)
|
description = getString(R.string.channel_download_description)
|
||||||
enableLights(false)
|
enableLights(false)
|
||||||
enableVibration(false)
|
enableVibration(false)
|
||||||
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||||
}
|
})
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
|
manager.createNotificationChannel(NotificationChannel("downloader", getString(R.string.channel_downloader), NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
|
description = getString(R.string.channel_downloader_description)
|
||||||
|
enableLights(false)
|
||||||
|
enableVibration(false)
|
||||||
|
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||||
|
})
|
||||||
|
|
||||||
|
manager.createNotificationChannel(NotificationChannel("update", getString(R.string.channel_update), NotificationManager.IMPORTANCE_HIGH).apply {
|
||||||
|
description = getString(R.string.channel_update_description)
|
||||||
|
enableLights(true)
|
||||||
|
enableVibration(true)
|
||||||
|
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||||
|
})
|
||||||
|
|
||||||
|
manager.createNotificationChannel(NotificationChannel("import", getString(R.string.channel_update), NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
|
description = getString(R.string.channel_update_description)
|
||||||
|
enableLights(false)
|
||||||
|
enableVibration(false)
|
||||||
|
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||||
|
})
|
||||||
|
|
||||||
|
manager.createNotificationChannel(NotificationChannel("transfer", getString(R.string.channel_transfer), NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
|
description = getString(R.string.channel_transfer_description)
|
||||||
|
enableLights(false)
|
||||||
|
enableVibration(false)
|
||||||
|
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
AppCompatDelegate.setDefaultNightMode(when (Preferences.get<Boolean>("dark_mode")) {
|
||||||
AppCompatDelegate.setDefaultNightMode(when (preference.getBoolean("dark_mode", false)) {
|
|
||||||
true -> AppCompatDelegate.MODE_NIGHT_YES
|
true -> AppCompatDelegate.MODE_NIGHT_YES
|
||||||
false -> AppCompatDelegate.MODE_NIGHT_NO
|
false -> AppCompatDelegate.MODE_NIGHT_NO
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,103 +18,97 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.adapters
|
package xyz.quaver.pupil.adapters
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.util.Base64
|
|
||||||
import android.util.SparseBooleanArray
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.LinearLayout
|
import android.widget.Toast
|
||||||
import androidx.cardview.widget.CardView
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
|
||||||
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
|
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
|
||||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||||
import com.bumptech.glide.Glide
|
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|
||||||
import com.daimajia.swipe.SwipeLayout
|
import com.daimajia.swipe.SwipeLayout
|
||||||
import com.daimajia.swipe.adapters.RecyclerSwipeAdapter
|
import com.daimajia.swipe.adapters.RecyclerSwipeAdapter
|
||||||
import com.daimajia.swipe.interfaces.SwipeAdapterInterface
|
import com.daimajia.swipe.interfaces.SwipeAdapterInterface
|
||||||
import com.google.android.material.chip.Chip
|
import com.github.piasy.biv.loader.ImageLoader
|
||||||
import kotlinx.android.synthetic.main.item_galleryblock.view.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import xyz.quaver.io.util.getChild
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import xyz.quaver.hitomi.GalleryBlock
|
|
||||||
import xyz.quaver.pupil.BuildConfig
|
|
||||||
import xyz.quaver.pupil.Pupil
|
|
||||||
import xyz.quaver.pupil.R
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.databinding.GalleryblockItemBinding
|
||||||
|
import xyz.quaver.pupil.favoriteTags
|
||||||
|
import xyz.quaver.pupil.favorites
|
||||||
|
import xyz.quaver.pupil.hitomi.getGallery
|
||||||
|
import xyz.quaver.pupil.hitomi.getGalleryInfo
|
||||||
import xyz.quaver.pupil.types.Tag
|
import xyz.quaver.pupil.types.Tag
|
||||||
import xyz.quaver.pupil.util.Histories
|
import xyz.quaver.pupil.ui.view.ProgressCard
|
||||||
import xyz.quaver.pupil.util.download.Cache
|
import xyz.quaver.pupil.util.Preferences
|
||||||
import xyz.quaver.pupil.util.download.DownloadWorker
|
import xyz.quaver.pupil.util.downloader.Cache
|
||||||
|
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||||
import xyz.quaver.pupil.util.wordCapitalize
|
import xyz.quaver.pupil.util.wordCapitalize
|
||||||
import java.util.*
|
import java.io.File
|
||||||
import kotlin.collections.ArrayList
|
|
||||||
import kotlin.concurrent.schedule
|
|
||||||
|
|
||||||
class GalleryBlockAdapter(context: Context, private val galleries: List<GalleryBlock>) : RecyclerSwipeAdapter<RecyclerView.ViewHolder>(), SwipeAdapterInterface {
|
class GalleryBlockAdapter(private val galleries: List<Int>) : RecyclerSwipeAdapter<RecyclerView.ViewHolder>(), SwipeAdapterInterface {
|
||||||
|
|
||||||
enum class ViewType {
|
var updateAll = true
|
||||||
NEXT,
|
var thin: Boolean = Preferences["thin"]
|
||||||
GALLERY,
|
|
||||||
PREV
|
inner class GalleryViewHolder(val binding: GalleryblockItemBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
private var galleryID: Int = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
while (updateAll) {
|
||||||
|
updateProgress(itemView.context)
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val glide = Glide.with(context)
|
private fun updateProgress(context: Context) = CoroutineScope(Dispatchers.Main).launch {
|
||||||
private lateinit var favorites: Histories
|
with(binding.galleryblockCard) {
|
||||||
|
val imageList = Cache.getInstance(context, galleryID).metadata.imageList
|
||||||
|
|
||||||
val timer = Timer()
|
if (imageList == null) {
|
||||||
|
max = 0
|
||||||
inner class GalleryViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
|
return@with
|
||||||
var timerTask: TimerTask? = null
|
|
||||||
|
|
||||||
fun updateProgress(context: Context, galleryID: Int) = CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
val cache = Cache(context).getCachedGallery(galleryID)
|
|
||||||
val reader = Cache(context).getReaderOrNull(galleryID)
|
|
||||||
|
|
||||||
if (reader == null) {
|
|
||||||
view.galleryblock_progressbar.visibility = View.GONE
|
|
||||||
view.galleryblock_progress_complete.visibility = View.GONE
|
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with(view.galleryblock_progressbar) {
|
progress = imageList.count { it != null }
|
||||||
|
max = imageList.size
|
||||||
|
|
||||||
progress = cache?.listFiles()?.count { file ->
|
this@GalleryViewHolder.binding.galleryblockId.setOnClickListener {
|
||||||
Regex("^[0-9]+.+\$").matches(file.name!!)
|
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
|
||||||
} ?: 0
|
ClipData.newPlainText("gallery_id", galleryID.toString())
|
||||||
|
)
|
||||||
if (visibility == View.GONE) {
|
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||||
visibility = View.VISIBLE
|
|
||||||
max = reader.galleryInfo.size
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progress == max) {
|
type = if (!imageList.contains(null)) {
|
||||||
if (completeFlag.get(galleryID, false)) {
|
val downloadManager = DownloadManager.getInstance(context)
|
||||||
with(view.galleryblock_progress_complete) {
|
|
||||||
setImageResource(R.drawable.ic_progressbar)
|
if (downloadManager.getDownloadFolder(galleryID) == null)
|
||||||
visibility = View.VISIBLE
|
ProgressCard.Type.CACHE
|
||||||
}
|
else
|
||||||
} else {
|
ProgressCard.Type.DOWNLOAD
|
||||||
with(view.galleryblock_progress_complete) {
|
|
||||||
setImageDrawable(AnimatedVectorDrawableCompat.create(context, R.drawable.ic_progressbar_complete).apply {
|
|
||||||
this?.start()
|
|
||||||
})
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
completeFlag.put(galleryID, true)
|
|
||||||
}
|
|
||||||
} else
|
} else
|
||||||
view.galleryblock_progress_complete.visibility = View.INVISIBLE
|
ProgressCard.Type.LOADING
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bind(galleryBlock: GalleryBlock) {
|
fun bind(galleryID: Int) {
|
||||||
with(view) {
|
this.galleryID = galleryID
|
||||||
val resources = context.resources
|
updateProgress(itemView.context)
|
||||||
|
|
||||||
|
val cache = Cache.getInstance(itemView.context, galleryID)
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val galleryBlock = cache.getGalleryBlock() ?: return@launch
|
||||||
|
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
val resources = itemView.context.resources
|
||||||
val languages = resources.getStringArray(R.array.languages).map {
|
val languages = resources.getStringArray(R.array.languages).map {
|
||||||
it.split("|").let { split ->
|
it.split("|").let { split ->
|
||||||
Pair(split[0], split[1])
|
Pair(split[0], split[1])
|
||||||
@@ -124,62 +118,60 @@ class GalleryBlockAdapter(context: Context, private val galleries: List<GalleryB
|
|||||||
val artists = galleryBlock.artists
|
val artists = galleryBlock.artists
|
||||||
val series = galleryBlock.series
|
val series = galleryBlock.series
|
||||||
|
|
||||||
galleryblock_thumbnail.setImageDrawable(CircularProgressDrawable(context).also {
|
binding.galleryblockThumbnail.apply {
|
||||||
it.start()
|
setOnClickListener {
|
||||||
|
itemView.performClick()
|
||||||
|
}
|
||||||
|
setOnLongClickListener {
|
||||||
|
itemView.performLongClick()
|
||||||
|
}
|
||||||
|
setFailureImage(ContextCompat.getDrawable(context, R.drawable.image_broken_variant))
|
||||||
|
setImageLoaderCallback(object: ImageLoader.Callback {
|
||||||
|
override fun onFail(error: Exception?) {
|
||||||
|
Cache.delete(context, galleryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCacheHit(imageType: Int, image: File?) {}
|
||||||
|
override fun onCacheMiss(imageType: Int, image: File?) {}
|
||||||
|
override fun onFinish() {}
|
||||||
|
override fun onProgress(progress: Int) {}
|
||||||
|
override fun onStart() {}
|
||||||
|
override fun onSuccess(image: File?) {}
|
||||||
})
|
})
|
||||||
|
ssiv?.recycle()
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
val thumbnail = Cache(context).getThumbnail(galleryBlock.id).let {
|
cache.getThumbnail().let { launch(Dispatchers.Main) {
|
||||||
if (it != null)
|
showImage(it)
|
||||||
Base64.decode(it, Base64.DEFAULT)
|
} }
|
||||||
else
|
}
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
glide
|
binding.galleryblockTitle.text = galleryBlock.title
|
||||||
.load(thumbnail)
|
with(binding.galleryblockArtist) {
|
||||||
.skipMemoryCache(true)
|
text = artists.joinToString { it.wordCapitalize() }
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
||||||
.error(R.drawable.image_broken_variant)
|
|
||||||
.apply {
|
|
||||||
if (BuildConfig.CENSOR)
|
|
||||||
override(5, 8)
|
|
||||||
}
|
|
||||||
.into(galleryblock_thumbnail)
|
|
||||||
}
|
|
||||||
|
|
||||||
//Check cache
|
|
||||||
val cache = Cache(context).getCachedGallery(galleryBlock.id)
|
|
||||||
val reader = Cache(context).getReaderOrNull(galleryBlock.id)
|
|
||||||
|
|
||||||
if (cache != null && reader != null) {
|
|
||||||
val count = cache.listFiles().count {
|
|
||||||
Regex("^[0-9]+.+\$").matches(it.name!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
with(galleryblock_progressbar) {
|
|
||||||
max = reader.galleryInfo.size
|
|
||||||
progress = count
|
|
||||||
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
galleryblock_progressbar.visibility = View.GONE
|
|
||||||
|
|
||||||
if (timerTask == null)
|
|
||||||
timerTask = timer.schedule(0, 1000) {
|
|
||||||
updateProgress(context, galleryBlock.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryblock_title.text = galleryBlock.title
|
|
||||||
with(galleryblock_artist) {
|
|
||||||
text = artists.joinToString(", ") { it.wordCapitalize() }
|
|
||||||
visibility = when {
|
visibility = when {
|
||||||
artists.isNotEmpty() -> View.VISIBLE
|
artists.isNotEmpty() -> View.VISIBLE
|
||||||
else -> View.GONE
|
else -> View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val gallery = runCatching {
|
||||||
|
getGallery(galleryID)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (gallery?.groups?.isNotEmpty() != true)
|
||||||
|
return@launch
|
||||||
|
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
text = context.getString(
|
||||||
|
R.string.galleryblock_artist_with_group,
|
||||||
|
artists.joinToString { it.wordCapitalize() },
|
||||||
|
gallery.groups.joinToString { it.wordCapitalize() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
with(galleryblock_series) {
|
}
|
||||||
|
}
|
||||||
|
with(binding.galleryblockSeries) {
|
||||||
text =
|
text =
|
||||||
resources.getString(
|
resources.getString(
|
||||||
R.string.galleryblock_series,
|
R.string.galleryblock_series,
|
||||||
@@ -189,54 +181,61 @@ class GalleryBlockAdapter(context: Context, private val galleries: List<GalleryB
|
|||||||
else -> View.GONE
|
else -> View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
galleryblock_type.text = resources.getString(R.string.galleryblock_type, galleryBlock.type).wordCapitalize()
|
binding.galleryblockType.text = resources.getString(R.string.galleryblock_type, galleryBlock.type).wordCapitalize()
|
||||||
with(galleryblock_language) {
|
with(binding.galleryblockLanguage) {
|
||||||
text =
|
text =
|
||||||
resources.getString(R.string.galleryblock_language, languages[galleryBlock.language])
|
resources.getString(R.string.galleryblock_language, languages[galleryBlock.language])
|
||||||
visibility = when {
|
visibility = when {
|
||||||
galleryBlock.language.isNotEmpty() -> View.VISIBLE
|
!galleryBlock.language.isNullOrEmpty() -> View.VISIBLE
|
||||||
else -> View.GONE
|
else -> View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
galleryblock_tag_group.removeAllViews()
|
with(binding.galleryblockTagGroup) {
|
||||||
galleryBlock.relatedTags.forEach {
|
onClickListener = {
|
||||||
galleryblock_tag_group.addView(Chip(context).apply {
|
onChipClickedHandler.forEach { callback ->
|
||||||
val tag = Tag.parse(it).let { tag ->
|
callback.invoke(it)
|
||||||
when {
|
|
||||||
tag.area != null -> tag
|
|
||||||
else -> Tag("tag", it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chipIcon = when(tag.area) {
|
tags.clear()
|
||||||
"male" -> {
|
|
||||||
setChipBackgroundColorResource(R.color.material_blue_700)
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
tags.addAll(
|
||||||
ContextCompat.getDrawable(context, R.drawable.ic_gender_male_white)
|
galleryBlock.relatedTags.sortedBy {
|
||||||
|
val tag = Tag.parse(it)
|
||||||
|
|
||||||
|
if (favoriteTags.contains(tag))
|
||||||
|
-1
|
||||||
|
else
|
||||||
|
when(Tag.parse(it).area) {
|
||||||
|
"female" -> 0
|
||||||
|
"male" -> 1
|
||||||
|
else -> 2
|
||||||
}
|
}
|
||||||
"female" -> {
|
}.map {
|
||||||
setChipBackgroundColorResource(R.color.material_pink_600)
|
Tag.parse(it)
|
||||||
setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
|
||||||
ContextCompat.getDrawable(context, R.drawable.ic_gender_female_white)
|
|
||||||
}
|
}
|
||||||
else -> null
|
)
|
||||||
|
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
refresh()
|
||||||
}
|
}
|
||||||
text = tag.tag.wordCapitalize()
|
|
||||||
setEnsureMinTouchTargetSize(false)
|
|
||||||
setOnClickListener {
|
|
||||||
for (callback in onChipClickedHandler)
|
|
||||||
callback.invoke(tag)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
galleryblock_id.text = galleryBlock.id.toString()
|
binding.galleryblockId.text = galleryBlock.id.toString()
|
||||||
|
binding.galleryblockPagecount.text = "-"
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val pageCount = kotlin.runCatching {
|
||||||
|
getGalleryInfo(galleryBlock.id).files.size
|
||||||
|
}.getOrNull() ?: return@launch
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
binding.galleryblockPagecount.text = itemView.context.getString(R.string.galleryblock_pagecount, pageCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!::favorites.isInitialized)
|
with(binding.galleryblockFavorite) {
|
||||||
favorites = (context.applicationContext as Pupil).favorites
|
|
||||||
|
|
||||||
with(galleryblock_favorite) {
|
|
||||||
setImageResource(if (favorites.contains(galleryBlock.id)) R.drawable.ic_star_filled else R.drawable.ic_star_empty)
|
setImageResource(if (favorites.contains(galleryBlock.id)) R.drawable.ic_star_filled else R.drawable.ic_star_empty)
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
when {
|
when {
|
||||||
@@ -262,87 +261,51 @@ class GalleryBlockAdapter(context: Context, private val galleries: List<GalleryB
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class NextViewHolder(view: LinearLayout) : RecyclerView.ViewHolder(view)
|
|
||||||
class PrevViewHolder(view: LinearLayout) : RecyclerView.ViewHolder(view)
|
|
||||||
|
|
||||||
class ViewHolderFactory {
|
|
||||||
companion object {
|
|
||||||
fun getLayoutID(type: Int): Int {
|
|
||||||
return when(ViewType.values()[type]) {
|
|
||||||
ViewType.NEXT -> R.layout.item_next
|
|
||||||
ViewType.PREV -> R.layout.item_prev
|
|
||||||
ViewType.GALLERY -> R.layout.item_galleryblock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val completeFlag = SparseBooleanArray()
|
// Make some views invisible to make it thinner
|
||||||
|
if (thin) {
|
||||||
|
binding.galleryblockTagGroup.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val onChipClickedHandler = ArrayList<((Tag) -> Unit)>()
|
val onChipClickedHandler = ArrayList<((Tag) -> Unit)>()
|
||||||
var onDownloadClickedHandler: ((Int) -> Unit)? = null
|
var onDownloadClickedHandler: ((Int) -> Unit)? = null
|
||||||
var onDeleteClickedHandler: ((Int) -> Unit)? = null
|
var onDeleteClickedHandler: ((Int) -> Unit)? = null
|
||||||
|
|
||||||
var showNext = false
|
|
||||||
var showPrev = false
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
return GalleryViewHolder(GalleryblockItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
fun getViewHolder(type: Int, view: View): RecyclerView.ViewHolder {
|
|
||||||
return when(ViewType.values()[type]) {
|
|
||||||
ViewType.NEXT -> NextViewHolder(view as LinearLayout)
|
|
||||||
ViewType.PREV -> PrevViewHolder(view as LinearLayout)
|
|
||||||
ViewType.GALLERY -> GalleryViewHolder(view as CardView)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return getViewHolder(
|
|
||||||
viewType,
|
|
||||||
LayoutInflater.from(parent.context).inflate(
|
|
||||||
ViewHolderFactory.getLayoutID(viewType),
|
|
||||||
parent,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
if (holder is GalleryViewHolder) {
|
if (holder is GalleryViewHolder) {
|
||||||
val gallery = galleries[position-(if (showPrev) 1 else 0)]
|
val galleryID = galleries[position]
|
||||||
|
|
||||||
holder.bind(gallery)
|
holder.bind(galleryID)
|
||||||
|
|
||||||
with(holder.view.galleryblock_primary) {
|
holder.binding.galleryblockCard.binding.download.setOnClickListener {
|
||||||
setOnClickListener {
|
|
||||||
holder.view.performClick()
|
|
||||||
}
|
|
||||||
setOnLongClickListener {
|
|
||||||
holder.view.performLongClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
holder.view.galleryblock_download.setOnClickListener {
|
|
||||||
onDownloadClickedHandler?.invoke(position)
|
onDownloadClickedHandler?.invoke(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
holder.view.galleryblock_delete.setOnClickListener {
|
holder.binding.galleryblockCard.binding.delete.setOnClickListener {
|
||||||
onDeleteClickedHandler?.invoke(position)
|
onDeleteClickedHandler?.invoke(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
mItemManger.bindView(holder.view, position)
|
mItemManger.bindView(holder.binding.root, position)
|
||||||
|
|
||||||
holder.view.galleryblock_swipe_layout.addSwipeListener(object: SwipeLayout.SwipeListener {
|
holder.binding.galleryblockCard.binding.swipeLayout.addSwipeListener(object: SwipeLayout.SwipeListener {
|
||||||
override fun onStartOpen(layout: SwipeLayout?) {
|
override fun onStartOpen(layout: SwipeLayout?) {
|
||||||
mItemManger.closeAllExcept(layout)
|
mItemManger.closeAllExcept(layout)
|
||||||
|
|
||||||
holder.view.galleryblock_download.text =
|
holder.binding.galleryblockCard.binding.download.text =
|
||||||
if (DownloadWorker.getInstance(holder.view.context).progress.indexOfKey(gallery.id) < 0)
|
if (DownloadManager.getInstance(holder.binding.root.context).isDownloading(galleryID))
|
||||||
holder.view.context.getString(R.string.main_download)
|
holder.binding.root.context.getString(android.R.string.cancel)
|
||||||
else
|
else
|
||||||
holder.view.context.getString(android.R.string.cancel)
|
holder.binding.root.context.getString(R.string.main_download)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClose(layout: SwipeLayout?) {}
|
override fun onClose(layout: SwipeLayout?) {}
|
||||||
@@ -354,27 +317,7 @@ class GalleryBlockAdapter(context: Context, private val galleries: List<GalleryB
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
|
override fun getItemCount() = galleries.size
|
||||||
super.onViewDetachedFromWindow(holder)
|
|
||||||
|
|
||||||
if (holder is GalleryViewHolder) {
|
override fun getSwipeLayoutResourceId(position: Int) = R.id.swipe_layout
|
||||||
holder.timerTask?.cancel()
|
|
||||||
holder.timerTask = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount() =
|
|
||||||
(if (galleries.isEmpty()) 0 else galleries.size)+
|
|
||||||
(if (showNext) 1 else 0)+
|
|
||||||
(if (showPrev) 1 else 0)
|
|
||||||
|
|
||||||
override fun getItemViewType(position: Int): Int {
|
|
||||||
return when {
|
|
||||||
showPrev && position == 0 -> ViewType.PREV
|
|
||||||
showNext && position == galleries.size+(if (showPrev) 1 else 0) -> ViewType.NEXT
|
|
||||||
else -> ViewType.GALLERY
|
|
||||||
}.ordinal
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSwipeLayoutResourceId(position: Int) = R.id.galleryblock_swipe_layout
|
|
||||||
}
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pupil, Hitomi.la viewer for Android
|
|
||||||
* Copyright (C) 2020 tom5079
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.adapters
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.MotionEvent
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import kotlinx.android.synthetic.main.item_mirrors.view.*
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class MirrorAdapter(context: Context) : RecyclerView.Adapter<MirrorAdapter.ViewHolder>() {
|
|
||||||
|
|
||||||
class ViewHolder(val view: View) : RecyclerView.ViewHolder(view)
|
|
||||||
|
|
||||||
val mirrors = context.resources.getStringArray(R.array.mirrors).map {
|
|
||||||
it.split('|').let { split ->
|
|
||||||
Pair(split.first(), split.last())
|
|
||||||
}
|
|
||||||
}.toMap()
|
|
||||||
|
|
||||||
val list = mirrors.keys.toMutableList().apply {
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
.getString("mirrors", "")!!
|
|
||||||
.split(">")
|
|
||||||
.reversed()
|
|
||||||
.forEach {
|
|
||||||
if (this.contains(it)) {
|
|
||||||
this.remove(it)
|
|
||||||
this.add(0, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val onItemMove : ((Int, Int) -> Unit) = { from, to ->
|
|
||||||
Collections.swap(list, from, to)
|
|
||||||
notifyItemMoved(from, to)
|
|
||||||
onItemMoved?.invoke(list)
|
|
||||||
}
|
|
||||||
var onStartDrag : ((ViewHolder) -> Unit)? = null
|
|
||||||
var onItemMoved : ((List<String>) -> (Unit))? = null
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
|
||||||
with(holder.view) {
|
|
||||||
mirror_name.text = mirrors[list.elementAt(position)]
|
|
||||||
mirror_button.setOnTouchListener { _, event ->
|
|
||||||
if (event.action == MotionEvent.ACTION_DOWN)
|
|
||||||
onStartDrag?.invoke(holder)
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
|
||||||
return LayoutInflater.from(parent.context).inflate(
|
|
||||||
R.layout.item_mirrors, parent, false
|
|
||||||
).let {
|
|
||||||
ViewHolder(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount() = mirrors.size
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -19,124 +19,232 @@
|
|||||||
package xyz.quaver.pupil.adapters
|
package xyz.quaver.pupil.adapters
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.net.Uri
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.bumptech.glide.Glide
|
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.facebook.drawee.backends.pipeline.Fresco
|
||||||
import com.crashlytics.android.Crashlytics
|
import com.facebook.drawee.controller.BaseControllerListener
|
||||||
import io.fabric.sdk.android.Fabric
|
import com.facebook.drawee.drawable.ScalingUtils
|
||||||
import kotlinx.android.synthetic.main.item_reader.view.*
|
import com.facebook.drawee.interfaces.DraweeController
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import com.facebook.drawee.view.SimpleDraweeView
|
||||||
import kotlinx.coroutines.Dispatchers
|
import com.facebook.imagepipeline.image.ImageInfo
|
||||||
import kotlinx.coroutines.launch
|
import com.github.piasy.biv.view.BigImageView
|
||||||
import xyz.quaver.hitomi.Reader
|
import com.github.piasy.biv.view.ImageShownCallback
|
||||||
import xyz.quaver.pupil.BuildConfig
|
import com.github.piasy.biv.view.ImageViewFactory
|
||||||
|
import kotlinx.coroutines.*
|
||||||
import xyz.quaver.pupil.R
|
import xyz.quaver.pupil.R
|
||||||
import xyz.quaver.pupil.util.download.Cache
|
import xyz.quaver.pupil.databinding.ReaderItemBinding
|
||||||
import xyz.quaver.pupil.util.download.DownloadWorker
|
import xyz.quaver.pupil.hitomi.GalleryInfo
|
||||||
import java.util.*
|
import xyz.quaver.pupil.ui.ReaderActivity
|
||||||
import kotlin.concurrent.schedule
|
import xyz.quaver.pupil.util.downloader.Cache
|
||||||
|
import java.io.File
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class ReaderAdapter(private val context: Context,
|
class ReaderAdapter(
|
||||||
private val galleryID: Int) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() {
|
private val activity: ReaderActivity,
|
||||||
|
private val galleryID: Int
|
||||||
|
) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() {
|
||||||
|
var galleryInfo: GalleryInfo? = null
|
||||||
|
|
||||||
var isFullScreen = false
|
var isFullScreen = false
|
||||||
|
|
||||||
var reader: Reader? = null
|
var onItemClickListener : (() -> (Unit))? = null
|
||||||
private val glide = Glide.with(context)
|
|
||||||
val timer = Timer()
|
|
||||||
|
|
||||||
var onItemClickListener : ((Int) -> (Unit))? = null
|
|
||||||
|
|
||||||
|
inner class ViewHolder(private val binding: ReaderItemBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
init {
|
init {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
with (binding.image) {
|
||||||
reader = Cache(context).getReader(galleryID)
|
setImageViewFactory(FrescoImageViewFactory().apply {
|
||||||
launch(Dispatchers.Main) {
|
updateView = { imageInfo ->
|
||||||
notifyDataSetChanged()
|
binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||||
|
dimensionRatio = "${imageInfo.width}:${imageInfo.height}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setImageShownCallback(object : ImageShownCallback {
|
||||||
|
override fun onMainImageShown() {
|
||||||
|
binding.image.mainView.let { v ->
|
||||||
|
when (v) {
|
||||||
|
is SubsamplingScaleImageView ->
|
||||||
|
if (!isFullScreen) binding.image.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ViewHolder(val view: View) : RecyclerView.ViewHolder(view)
|
override fun onThumbnailShown() {}
|
||||||
|
})
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
setFailureImage(ContextCompat.getDrawable(itemView.context, R.drawable.image_broken_variant))
|
||||||
return LayoutInflater.from(parent.context).inflate(
|
setOnClickListener {
|
||||||
R.layout.item_reader, parent, false
|
onItemClickListener?.invoke()
|
||||||
).let {
|
|
||||||
ViewHolder(it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
binding.root.setOnClickListener {
|
||||||
holder.view as ConstraintLayout
|
onItemClickListener?.invoke()
|
||||||
|
}
|
||||||
if (isFullScreen)
|
|
||||||
holder.view.layoutParams.height = RecyclerView.LayoutParams.MATCH_PARENT
|
|
||||||
else
|
|
||||||
holder.view.layoutParams.height = RecyclerView.LayoutParams.WRAP_CONTENT
|
|
||||||
|
|
||||||
holder.view.image.setOnPhotoTapListener { _, _, _ ->
|
|
||||||
onItemClickListener?.invoke(position)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
holder.view.container.setOnClickListener {
|
fun bind(position: Int) {
|
||||||
onItemClickListener?.invoke(position)
|
if (cache == null)
|
||||||
|
cache = Cache.getInstance(itemView.context, galleryID)
|
||||||
|
|
||||||
|
if (!isFullScreen) {
|
||||||
|
binding.root.setBackgroundResource(R.drawable.reader_item_boundary)
|
||||||
|
binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||||
|
height = 0
|
||||||
|
dimensionRatio =
|
||||||
|
"${galleryInfo!!.files[position].width}:${galleryInfo!!.files[position].height}"
|
||||||
}
|
}
|
||||||
|
|
||||||
(holder.view.container.layoutParams as ConstraintLayout.LayoutParams)
|
|
||||||
.dimensionRatio = "${reader!!.galleryInfo[position].width}:${reader!!.galleryInfo[position].height}"
|
|
||||||
|
|
||||||
holder.view.reader_index.text = (position+1).toString()
|
|
||||||
|
|
||||||
val images = Cache(context).getImages(galleryID)
|
|
||||||
|
|
||||||
if (images?.get(position) != null) {
|
|
||||||
glide
|
|
||||||
.load(images[position]?.uri)
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
||||||
.skipMemoryCache(true)
|
|
||||||
.error(R.drawable.image_broken_variant)
|
|
||||||
.apply {
|
|
||||||
if (BuildConfig.CENSOR)
|
|
||||||
override(5, 8)
|
|
||||||
}
|
|
||||||
.into(holder.view.image)
|
|
||||||
} else {
|
} else {
|
||||||
val progress = DownloadWorker.getInstance(context).progress[galleryID]?.get(position)
|
binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||||
if (progress?.isNaN() == true) {
|
height = ConstraintLayout.LayoutParams.MATCH_PARENT
|
||||||
|
dimensionRatio = null
|
||||||
if (Fabric.isInitialized())
|
}
|
||||||
Crashlytics.logException(DownloadWorker.getInstance(context).exception[galleryID]?.get(position))
|
binding.root.background = null
|
||||||
|
|
||||||
glide
|
|
||||||
.load(R.drawable.image_broken_variant)
|
|
||||||
.into(holder.view.image)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
holder.view.reader_item_progressbar.progress =
|
binding.readerIndex.text = (position+1).toString()
|
||||||
|
|
||||||
|
val image = cache!!.getImage(position)
|
||||||
|
val progress = activity.downloader?.progress?.get(galleryID)?.get(position)
|
||||||
|
|
||||||
|
if (progress?.isInfinite() == true && image != null) {
|
||||||
|
binding.progressGroup.visibility = View.INVISIBLE
|
||||||
|
binding.image.showImage(image.uri)
|
||||||
|
} else {
|
||||||
|
binding.progressGroup.visibility = View.VISIBLE
|
||||||
|
binding.readerItemProgressbar.progress =
|
||||||
if (progress?.isInfinite() == true)
|
if (progress?.isInfinite() == true)
|
||||||
100
|
100
|
||||||
else
|
else
|
||||||
progress?.roundToInt() ?: 0
|
progress?.roundToInt() ?: 0
|
||||||
|
|
||||||
holder.view.image.setImageDrawable(null)
|
clear()
|
||||||
|
|
||||||
|
|
||||||
timer.schedule(1000) {
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
delay(1000)
|
||||||
notifyItemChanged(position)
|
notifyItemChanged(position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
binding.image.mainView.let {
|
||||||
|
when (it) {
|
||||||
|
is SubsamplingScaleImageView ->
|
||||||
|
it.recycle()
|
||||||
|
is SimpleDraweeView ->
|
||||||
|
it.controller = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount() = reader?.galleryInfo?.size ?: 0
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
return ViewHolder(ReaderItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cache: Cache? = null
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
holder.bind(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = galleryInfo?.files?.size ?: 0
|
||||||
|
|
||||||
|
override fun onViewRecycled(holder: ViewHolder) {
|
||||||
|
holder.clear()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FrescoImageViewFactory : ImageViewFactory() {
|
||||||
|
var updateView: ((ImageInfo) -> Unit)? = null
|
||||||
|
|
||||||
|
override fun createAnimatedImageView(
|
||||||
|
context: Context, imageType: Int,
|
||||||
|
initScaleType: Int
|
||||||
|
): View {
|
||||||
|
val view = SimpleDraweeView(context)
|
||||||
|
view.hierarchy.actualImageScaleType = scaleType(initScaleType)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadAnimatedContent(
|
||||||
|
view: View, imageType: Int,
|
||||||
|
imageFile: File
|
||||||
|
) {
|
||||||
|
if (view is SimpleDraweeView) {
|
||||||
|
val controller: DraweeController = Fresco.newDraweeControllerBuilder()
|
||||||
|
.setUri(Uri.parse("file://" + imageFile.absolutePath))
|
||||||
|
.setAutoPlayAnimations(true)
|
||||||
|
.setControllerListener(object: BaseControllerListener<ImageInfo>() {
|
||||||
|
override fun onIntermediateImageSet(id: String?, imageInfo: ImageInfo?) {
|
||||||
|
imageInfo?.let { updateView?.invoke(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFinalImageSet(id: String?, imageInfo: ImageInfo?, animatable: Animatable?) {
|
||||||
|
imageInfo?.let { updateView?.invoke(it) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
view.controller = controller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createThumbnailView(
|
||||||
|
context: Context,
|
||||||
|
scaleType: ImageView.ScaleType, willLoadFromNetwork: Boolean
|
||||||
|
): View {
|
||||||
|
return if (willLoadFromNetwork) {
|
||||||
|
val thumbnailView = SimpleDraweeView(context)
|
||||||
|
thumbnailView.hierarchy.actualImageScaleType = scaleType(scaleType)
|
||||||
|
thumbnailView
|
||||||
|
} else {
|
||||||
|
super.createThumbnailView(context, scaleType, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadThumbnailContent(view: View, thumbnail: Uri) {
|
||||||
|
if (view is SimpleDraweeView) {
|
||||||
|
val controller: DraweeController = Fresco.newDraweeControllerBuilder()
|
||||||
|
.setUri(thumbnail)
|
||||||
|
.build()
|
||||||
|
view.controller = controller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scaleType(value: Int): ScalingUtils.ScaleType {
|
||||||
|
return when (value) {
|
||||||
|
BigImageView.INIT_SCALE_TYPE_CENTER -> ScalingUtils.ScaleType.CENTER
|
||||||
|
BigImageView.INIT_SCALE_TYPE_CENTER_CROP -> ScalingUtils.ScaleType.CENTER_CROP
|
||||||
|
BigImageView.INIT_SCALE_TYPE_CENTER_INSIDE -> ScalingUtils.ScaleType.CENTER_INSIDE
|
||||||
|
BigImageView.INIT_SCALE_TYPE_FIT_END -> ScalingUtils.ScaleType.FIT_END
|
||||||
|
BigImageView.INIT_SCALE_TYPE_FIT_START -> ScalingUtils.ScaleType.FIT_START
|
||||||
|
BigImageView.INIT_SCALE_TYPE_FIT_XY -> ScalingUtils.ScaleType.FIT_XY
|
||||||
|
BigImageView.INIT_SCALE_TYPE_FIT_CENTER -> ScalingUtils.ScaleType.FIT_CENTER
|
||||||
|
else -> ScalingUtils.ScaleType.FIT_CENTER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scaleType(scaleType: ImageView.ScaleType): ScalingUtils.ScaleType {
|
||||||
|
return when (scaleType) {
|
||||||
|
ImageView.ScaleType.CENTER -> ScalingUtils.ScaleType.CENTER
|
||||||
|
ImageView.ScaleType.CENTER_CROP -> ScalingUtils.ScaleType.CENTER_CROP
|
||||||
|
ImageView.ScaleType.CENTER_INSIDE -> ScalingUtils.ScaleType.CENTER_INSIDE
|
||||||
|
ImageView.ScaleType.FIT_END -> ScalingUtils.ScaleType.FIT_END
|
||||||
|
ImageView.ScaleType.FIT_START -> ScalingUtils.ScaleType.FIT_START
|
||||||
|
ImageView.ScaleType.FIT_XY -> ScalingUtils.ScaleType.FIT_XY
|
||||||
|
ImageView.ScaleType.FIT_CENTER -> ScalingUtils.ScaleType.FIT_CENTER
|
||||||
|
else -> ScalingUtils.ScaleType.FIT_CENTER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,30 +18,35 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.adapters
|
package xyz.quaver.pupil.adapters
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.bumptech.glide.RequestManager
|
import com.github.piasy.biv.view.BigImageView
|
||||||
import xyz.quaver.pupil.BuildConfig
|
import xyz.quaver.pupil.R
|
||||||
|
|
||||||
class ThumbnailAdapter(private val glide: RequestManager, private val thumbnails: List<String>) : RecyclerView.Adapter<ThumbnailAdapter.ViewHolder>() {
|
class ThumbnailAdapter(var thumbnails: List<String>) : RecyclerView.Adapter<ThumbnailAdapter.ViewHolder>() {
|
||||||
|
|
||||||
class ViewHolder(val view: ImageView) : RecyclerView.ViewHolder(view)
|
class ViewHolder(val view: BigImageView) : RecyclerView.ViewHolder(view) {
|
||||||
|
fun clear() {
|
||||||
|
view.ssiv?.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
return ViewHolder(ImageView(parent.context))
|
return ViewHolder(BigImageView(parent.context).apply {
|
||||||
|
setFailureImage(ContextCompat.getDrawable(context, R.drawable.image_broken_variant))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
glide
|
holder.view.showImage(Uri.parse(thumbnails[position]))
|
||||||
.load(thumbnails[position])
|
|
||||||
.apply {
|
|
||||||
if (BuildConfig.CENSOR)
|
|
||||||
override(5, 8)
|
|
||||||
}
|
|
||||||
.into(holder.view)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount() = thumbnails.size
|
override fun getItemCount() = thumbnails.size
|
||||||
|
|
||||||
|
override fun onViewRecycled(holder: ViewHolder) {
|
||||||
|
holder.clear()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.adapters
|
||||||
|
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
class ThumbnailPageAdapter(private val thumbnails: List<String>) : RecyclerView.Adapter<ThumbnailPageAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
class ViewHolder(val view: RecyclerView) : RecyclerView.ViewHolder(view)
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
return ViewHolder(RecyclerView(parent.context).apply {
|
||||||
|
val layoutManager = GridLayoutManager(parent.context, 3)
|
||||||
|
val adapter = ThumbnailAdapter(listOf())
|
||||||
|
|
||||||
|
this.layoutManager = layoutManager
|
||||||
|
this.adapter = adapter
|
||||||
|
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
(holder.view.adapter as ThumbnailAdapter).apply {
|
||||||
|
thumbnails = this@ThumbnailPageAdapter.thumbnails.slice(9*position until min(9*position+9, this@ThumbnailPageAdapter.thumbnails.size))
|
||||||
|
notifyDataSetChanged()
|
||||||
|
|
||||||
|
(holder.view.layoutManager as GridLayoutManager).scrollToPosition(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = if (thumbnails.isEmpty()) 0 else thumbnails.size/9 + if (thumbnails.size%9 != 0) 1 else 0
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package xyz.quaver.pupil.adapters
|
||||||
|
|
||||||
|
import android.net.wifi.p2p.WifiP2pDevice
|
||||||
|
import android.net.wifi.p2p.WifiP2pDeviceList
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.BaseAdapter
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.databinding.TransferPeerListItemBinding
|
||||||
|
|
||||||
|
class TransferPeersAdapter(
|
||||||
|
private val devices: Collection<WifiP2pDevice>,
|
||||||
|
private val onDeviceSelected: (WifiP2pDevice) -> Unit
|
||||||
|
): RecyclerView.Adapter<TransferPeersAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
class ViewHolder(val binding: TransferPeerListItemBinding): RecyclerView.ViewHolder(binding.root)
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val binding = TransferPeerListItemBinding.inflate(
|
||||||
|
LayoutInflater.from(parent.context),
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
return ViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
val device = devices.elementAt(position)
|
||||||
|
|
||||||
|
holder.binding.deviceName.text = device.deviceName
|
||||||
|
holder.binding.deviceAddress.text = device.deviceAddress
|
||||||
|
|
||||||
|
holder.binding.root.setOnClickListener {
|
||||||
|
onDeviceSelected(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int {
|
||||||
|
return devices.size
|
||||||
|
}
|
||||||
|
}
|
||||||
273
app/src/main/java/xyz/quaver/pupil/hitomi/common.kt
Normal file
273
app/src/main/java/xyz/quaver/pupil/hitomi/common.kt
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2019 tom5079
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.hitomi
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.datetime.Clock.System.now
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import xyz.quaver.pupil.client
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
|
const val protocol = "https:"
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Artist(
|
||||||
|
val artist: String,
|
||||||
|
val url: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Group(
|
||||||
|
val group: String,
|
||||||
|
val url: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Parody(
|
||||||
|
val parody: String,
|
||||||
|
val url: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Character(
|
||||||
|
val character: String,
|
||||||
|
val url: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Tag(
|
||||||
|
val tag: String,
|
||||||
|
val url: String,
|
||||||
|
val female: String? = null,
|
||||||
|
val male: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Language(
|
||||||
|
val galleryid: String,
|
||||||
|
val url: String,
|
||||||
|
val language_localname: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GalleryInfo(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val japanese_title: String? = null,
|
||||||
|
val language: String? = null,
|
||||||
|
val type: String,
|
||||||
|
val date: String,
|
||||||
|
val artists: List<Artist>? = null,
|
||||||
|
val groups: List<Group>? = null,
|
||||||
|
val parodys: List<Parody>? = null,
|
||||||
|
val tags: List<Tag>? = null,
|
||||||
|
val related: List<Int> = emptyList(),
|
||||||
|
val languages: List<Language> = emptyList(),
|
||||||
|
val characters: List<Character>? = null,
|
||||||
|
val scene_indexes: List<Int>? = emptyList(),
|
||||||
|
val files: List<GalleryFiles> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = Json {
|
||||||
|
isLenient = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
allowSpecialFloatingPointValues = true
|
||||||
|
useArrayPolymorphism = true
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias HeaderSetter = (Request.Builder) -> Request.Builder
|
||||||
|
fun URL.readText(settings: HeaderSetter? = null): String {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(this).let {
|
||||||
|
settings?.invoke(it) ?: it
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return client.newCall(request).execute().also{ if (it.code() != 200) throw IOException("CODE ${it.code()}") }.body()?.use { it.string() } ?: throw IOException()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun URL.readBytes(settings: HeaderSetter? = null): ByteArray {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(this).let {
|
||||||
|
settings?.invoke(it) ?: it
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return client.newCall(request).execute().also { if (it.code() != 200) throw IOException("CODE ${it.code()}") }.body()?.use { it.bytes() } ?: throw IOException()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("EXPERIMENTAL_API_USAGE")
|
||||||
|
fun getGalleryInfo(galleryID: Int) =
|
||||||
|
json.decodeFromString<GalleryInfo>(
|
||||||
|
URL("$protocol//$domain/galleries/$galleryID.js").readText()
|
||||||
|
.replace("var galleryinfo = ", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
//common.js
|
||||||
|
const val domain = "ltn.hitomi.la"
|
||||||
|
const val galleryblockextension = ".html"
|
||||||
|
const val galleryblockdir = "galleryblock"
|
||||||
|
const val nozomiextension = ".nozomi"
|
||||||
|
|
||||||
|
val evaluationContext = Dispatchers.Main + Job()
|
||||||
|
|
||||||
|
object gg {
|
||||||
|
private var lastRetrieval: Long? = null
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private var mDefault = 0
|
||||||
|
private val mMap = mutableMapOf<Int, Int>()
|
||||||
|
|
||||||
|
private var b = ""
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class, ExperimentalCoroutinesApi::class)
|
||||||
|
private suspend fun refresh() = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
if (lastRetrieval == null || (lastRetrieval!! + 60000) < System.currentTimeMillis()) {
|
||||||
|
val ggjs: String = suspendCancellableCoroutine { continuation ->
|
||||||
|
val call = client.newCall(Request.Builder().url("https://ltn.hitomi.la/gg.js").build())
|
||||||
|
|
||||||
|
call.enqueue(object: Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
if (continuation.isCancelled) return
|
||||||
|
continuation.resumeWithException(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
if (!call.isCanceled) {
|
||||||
|
response.body()?.use {
|
||||||
|
continuation.resume(it.string()) {
|
||||||
|
call.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
call.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mDefault = Regex("var o = (\\d)").find(ggjs)!!.groupValues[1].toInt()
|
||||||
|
val o = Regex("o = (\\d); break;").find(ggjs)!!.groupValues[1].toInt()
|
||||||
|
|
||||||
|
mMap.clear()
|
||||||
|
Regex("case (\\d+):").findAll(ggjs).forEach {
|
||||||
|
val case = it.groupValues[1].toInt()
|
||||||
|
mMap[case] = o
|
||||||
|
}
|
||||||
|
|
||||||
|
b = Regex("b: '(.+)'").find(ggjs)!!.groupValues[1]
|
||||||
|
|
||||||
|
lastRetrieval = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun m(g: Int): Int {
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
return mMap[g] ?: mDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun b(): String {
|
||||||
|
refresh()
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
fun s(h: String): String {
|
||||||
|
val m = Regex("(..)(.)$").find(h)
|
||||||
|
return m!!.groupValues.let { it[2]+it[1] }.toInt(16).toString(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun subdomainFromURL(url: String, base: String? = null) : String {
|
||||||
|
var retval = "b"
|
||||||
|
|
||||||
|
if (!base.isNullOrBlank())
|
||||||
|
retval = base
|
||||||
|
|
||||||
|
val b = 16
|
||||||
|
|
||||||
|
val r = Regex("""/[0-9a-f]{61}([0-9a-f]{2})([0-9a-f])""")
|
||||||
|
val m = r.find(url) ?: return "a"
|
||||||
|
|
||||||
|
val g = m.groupValues.let { it[2]+it[1] }.toIntOrNull(b)
|
||||||
|
|
||||||
|
if (g != null) {
|
||||||
|
retval = (97+ gg.m(g)).toChar().toString() + retval
|
||||||
|
}
|
||||||
|
|
||||||
|
return retval
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun urlFromUrl(url: String, base: String? = null) : String {
|
||||||
|
return url.replace(Regex("""//..?\.hitomi\.la/"""), "//${subdomainFromURL(url, base)}.hitomi.la/")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fullPathFromHash(hash: String) : String =
|
||||||
|
"${gg.b()}${gg.s(hash)}/$hash"
|
||||||
|
|
||||||
|
fun realFullPathFromHash(hash: String): String =
|
||||||
|
hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1/$hash")
|
||||||
|
|
||||||
|
suspend fun urlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null) : String {
|
||||||
|
val ext = ext ?: dir ?: image.name.takeLastWhile { it != '.' }
|
||||||
|
val dir = dir ?: "images"
|
||||||
|
return "https://a.hitomi.la/$dir/${fullPathFromHash(image.hash)}.$ext"
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun urlFromUrlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null, base: String? = null) =
|
||||||
|
if (base == "tn")
|
||||||
|
urlFromUrl("https://a.hitomi.la/$dir/${realFullPathFromHash(image.hash)}.$ext", base)
|
||||||
|
else
|
||||||
|
urlFromUrl(urlFromHash(galleryID, image, dir, ext), base)
|
||||||
|
|
||||||
|
suspend fun rewriteTnPaths(html: String) {
|
||||||
|
html.replace(Regex("""//tn\.hitomi\.la/[^/]+/[0-9a-f]/[0-9a-f]{2}/[0-9a-f]{64}""")) { url ->
|
||||||
|
runBlocking {
|
||||||
|
urlFromUrl(url.value, "tn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean) : String {
|
||||||
|
return urlFromUrlFromHash(galleryID, image, "webp", null, "a")
|
||||||
|
// return when {
|
||||||
|
// noWebp ->
|
||||||
|
// urlFromUrlFromHash(galleryID, image)
|
||||||
|
//// image.hasavif != 0 ->
|
||||||
|
//// urlFromUrlFromHash(galleryID, image, "avif", null, "a")
|
||||||
|
// image.haswebp != 0 ->
|
||||||
|
// urlFromUrlFromHash(galleryID, image, "webp", null, "a")
|
||||||
|
// else ->
|
||||||
|
// urlFromUrlFromHash(galleryID, image)
|
||||||
|
// }
|
||||||
|
}
|
||||||
54
app/src/main/java/xyz/quaver/pupil/hitomi/galleries.kt
Normal file
54
app/src/main/java/xyz/quaver/pupil/hitomi/galleries.kt
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2019 tom5079
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.hitomi
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Gallery(
|
||||||
|
val related: List<Int>,
|
||||||
|
val langList: List<Pair<String, String>>,
|
||||||
|
val cover: String,
|
||||||
|
val title: String,
|
||||||
|
val artists: List<String>,
|
||||||
|
val groups: List<String>,
|
||||||
|
val type: String,
|
||||||
|
val language: String,
|
||||||
|
val series: List<String>,
|
||||||
|
val characters: List<String>,
|
||||||
|
val tags: List<String>,
|
||||||
|
val thumbnails: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun getGallery(galleryID: Int) : Gallery {
|
||||||
|
val info = getGalleryInfo(galleryID)
|
||||||
|
|
||||||
|
return Gallery(
|
||||||
|
info.related,
|
||||||
|
info.languages.map { it.name to it.galleryid },
|
||||||
|
urlFromUrlFromHash(galleryID, info.files.first(), "webpbigtn", "webp", "tn"),
|
||||||
|
info.title,
|
||||||
|
info.artists?.map { it.artist }.orEmpty(),
|
||||||
|
info.groups?.map { it.group }.orEmpty(),
|
||||||
|
info.type,
|
||||||
|
info.language.orEmpty(),
|
||||||
|
info.parodys?.map { it.parody }.orEmpty(),
|
||||||
|
info.characters?.map { it.character }.orEmpty(),
|
||||||
|
info.tags?.map { "${if (it.female.isNullOrEmpty()) "" else "female:"}${if (it.male.isNullOrEmpty()) "" else "male:"}${it.tag}" }.orEmpty(),
|
||||||
|
info.files.map { urlFromUrlFromHash(galleryID, it, "webpsmalltn", "webp", "tn") }
|
||||||
|
)
|
||||||
|
}
|
||||||
92
app/src/main/java/xyz/quaver/pupil/hitomi/galleryblock.kt
Normal file
92
app/src/main/java/xyz/quaver/pupil/hitomi/galleryblock.kt
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2019 tom5079
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.hitomi
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import kotlin.io.readText
|
||||||
|
|
||||||
|
//galleryblock.js
|
||||||
|
fun fetchNozomi(area: String? = null, tag: String = "index", language: String = "all", start: Int = -1, count: Int = -1) : Pair<List<Int>, Int> {
|
||||||
|
val url = when(area) {
|
||||||
|
null -> "$protocol//$domain/$tag-$language$nozomiextension"
|
||||||
|
else -> "$protocol//$domain/$area/$tag-$language$nozomiextension"
|
||||||
|
}
|
||||||
|
|
||||||
|
with(URL(url).openConnection() as HttpsURLConnection) {
|
||||||
|
requestMethod = "GET"
|
||||||
|
|
||||||
|
if (start != -1 && count != -1) {
|
||||||
|
val startByte = start*4
|
||||||
|
val endByte = (start+count)*4-1
|
||||||
|
|
||||||
|
setRequestProperty("Range", "bytes=$startByte-$endByte")
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
val totalItems = getHeaderField("Content-Range")
|
||||||
|
.replace(Regex("^[Bb]ytes \\d+-\\d+/"), "").toInt() / 4
|
||||||
|
|
||||||
|
val nozomi = ArrayList<Int>()
|
||||||
|
|
||||||
|
val arrayBuffer = ByteBuffer
|
||||||
|
.wrap(inputStream.readBytes())
|
||||||
|
.order(ByteOrder.BIG_ENDIAN)
|
||||||
|
|
||||||
|
while (arrayBuffer.hasRemaining())
|
||||||
|
nozomi.add(arrayBuffer.int)
|
||||||
|
|
||||||
|
return Pair(nozomi, totalItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GalleryBlock(
|
||||||
|
val id: Int,
|
||||||
|
val galleryUrl: String,
|
||||||
|
val thumbnails: List<String>,
|
||||||
|
val title: String,
|
||||||
|
val artists: List<String>,
|
||||||
|
val series: List<String>,
|
||||||
|
val type: String,
|
||||||
|
val language: String,
|
||||||
|
val relatedTags: List<String>,
|
||||||
|
val groups: List<String> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun getGalleryBlock(galleryID: Int) : GalleryBlock {
|
||||||
|
val info = getGalleryInfo(galleryID)
|
||||||
|
|
||||||
|
return GalleryBlock(
|
||||||
|
galleryID,
|
||||||
|
"",
|
||||||
|
listOf(urlFromUrlFromHash(galleryID, info.files.first(), "webpbigtn", "webp", "tn")),
|
||||||
|
info.title,
|
||||||
|
info.artists?.map { it.artist }.orEmpty(),
|
||||||
|
info.parodys?.map { it.parody }.orEmpty(),
|
||||||
|
info.type,
|
||||||
|
info.language.orEmpty(),
|
||||||
|
info.tags?.map { "${if (it.female.isNullOrEmpty()) "" else "female:"}${if (it.male.isNullOrEmpty()) "" else "male:"}${it.tag}" }.orEmpty(),
|
||||||
|
info.groups?.map { it.group }.orEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,31 +14,25 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package xyz.quaver.hitomi
|
package xyz.quaver.pupil.hitomi
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.jsoup.Jsoup
|
import xyz.quaver.pupil.hitomi.GalleryInfo
|
||||||
import xyz.quaver.Code
|
import xyz.quaver.pupil.hitomi.getGalleryInfo
|
||||||
|
|
||||||
fun getReferer(galleryID: Int) = "https://hitomi.la/reader/$galleryID.html"
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GalleryInfo(
|
data class GalleryFiles(
|
||||||
val width: Int,
|
val width: Int,
|
||||||
val hash: String? = null,
|
val hash: String,
|
||||||
val haswebp: Int = 0,
|
val haswebp: Int = 0,
|
||||||
val name: String,
|
val name: String,
|
||||||
val height: Int
|
val height: Int,
|
||||||
|
val hasavif: Int = 0,
|
||||||
|
val hasavifsmalltn: Int? = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Reader(val code: Code, val title: String, val galleryInfo: List<GalleryInfo>)
|
|
||||||
|
|
||||||
//Set header `Referer` to reader url to avoid 403 error
|
//Set header `Referer` to reader url to avoid 403 error
|
||||||
fun getReader(galleryID: Int) : Reader {
|
@Deprecated("", replaceWith = ReplaceWith("getGalleryInfo"))
|
||||||
val readerUrl = "https://hitomi.la/reader/$galleryID.html"
|
fun getReader(galleryID: Int) : GalleryInfo {
|
||||||
|
return getGalleryInfo(galleryID)
|
||||||
val doc = Jsoup.connect(readerUrl).get()
|
|
||||||
|
|
||||||
return Reader(Code.HITOMI, doc.title(), getGalleryInfo(galleryID))
|
|
||||||
}
|
}
|
||||||
@@ -14,19 +14,17 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package xyz.quaver.hitomi
|
package xyz.quaver.pupil.hitomi
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.coroutineScope
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
fun doSearch(query: String, sortByPopularity: Boolean = false) : List<Int> {
|
suspend fun doSearch(query: String, sortByPopularity: Boolean = false) : Set<Int> = coroutineScope {
|
||||||
val terms = query
|
val terms = query
|
||||||
.trim()
|
.trim()
|
||||||
.replace(Regex("""^\?"""), "")
|
.replace(Regex("""^\?"""), "")
|
||||||
.toLowerCase(Locale.US)
|
.lowercase()
|
||||||
.split(Regex("\\s+"))
|
.split(Regex("\\s+"))
|
||||||
.map {
|
.map {
|
||||||
it.replace('_', ' ')
|
it.replace('_', ' ')
|
||||||
@@ -38,42 +36,41 @@ fun doSearch(query: String, sortByPopularity: Boolean = false) : List<Int> {
|
|||||||
for (term in terms) {
|
for (term in terms) {
|
||||||
if (term.matches(Regex("^-.+")))
|
if (term.matches(Regex("^-.+")))
|
||||||
negativeTerms.push(term.replace(Regex("^-"), ""))
|
negativeTerms.push(term.replace(Regex("^-"), ""))
|
||||||
else
|
else if (term.isNotBlank())
|
||||||
positiveTerms.push(term)
|
positiveTerms.push(term)
|
||||||
}
|
}
|
||||||
|
|
||||||
val positiveResults = positiveTerms.map {
|
val positiveResults = positiveTerms.map {
|
||||||
CoroutineScope(Dispatchers.IO).async {
|
async {
|
||||||
|
runCatching {
|
||||||
getGalleryIDsForQuery(it)
|
getGalleryIDsForQuery(it)
|
||||||
|
}.getOrElse { emptySet() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val negativeResults = negativeTerms.map {
|
val negativeResults = negativeTerms.mapIndexed { index, it ->
|
||||||
CoroutineScope(Dispatchers.IO).async {
|
async {
|
||||||
|
runCatching {
|
||||||
getGalleryIDsForQuery(it)
|
getGalleryIDsForQuery(it)
|
||||||
|
}.getOrElse { emptySet() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var results = when {
|
val results = when {
|
||||||
sortByPopularity -> getGalleryIDsFromNozomi(null, "popular", "all")
|
sortByPopularity -> getGalleryIDsFromNozomi(null, "popular", "all")
|
||||||
positiveTerms.isEmpty() -> getGalleryIDsFromNozomi(null, "index", "all")
|
positiveTerms.isEmpty() -> getGalleryIDsFromNozomi(null, "index", "all")
|
||||||
else -> listOf()
|
else -> emptySet()
|
||||||
}
|
}.toMutableSet()
|
||||||
|
|
||||||
runBlocking {
|
fun filterPositive(newResults: Set<Int>) {
|
||||||
@Synchronized fun filterPositive(newResults: List<Int>) {
|
when {
|
||||||
results = when {
|
results.isEmpty() -> results.addAll(newResults)
|
||||||
results.isEmpty() -> newResults
|
else -> results.retainAll(newResults)
|
||||||
else -> newResults.sorted().let { sorted ->
|
|
||||||
results.filter { sorted.binarySearch(it) >= 0 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized fun filterNegative(newResults: List<Int>) {
|
fun filterNegative(newResults: Set<Int>) {
|
||||||
results = newResults.sorted().let { sorted ->
|
results.removeAll(newResults)
|
||||||
results.filter { sorted.binarySearch(it) < 0 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//positive results
|
//positive results
|
||||||
@@ -82,10 +79,9 @@ fun doSearch(query: String, sortByPopularity: Boolean = false) : List<Int> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//negative results
|
//negative results
|
||||||
negativeResults.forEach {
|
negativeResults.forEachIndexed { index, it ->
|
||||||
filterNegative(it.await())
|
filterNegative(it.await())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
results
|
||||||
}
|
}
|
||||||
@@ -14,13 +14,17 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package xyz.quaver.hitomi
|
package xyz.quaver.pupil.hitomi
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import okhttp3.Request
|
||||||
|
import xyz.quaver.pupil.client
|
||||||
|
import xyz.quaver.pupil.util.content
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import javax.net.ssl.HttpsURLConnection
|
import kotlin.math.min
|
||||||
|
|
||||||
//searchlib.js
|
//searchlib.js
|
||||||
const val separator = "-"
|
const val separator = "-"
|
||||||
@@ -31,14 +35,15 @@ const val max_node_size = 464
|
|||||||
const val B = 16
|
const val B = 16
|
||||||
const val compressed_nozomi_prefix = "n"
|
const val compressed_nozomi_prefix = "n"
|
||||||
|
|
||||||
var tag_index_version = getIndexVersion("tagindex")
|
val tag_index_version: String by lazy { getIndexVersion("tagindex") }
|
||||||
var galleries_index_version = getIndexVersion("galleriesindex")
|
val galleries_index_version: String by lazy { getIndexVersion("galleriesindex") }
|
||||||
|
val tagIndexDomain = "tagindex.hitomi.la"
|
||||||
|
|
||||||
fun sha256(data: ByteArray) : ByteArray {
|
fun sha256(data: ByteArray) : ByteArray {
|
||||||
return MessageDigest.getInstance("SHA-256").digest(data)
|
return MessageDigest.getInstance("SHA-256").digest(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseExperimental(ExperimentalUnsignedTypes::class)
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
fun hashTerm(term: String) : UByteArray {
|
fun hashTerm(term: String) : UByteArray {
|
||||||
return sha256(term.toByteArray()).toUByteArray().sliceArray(0 until 4)
|
return sha256(term.toByteArray()).toUByteArray().sliceArray(0 until 4)
|
||||||
}
|
}
|
||||||
@@ -47,17 +52,11 @@ fun sanitize(input: String) : String {
|
|||||||
return input.replace(Regex("[/#]"), "")
|
return input.replace(Regex("[/#]"), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getIndexVersion(name: String) : String {
|
fun getIndexVersion(name: String) =
|
||||||
return try {
|
URL("$protocol//$domain/$name/version?_=${System.currentTimeMillis()}").readText()
|
||||||
URL("$protocol//$domain/$name/version?_=${System.currentTimeMillis()}")
|
|
||||||
.readText()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//search.js
|
//search.js
|
||||||
fun getGalleryIDsForQuery(query: String) : List<Int> {
|
fun getGalleryIDsForQuery(query: String) : Set<Int> {
|
||||||
query.replace("_", " ").let {
|
query.replace("_", " ").let {
|
||||||
if (it.indexOf(':') > -1) {
|
if (it.indexOf(':') > -1) {
|
||||||
val sides = it.split(":")
|
val sides = it.split(":")
|
||||||
@@ -84,17 +83,25 @@ fun getGalleryIDsForQuery(query: String) : List<Int> {
|
|||||||
val key = hashTerm(it)
|
val key = hashTerm(it)
|
||||||
val field = "galleries"
|
val field = "galleries"
|
||||||
|
|
||||||
val node = getNodeAtAddress(field, 0) ?: return emptyList()
|
val node = getNodeAtAddress(field, 0) ?: return emptySet()
|
||||||
|
|
||||||
val data = bSearch(field, key, node)
|
val data = bSearch(field, key, node)
|
||||||
|
|
||||||
if (data != null)
|
if (data != null)
|
||||||
return getGalleryIDsFromData(data)
|
return getGalleryIDsFromData(data)
|
||||||
|
|
||||||
return emptyList()
|
return emptySet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun encodeSearchQueryForUrl(s: Char) =
|
||||||
|
when(s) {
|
||||||
|
' ' -> "_"
|
||||||
|
'/' -> "slash"
|
||||||
|
'.' -> "dot"
|
||||||
|
else -> s.toString()
|
||||||
|
}
|
||||||
|
|
||||||
fun getSuggestionsForQuery(query: String) : List<Suggestion> {
|
fun getSuggestionsForQuery(query: String) : List<Suggestion> {
|
||||||
query.replace('_', ' ').let {
|
query.replace('_', ' ').let {
|
||||||
var field = "global"
|
var field = "global"
|
||||||
@@ -106,28 +113,44 @@ fun getSuggestionsForQuery(query: String) : List<Suggestion> {
|
|||||||
term = sides[1]
|
term = sides[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
val key = hashTerm(term)
|
val chars = term.map(::encodeSearchQueryForUrl)
|
||||||
val node = getNodeAtAddress(field, 0) ?: return emptyList()
|
val url = "https://$tagIndexDomain/$field${if (chars.isNotEmpty()) "/${chars.joinToString("/")}" else ""}.json"
|
||||||
val data = bSearch(field, key, node)
|
|
||||||
|
|
||||||
if (data != null)
|
val request = Request.Builder()
|
||||||
return getSuggestionsFromData(field, data)
|
.url(url)
|
||||||
|
.build()
|
||||||
|
|
||||||
return emptyList()
|
val suggestions = json.parseToJsonElement(client.newCall(request).execute().body()?.use { body -> body.string() } ?: return emptyList())
|
||||||
|
|
||||||
|
return buildList {
|
||||||
|
suggestions.jsonArray.forEach { suggestionRaw ->
|
||||||
|
val suggestion = suggestionRaw.jsonArray
|
||||||
|
if (suggestion.size < 3) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val ns = suggestion[2].content ?: ""
|
||||||
|
|
||||||
|
val tagname = sanitize(suggestion[0].content ?: return@forEach)
|
||||||
|
val url = when(ns) {
|
||||||
|
"female", "male" -> "/tag/$ns:$tagname${separator}1$extension"
|
||||||
|
"language" -> "/index-$tagname${separator}1$extension"
|
||||||
|
else -> "/$ns/$tagname${separator}all${separator}1$extension"
|
||||||
|
}
|
||||||
|
|
||||||
|
add(Suggestion(suggestion[0].content ?: "", suggestion[1].content?.toIntOrNull() ?: 0, url, ns))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Suggestion(val s: String, val t: Int, val u: String, val n: String)
|
data class Suggestion(val s: String, val t: Int, val u: String, val n: String)
|
||||||
fun getSuggestionsFromData(field: String, data: Pair<Long, Int>) : List<Suggestion> {
|
fun getSuggestionsFromData(field: String, data: Pair<Long, Int>) : List<Suggestion> {
|
||||||
if (tag_index_version.isEmpty())
|
|
||||||
tag_index_version = getIndexVersion("tagindex")
|
|
||||||
|
|
||||||
val url = "$protocol//$domain/$index_dir/$field.$tag_index_version.data"
|
val url = "$protocol//$domain/$index_dir/$field.$tag_index_version.data"
|
||||||
val (offset, length) = data
|
val (offset, length) = data
|
||||||
if (length > 10000 || length <= 0)
|
if (length > 10000 || length <= 0)
|
||||||
throw Exception("length $length is too long")
|
throw Exception("length $length is too long")
|
||||||
|
|
||||||
val inbuf = getURLAtRange(url, offset.until(offset+length)) ?: return emptyList()
|
val inbuf = getURLAtRange(url, offset.until(offset+length))
|
||||||
|
|
||||||
val suggestions = ArrayList<Suggestion>()
|
val suggestions = ArrayList<Suggestion>()
|
||||||
|
|
||||||
@@ -166,17 +189,16 @@ fun getSuggestionsFromData(field: String, data: Pair<Long, Int>) : List<Suggesti
|
|||||||
return suggestions
|
return suggestions
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : List<Int> {
|
fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : Set<Int> {
|
||||||
val nozomiAddress =
|
val nozomiAddress =
|
||||||
when(area) {
|
when(area) {
|
||||||
null -> "$protocol//$domain/$compressed_nozomi_prefix/$tag-$language$nozomiextension"
|
null -> "$protocol//$domain/$compressed_nozomi_prefix/$tag-$language$nozomiextension"
|
||||||
else -> "$protocol//$domain/$compressed_nozomi_prefix/$area/$tag-$language$nozomiextension"
|
else -> "$protocol//$domain/$compressed_nozomi_prefix/$area/$tag-$language$nozomiextension"
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
val bytes = URL(nozomiAddress).readBytes()
|
val bytes = URL(nozomiAddress).readBytes()
|
||||||
|
|
||||||
val nozomi = ArrayList<Int>()
|
val nozomi = mutableSetOf<Int>()
|
||||||
|
|
||||||
val arrayBuffer = ByteBuffer
|
val arrayBuffer = ByteBuffer
|
||||||
.wrap(bytes)
|
.wrap(bytes)
|
||||||
@@ -186,23 +208,17 @@ fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : List
|
|||||||
nozomi.add(arrayBuffer.int)
|
nozomi.add(arrayBuffer.int)
|
||||||
|
|
||||||
return nozomi
|
return nozomi
|
||||||
} catch (e: Exception) {
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getGalleryIDsFromData(data: Pair<Long, Int>) : List<Int> {
|
fun getGalleryIDsFromData(data: Pair<Long, Int>) : Set<Int> {
|
||||||
if (galleries_index_version.isEmpty())
|
|
||||||
galleries_index_version = getIndexVersion("galleriesindex")
|
|
||||||
|
|
||||||
val url = "$protocol//$domain/$galleries_index_dir/galleries.$galleries_index_version.data"
|
val url = "$protocol//$domain/$galleries_index_dir/galleries.$galleries_index_version.data"
|
||||||
val (offset, length) = data
|
val (offset, length) = data
|
||||||
if (length > 100000000 || length <= 0)
|
if (length > 100000000 || length <= 0)
|
||||||
throw Exception("length $length is too long")
|
throw Exception("length $length is too long")
|
||||||
|
|
||||||
val inbuf = getURLAtRange(url, offset.until(offset+length)) ?: return emptyList()
|
val inbuf = getURLAtRange(url, offset.until(offset+length))
|
||||||
|
|
||||||
val galleryIDs = ArrayList<Int>()
|
val galleryIDs = mutableSetOf<Int>()
|
||||||
|
|
||||||
val buffer = ByteBuffer
|
val buffer = ByteBuffer
|
||||||
.wrap(inbuf)
|
.wrap(inbuf)
|
||||||
@@ -224,39 +240,31 @@ fun getGalleryIDsFromData(data: Pair<Long, Int>) : List<Int> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getNodeAtAddress(field: String, address: Long) : Node? {
|
fun getNodeAtAddress(field: String, address: Long) : Node? {
|
||||||
if (tag_index_version.isEmpty())
|
|
||||||
tag_index_version = getIndexVersion("tagindex")
|
|
||||||
if (galleries_index_version.isEmpty())
|
|
||||||
galleries_index_version = getIndexVersion("galleriesindex")
|
|
||||||
|
|
||||||
val url =
|
val url =
|
||||||
when(field) {
|
when(field) {
|
||||||
"galleries" -> "$protocol//$domain/$galleries_index_dir/galleries.$galleries_index_version.index"
|
"galleries" -> "$protocol//$domain/$galleries_index_dir/galleries.$galleries_index_version.index"
|
||||||
|
"languages" -> "$protocol//$domain/$galleries_index_dir/languages.$galleries_index_version.index"
|
||||||
|
"nozomiurl" -> "$protocol//$domain/$galleries_index_dir/nozomiurl.$galleries_index_version.index"
|
||||||
else -> "$protocol//$domain/$index_dir/$field.$tag_index_version.index"
|
else -> "$protocol//$domain/$index_dir/$field.$tag_index_version.index"
|
||||||
}
|
}
|
||||||
|
|
||||||
val nodedata = getURLAtRange(url, address.until(address+max_node_size)) ?: return null
|
val nodedata = getURLAtRange(url, address.until(address+ max_node_size))
|
||||||
|
|
||||||
return decodeNode(nodedata)
|
return decodeNode(nodedata)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getURLAtRange(url: String, range: LongRange) : ByteArray? {
|
fun getURLAtRange(url: String, range: LongRange) : ByteArray {
|
||||||
try {
|
val request = Request.Builder()
|
||||||
with (URL(url).openConnection() as HttpsURLConnection) {
|
.url(url)
|
||||||
requestMethod = "GET"
|
.header("Range", "bytes=${range.first}-${range.last}")
|
||||||
|
.build()
|
||||||
|
|
||||||
setRequestProperty("Range", "bytes=${range.first}-${range.last}")
|
return client.newCall(request).execute().body()?.use { it.bytes() } ?: byteArrayOf()
|
||||||
|
|
||||||
return inputStream.readBytes()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseExperimental(ExperimentalUnsignedTypes::class)
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
data class Node(val keys: List<UByteArray>, val datas: List<Pair<Long, Int>>, val subNodeAddresses: List<Long>)
|
data class Node(val keys: List<UByteArray>, val datas: List<Pair<Long, Int>>, val subNodeAddresses: List<Long>)
|
||||||
@UseExperimental(ExperimentalUnsignedTypes::class)
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
fun decodeNode(data: ByteArray) : Node {
|
fun decodeNode(data: ByteArray) : Node {
|
||||||
val buffer = ByteBuffer
|
val buffer = ByteBuffer
|
||||||
.wrap(data)
|
.wrap(data)
|
||||||
@@ -298,10 +306,10 @@ fun decodeNode(data: ByteArray) : Node {
|
|||||||
return Node(keys, datas, subNodeAddresses)
|
return Node(keys, datas, subNodeAddresses)
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseExperimental(ExperimentalUnsignedTypes::class)
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
fun bSearch(field: String, key: UByteArray, node: Node) : Pair<Long, Int>? {
|
fun bSearch(field: String, key: UByteArray, node: Node) : Pair<Long, Int>? {
|
||||||
fun compareArrayBuffers(dv1: UByteArray, dv2: UByteArray) : Int {
|
fun compareArrayBuffers(dv1: UByteArray, dv2: UByteArray) : Int {
|
||||||
val top = Math.min(dv1.size, dv2.size)
|
val top = min(dv1.size, dv2.size)
|
||||||
|
|
||||||
for (i in 0.until(top)) {
|
for (i in 0.until(top)) {
|
||||||
if (dv1[i] < dv2[i])
|
if (dv1[i] < dv2[i])
|
||||||
@@ -314,7 +322,7 @@ fun bSearch(field: String, key: UByteArray, node: Node) : Pair<Long, Int>? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun locateKey(key: UByteArray, node: Node) : Pair<Boolean, Int> {
|
fun locateKey(key: UByteArray, node: Node) : Pair<Boolean, Int> {
|
||||||
for (i in 0 until node.keys.size) {
|
for (i in node.keys.indices) {
|
||||||
val cmpResult = compareArrayBuffers(key, node.keys[i])
|
val cmpResult = compareArrayBuffers(key, node.keys[i])
|
||||||
|
|
||||||
if (cmpResult <= 0)
|
if (cmpResult <= 0)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.receiver
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.util.Preferences
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class UpdateBroadcastReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
context ?: return
|
||||||
|
|
||||||
|
when (intent?.action) {
|
||||||
|
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
|
||||||
|
|
||||||
|
// Validate download
|
||||||
|
val downloadID: Long = Preferences["update_download_id"]
|
||||||
|
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
|
|
||||||
|
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2) != downloadID)
|
||||||
|
return
|
||||||
|
|
||||||
|
// Get target uri
|
||||||
|
|
||||||
|
val query = DownloadManager.Query()
|
||||||
|
.setFilterById(downloadID)
|
||||||
|
|
||||||
|
val uri = downloadManager.query(query).use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))?.let {
|
||||||
|
val uri = Uri.parse(it)
|
||||||
|
|
||||||
|
when (uri.scheme) {
|
||||||
|
"file" ->
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
||||||
|
FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!))
|
||||||
|
else
|
||||||
|
uri
|
||||||
|
"content" -> uri
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
null
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
// Build Notification
|
||||||
|
|
||||||
|
val notificationManager = NotificationManagerCompat.from(context)
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(context, System.currentTimeMillis().toInt(), Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
|
||||||
|
}, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_IMMUTABLE else 0)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, "update")
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
.setContentTitle(context.getText(R.string.update_download_completed))
|
||||||
|
.setContentText(context.getText(R.string.update_download_completed_description))
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
notificationManager.notify(R.id.notification_id_update, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package xyz.quaver.pupil.receiver
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.wifi.p2p.WifiP2pManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import xyz.quaver.pupil.ui.ErrorType
|
||||||
|
import xyz.quaver.pupil.ui.TransferStep
|
||||||
|
import xyz.quaver.pupil.ui.TransferViewModel
|
||||||
|
|
||||||
|
private inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? = when {
|
||||||
|
Build.VERSION.SDK_INT >= 33 -> getParcelableExtra(key, T::class.java)
|
||||||
|
else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T
|
||||||
|
}
|
||||||
|
|
||||||
|
class WifiDirectBroadcastReceiver(
|
||||||
|
private val manager: WifiP2pManager,
|
||||||
|
private val channel: WifiP2pManager.Channel,
|
||||||
|
private val viewModel: TransferViewModel
|
||||||
|
): BroadcastReceiver() {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
context!!
|
||||||
|
when (intent?.action) {
|
||||||
|
WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
|
||||||
|
val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)
|
||||||
|
Log.d("PUPILD", "Wifi P2P state changed: $state")
|
||||||
|
viewModel.setWifiP2pEnabled(state == WifiP2pManager.WIFI_P2P_STATE_ENABLED)
|
||||||
|
}
|
||||||
|
WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> {
|
||||||
|
Log.d("PUPILD", "Wifi P2P peers changed")
|
||||||
|
manager.requestPeers(channel) { peers ->
|
||||||
|
viewModel.setPeers(peers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
|
||||||
|
// Respond to new connection or disconnections
|
||||||
|
val networkInfo = intent.getParcelableExtraCompat<android.net.NetworkInfo>(WifiP2pManager.EXTRA_NETWORK_INFO)
|
||||||
|
|
||||||
|
Log.d("PUPILD", "Wifi P2P connection changed: $networkInfo ${networkInfo?.isConnected}")
|
||||||
|
|
||||||
|
if (networkInfo?.isConnected == true) {
|
||||||
|
manager.requestConnectionInfo(channel) { info ->
|
||||||
|
viewModel.setConnectionInfo(info)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
viewModel.setConnectionInfo(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> {
|
||||||
|
// Respond to this device's wifi state changing
|
||||||
|
Log.d("PUPILD", "Wifi P2P this device changed")
|
||||||
|
viewModel.setThisDevice(intent.getParcelableExtraCompat(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
444
app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt
Normal file
444
app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.services
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.TaskStackBuilder
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import okio.*
|
||||||
|
import xyz.quaver.pupil.*
|
||||||
|
import xyz.quaver.pupil.ui.ReaderActivity
|
||||||
|
import xyz.quaver.pupil.util.*
|
||||||
|
import xyz.quaver.pupil.util.downloader.Cache
|
||||||
|
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.log10
|
||||||
|
|
||||||
|
private typealias ProgressListener = (DownloadService.Tag, Long, Long, Boolean) -> Unit
|
||||||
|
class DownloadService : Service() {
|
||||||
|
data class Tag(val galleryID: Int, val index: Int, val startId: Int? = null)
|
||||||
|
|
||||||
|
//region Notification
|
||||||
|
private val notificationManager by lazy {
|
||||||
|
NotificationManagerCompat.from(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val serviceNotification by lazy {
|
||||||
|
NotificationCompat.Builder(this, "downloader")
|
||||||
|
.setContentTitle(getString(R.string.downloader_running))
|
||||||
|
.setProgress(0, 0, false)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setOngoing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val notification = ConcurrentHashMap<Int, NotificationCompat.Builder?>()
|
||||||
|
|
||||||
|
private fun initNotification(galleryID: Int) {
|
||||||
|
val intent = Intent(this, ReaderActivity::class.java)
|
||||||
|
.putExtra("galleryID", galleryID)
|
||||||
|
|
||||||
|
val pendingIntent = TaskStackBuilder.create(this).run {
|
||||||
|
addNextIntentWithParentStack(intent)
|
||||||
|
getPendingIntent(galleryID, PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
|
||||||
|
}
|
||||||
|
val action =
|
||||||
|
NotificationCompat.Action.Builder(0, getText(android.R.string.cancel),
|
||||||
|
PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
R.id.notification_download_cancel_action.normalizeID(),
|
||||||
|
Intent(this, DownloadService::class.java)
|
||||||
|
.putExtra(KEY_COMMAND, COMMAND_CANCEL)
|
||||||
|
.putExtra(KEY_ID, galleryID),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0),
|
||||||
|
).build()
|
||||||
|
|
||||||
|
notification[galleryID] = NotificationCompat.Builder(this, "download").apply {
|
||||||
|
setContentTitle(getString(R.string.reader_loading))
|
||||||
|
setContentText(getString(R.string.reader_notification_text))
|
||||||
|
setSmallIcon(R.drawable.ic_notification)
|
||||||
|
setContentIntent(pendingIntent)
|
||||||
|
addAction(action)
|
||||||
|
setProgress(0, 0, true)
|
||||||
|
setOngoing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(galleryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("RestrictedApi", "MissingPermission")
|
||||||
|
private fun notify(galleryID: Int) {
|
||||||
|
val max = progress[galleryID]?.size ?: 0
|
||||||
|
val progress = progress[galleryID]?.count { it == Float.POSITIVE_INFINITY } ?: 0
|
||||||
|
|
||||||
|
val notification = notification[galleryID] ?: return
|
||||||
|
|
||||||
|
if (!checkNotificationEnabled(this)) return
|
||||||
|
|
||||||
|
if (isCompleted(galleryID)) {
|
||||||
|
notification
|
||||||
|
.setContentText(getString(R.string.reader_notification_complete))
|
||||||
|
.setProgress(0, 0, false)
|
||||||
|
.setOngoing(false)
|
||||||
|
.mActions.clear()
|
||||||
|
|
||||||
|
notificationManager.cancel(galleryID)
|
||||||
|
} else
|
||||||
|
notification
|
||||||
|
.setProgress(max, progress, false)
|
||||||
|
.setContentText("$progress/$max")
|
||||||
|
|
||||||
|
if (DownloadManager.getInstance(this).getDownloadFolder(galleryID) != null || galleryID == priority)
|
||||||
|
notification.let { notificationManager.notify(galleryID, it.build()) }
|
||||||
|
else
|
||||||
|
notificationManager.cancel(galleryID)
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region ProgressListener
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private val progressListener: ProgressListener = { (galleryID, index), bytesRead, contentLength, done ->
|
||||||
|
if (!done && progress[galleryID]?.get(index)?.isFinite() == true)
|
||||||
|
progress[galleryID]?.set(index, bytesRead * 100F / contentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ProgressResponseBody(
|
||||||
|
val tag: Any?,
|
||||||
|
val responseBody: ResponseBody,
|
||||||
|
val progressListener : ProgressListener
|
||||||
|
) : ResponseBody() {
|
||||||
|
private var bufferedSource : BufferedSource? = null
|
||||||
|
|
||||||
|
override fun contentLength() = responseBody.contentLength()
|
||||||
|
override fun contentType() = responseBody.contentType()
|
||||||
|
|
||||||
|
override fun source(): BufferedSource {
|
||||||
|
if (bufferedSource == null)
|
||||||
|
bufferedSource = Okio.buffer(source(responseBody.source()))
|
||||||
|
|
||||||
|
return bufferedSource!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun source(source: Source) = object: ForwardingSource(source) {
|
||||||
|
var totalBytesRead = 0L
|
||||||
|
|
||||||
|
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||||
|
val bytesRead = super.read(sink, byteCount)
|
||||||
|
|
||||||
|
totalBytesRead += if (bytesRead == -1L) 0L else bytesRead
|
||||||
|
progressListener.invoke(tag as Tag, totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
|
||||||
|
|
||||||
|
return bytesRead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val interceptor: PupilInterceptor = { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
|
||||||
|
var response = kotlin.runCatching {
|
||||||
|
chain.proceed(request)
|
||||||
|
}.getOrNull()
|
||||||
|
var limit = 10
|
||||||
|
|
||||||
|
while (response?.isSuccessful != true) {
|
||||||
|
if (response?.code() == 503) {
|
||||||
|
Thread.sleep(200)
|
||||||
|
} else if (--limit < 0)
|
||||||
|
break
|
||||||
|
|
||||||
|
response = kotlin.runCatching {
|
||||||
|
chain.proceed(request)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response == null)
|
||||||
|
response = chain.proceed(request)
|
||||||
|
|
||||||
|
response!!.newBuilder()
|
||||||
|
.body(response.body()?.let {
|
||||||
|
ProgressResponseBody(request.tag(), it, progressListener)
|
||||||
|
}).build()
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Downloader
|
||||||
|
/**
|
||||||
|
* KEY
|
||||||
|
* primary galleryID
|
||||||
|
* secondary index
|
||||||
|
* PRIMARY VALUE
|
||||||
|
* MutableList -> Download in progress
|
||||||
|
* null -> Loading / Gallery doesn't exist
|
||||||
|
* SECONDARY VALUE
|
||||||
|
* 0 <= value < 100 -> Download in progress
|
||||||
|
* Float.POSITIVE_INFINITY -> Download completed
|
||||||
|
*/
|
||||||
|
val progress = ConcurrentHashMap<Int, MutableList<Float>>()
|
||||||
|
var priority = 0
|
||||||
|
|
||||||
|
fun isCompleted(galleryID: Int) = progress[galleryID]?.toList()?.all { it == Float.POSITIVE_INFINITY } == true
|
||||||
|
|
||||||
|
private val callback = object: Callback {
|
||||||
|
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
Log.d("PUPILD", "ONFAILURE ${call.request().tag()}, ${e}")
|
||||||
|
FirebaseCrashlytics.getInstance().recordException(e)
|
||||||
|
|
||||||
|
if (e.message?.contains("cancel", true) == false) {
|
||||||
|
val galleryID = (call.request().tag() as Tag).galleryID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
val (galleryID, index, startId) = call.request().tag() as Tag
|
||||||
|
val ext = call.request().url().encodedPath().split('.').last()
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
runCatching {
|
||||||
|
val image = response.also { if (it.code() != 200) throw IOException( "$galleryID $index ${response.request().url()} CODE ${it.code()}" ) }.body()?.use { it.bytes() } ?: throw Exception("Response null")
|
||||||
|
val padding = ceil(progress[galleryID]?.size?.let { log10(it.toFloat()) } ?: 0F).toInt()
|
||||||
|
|
||||||
|
Cache.getInstance(this@DownloadService, galleryID)
|
||||||
|
.putImage(index, "${index.toString().padStart(padding, '0')}.$ext", image)
|
||||||
|
|
||||||
|
progress[galleryID]?.set(index, Float.POSITIVE_INFINITY)
|
||||||
|
notify(galleryID)
|
||||||
|
|
||||||
|
if (isCompleted(galleryID)) {
|
||||||
|
if (DownloadManager.getInstance(this@DownloadService)
|
||||||
|
.getDownloadFolder(galleryID) != null
|
||||||
|
)
|
||||||
|
Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
|
||||||
|
|
||||||
|
startId?.let { stopSelf(it) }
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
FirebaseCrashlytics.getInstance().recordException(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(startId: Int? = null) {
|
||||||
|
client.dispatcher().queuedCalls().filter {
|
||||||
|
it.request().tag() is Tag
|
||||||
|
}.forEach {
|
||||||
|
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
|
||||||
|
it.cancel()
|
||||||
|
}
|
||||||
|
client.dispatcher().runningCalls().filter {
|
||||||
|
it.request().tag() is Tag
|
||||||
|
}.forEach {
|
||||||
|
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
|
||||||
|
it.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.clear()
|
||||||
|
notification.clear()
|
||||||
|
notificationManager.cancelAll()
|
||||||
|
|
||||||
|
startId?.let { stopSelf(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(galleryID: Int, startId: Int? = null) {
|
||||||
|
client.dispatcher().queuedCalls().filter {
|
||||||
|
(it.request().tag() as? Tag)?.galleryID == galleryID
|
||||||
|
}.forEach {
|
||||||
|
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
|
||||||
|
it.cancel()
|
||||||
|
}
|
||||||
|
client.dispatcher().runningCalls().filter {
|
||||||
|
(it.request().tag() as? Tag)?.galleryID == galleryID
|
||||||
|
}.forEach {
|
||||||
|
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
|
||||||
|
it.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.remove(galleryID)
|
||||||
|
notification.remove(galleryID)
|
||||||
|
notificationManager.cancel(galleryID)
|
||||||
|
|
||||||
|
startId?.let { stopSelf(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(galleryID: Int, startId: Int? = null) = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
cancel(galleryID)
|
||||||
|
DownloadManager.getInstance(this@DownloadService).deleteDownloadFolder(galleryID)
|
||||||
|
Cache.delete(this@DownloadService, galleryID)
|
||||||
|
|
||||||
|
startId?.let { stopSelf(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun download(galleryID: Int, priority: Boolean = false, startId: Int? = null): Job = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
if (DownloadManager.getInstance(this@DownloadService).isDownloading(galleryID))
|
||||||
|
return@launch
|
||||||
|
|
||||||
|
cleanCache(this@DownloadService)
|
||||||
|
|
||||||
|
val cache = Cache.getInstance(this@DownloadService, galleryID)
|
||||||
|
|
||||||
|
initNotification(galleryID)
|
||||||
|
|
||||||
|
val galleryInfo = cache.getGalleryInfo()
|
||||||
|
|
||||||
|
// Gallery doesn't exist
|
||||||
|
if (galleryInfo == null) {
|
||||||
|
delete(galleryID)
|
||||||
|
progress[galleryID] = mutableListOf()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
histories.add(galleryID)
|
||||||
|
|
||||||
|
progress[galleryID] = MutableList(galleryInfo.files.size) { 0F }
|
||||||
|
|
||||||
|
cache.metadata.imageList?.let {
|
||||||
|
it.forEachIndexed { index, image ->
|
||||||
|
progress[galleryID]?.set(index, if (image != null) Float.POSITIVE_INFINITY else 0F)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompleted(galleryID)) {
|
||||||
|
if (DownloadManager.getInstance(this@DownloadService).getDownloadFolder(galleryID) != null)
|
||||||
|
Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
|
||||||
|
|
||||||
|
notificationManager.cancel(galleryID)
|
||||||
|
startId?.let { stopSelf(it) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
notification[galleryID]?.setContentTitle(galleryInfo.title.ellipsize(32))
|
||||||
|
notify(galleryID)
|
||||||
|
|
||||||
|
val queued = mutableSetOf<Int>()
|
||||||
|
|
||||||
|
if (priority) {
|
||||||
|
client.dispatcher().queuedCalls().forEach {
|
||||||
|
val queuedID = (it.request().tag() as? Tag)?.galleryID ?: return@forEach
|
||||||
|
|
||||||
|
if (queued.add(queuedID))
|
||||||
|
cancel(queuedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
galleryInfo.getRequestBuilders().forEachIndexed { index, it ->
|
||||||
|
if (progress[galleryID]?.get(index)?.isInfinite() == false) {
|
||||||
|
val request = it.tag(Tag(galleryID, index, startId)).build()
|
||||||
|
client.newCall(request).enqueue(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queued.forEach { download(it) }
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KEY_COMMAND = "COMMAND" // String
|
||||||
|
const val KEY_ID = "ID" // Int
|
||||||
|
const val KEY_PRIORITY = "PRIORITY" // Boolean
|
||||||
|
|
||||||
|
const val COMMAND_DOWNLOAD = "DOWNLOAD"
|
||||||
|
const val COMMAND_CANCEL = "CANCEL"
|
||||||
|
const val COMMAND_DELETE = "DELETE"
|
||||||
|
|
||||||
|
private fun command(context: Context, extras: Intent.() -> Unit) {
|
||||||
|
ContextCompat.startForegroundService(context, Intent(context, DownloadService::class.java).apply(extras))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun download(context: Context, galleryID: Int, priority: Boolean = false) {
|
||||||
|
command(context) {
|
||||||
|
putExtra(KEY_COMMAND, COMMAND_DOWNLOAD)
|
||||||
|
putExtra(KEY_PRIORITY, priority)
|
||||||
|
putExtra(KEY_ID, galleryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context, galleryID: Int? = null) {
|
||||||
|
command(context) {
|
||||||
|
putExtra(KEY_COMMAND, COMMAND_CANCEL)
|
||||||
|
galleryID?.let { putExtra(KEY_ID, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(context: Context, galleryID: Int) {
|
||||||
|
command(context) {
|
||||||
|
putExtra(KEY_COMMAND, COMMAND_DELETE)
|
||||||
|
putExtra(KEY_ID, galleryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
startForeground(R.id.downloader_notification_id, serviceNotification.build())
|
||||||
|
} else {
|
||||||
|
startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (intent?.getStringExtra(KEY_COMMAND)) {
|
||||||
|
COMMAND_DOWNLOAD -> intent.getIntExtra(KEY_ID, -1).let { if (it > 0)
|
||||||
|
download(it, intent.getBooleanExtra(KEY_PRIORITY, false), startId)
|
||||||
|
}
|
||||||
|
COMMAND_CANCEL -> intent.getIntExtra(KEY_ID, -1).let { if (it > 0) cancel(it, startId) else cancel(startId = startId) }
|
||||||
|
COMMAND_DELETE -> intent.getIntExtra(KEY_ID, -1).let { if (it > 0) delete(it, startId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class Binder : android.os.Binder() {
|
||||||
|
val service = this@DownloadService
|
||||||
|
}
|
||||||
|
|
||||||
|
private val binder = Binder()
|
||||||
|
override fun onBind(p0: Intent?) = binder
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
startForeground(R.id.downloader_notification_id, serviceNotification.build())
|
||||||
|
} else {
|
||||||
|
startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
}
|
||||||
|
interceptors[Tag::class] = interceptor
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
interceptors.remove(Tag::class)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package xyz.quaver.pupil.services
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import io.ktor.network.selector.SelectorManager
|
||||||
|
import io.ktor.network.sockets.aSocket
|
||||||
|
import io.ktor.network.sockets.openReadChannel
|
||||||
|
import io.ktor.network.sockets.openWriteChannel
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import kotlin.coroutines.Continuation
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
class TransferClientService : Service() {
|
||||||
|
private val selectorManager = SelectorManager(Dispatchers.IO)
|
||||||
|
private val channel = Channel<Pair<TransferPacket, Continuation<TransferPacket>>>()
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
private fun startForeground() = runCatching {
|
||||||
|
val notification = NotificationCompat.Builder(this, "transfer")
|
||||||
|
.setContentTitle("Pupil")
|
||||||
|
.setContentText("Transfer server is running")
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
ServiceCompat.startForeground(
|
||||||
|
this,
|
||||||
|
1,
|
||||||
|
notification,
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
|
} else 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val address = intent?.getStringExtra("address") ?: run {
|
||||||
|
stopSelf(startId)
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
startForeground()
|
||||||
|
|
||||||
|
Log.d("PUPILD", "Starting service with address $address")
|
||||||
|
|
||||||
|
job?.cancel()
|
||||||
|
job = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
Log.d("PUPILD", "Connecting to $address")
|
||||||
|
|
||||||
|
val socket = aSocket(selectorManager).tcp().connect(address, 12221)
|
||||||
|
|
||||||
|
Log.d("PUPILD", "Connected to $address")
|
||||||
|
|
||||||
|
val readChannel = socket.openReadChannel()
|
||||||
|
val writeChannel = socket.openWriteChannel(autoFlush = true)
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
TransferPacket.Hello().writeToChannel(writeChannel)
|
||||||
|
val handshake = TransferPacket.readFromChannel(readChannel)
|
||||||
|
|
||||||
|
if (handshake !is TransferPacket.Hello || handshake.version != TRANSFER_PROTOCOL_VERSION) {
|
||||||
|
throw IllegalStateException("Invalid handshake")
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val (packet, continuation) = channel.receive()
|
||||||
|
|
||||||
|
Log.d("PUPILD", "Sending packet $packet")
|
||||||
|
|
||||||
|
packet.writeToChannel(writeChannel)
|
||||||
|
|
||||||
|
val response = TransferPacket.readFromChannel(readChannel).also {
|
||||||
|
Log.d("PUPILD", "Received packet $it")
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.resume(response)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Log.d("PUPILD", "Connection closed with error $it")
|
||||||
|
channel.close()
|
||||||
|
socket.close()
|
||||||
|
stopSelf(startId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
job?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class Binder: android.os.Binder() {
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
suspend fun sendPacket(packet: TransferPacket): Result<TransferPacket.ListResponse> = runCatching {
|
||||||
|
check(job != null) { "Service not running" }
|
||||||
|
check(!channel.isClosedForSend) { "Service not running" }
|
||||||
|
|
||||||
|
val response = suspendCoroutine { continuation ->
|
||||||
|
check (channel.trySend(packet to continuation).isSuccess) { "Service not running" }
|
||||||
|
}
|
||||||
|
|
||||||
|
check (response is TransferPacket.ListResponse) { "Invalid response" }
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val binder = Binder()
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder = binder
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package xyz.quaver.pupil.services
|
||||||
|
|
||||||
|
import io.ktor.utils.io.ByteReadChannel
|
||||||
|
import io.ktor.utils.io.ByteWriteChannel
|
||||||
|
|
||||||
|
const val TRANSFER_PROTOCOL_VERSION: UByte = 1u
|
||||||
|
|
||||||
|
enum class TransferType(val value: UByte) {
|
||||||
|
INVALID(255u),
|
||||||
|
HELLO(0u),
|
||||||
|
PING(1u),
|
||||||
|
PONG(2u),
|
||||||
|
LIST_REQUEST(3u),
|
||||||
|
LIST_RESPONSE(4u),
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface TransferPacket {
|
||||||
|
val type: TransferType
|
||||||
|
|
||||||
|
suspend fun writeToChannel(channel: ByteWriteChannel)
|
||||||
|
|
||||||
|
data class Hello(val version: UByte = TRANSFER_PROTOCOL_VERSION): TransferPacket {
|
||||||
|
override val type = TransferType.HELLO
|
||||||
|
|
||||||
|
override suspend fun writeToChannel(channel: ByteWriteChannel) {
|
||||||
|
channel.writeByte(type.value.toByte())
|
||||||
|
channel.writeByte(version.toByte())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data object Ping: TransferPacket {
|
||||||
|
override val type = TransferType.PING
|
||||||
|
override suspend fun writeToChannel(channel: ByteWriteChannel) {
|
||||||
|
channel.writeByte(type.value.toByte())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data object Pong: TransferPacket {
|
||||||
|
override val type = TransferType.PONG
|
||||||
|
override suspend fun writeToChannel(channel: ByteWriteChannel) {
|
||||||
|
channel.writeByte(type.value.toByte())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data object ListRequest: TransferPacket {
|
||||||
|
override val type = TransferType.LIST_REQUEST
|
||||||
|
override suspend fun writeToChannel(channel: ByteWriteChannel) {
|
||||||
|
channel.writeByte(type.value.toByte())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data object Invalid: TransferPacket {
|
||||||
|
override val type = TransferType.INVALID
|
||||||
|
override suspend fun writeToChannel(channel: ByteWriteChannel) {
|
||||||
|
channel.writeByte(type.value.toByte())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ListResponse(
|
||||||
|
val favoritesCount: Int,
|
||||||
|
val historyCount: Int,
|
||||||
|
val downloadsCount: Int,
|
||||||
|
): TransferPacket {
|
||||||
|
override val type = TransferType.LIST_RESPONSE
|
||||||
|
|
||||||
|
override suspend fun writeToChannel(channel: ByteWriteChannel) {
|
||||||
|
channel.writeByte(type.value.toByte())
|
||||||
|
channel.writeInt(favoritesCount)
|
||||||
|
channel.writeInt(historyCount)
|
||||||
|
channel.writeInt(downloadsCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
suspend fun readFromChannel(channel: ByteReadChannel): TransferPacket {
|
||||||
|
return when(val type = channel.readByte().toUByte()) {
|
||||||
|
TransferType.HELLO.value -> {
|
||||||
|
val version = channel.readByte().toUByte()
|
||||||
|
Hello(version)
|
||||||
|
}
|
||||||
|
TransferType.PING.value -> Ping
|
||||||
|
TransferType.PONG.value -> Pong
|
||||||
|
TransferType.LIST_REQUEST.value -> ListRequest
|
||||||
|
TransferType.LIST_RESPONSE.value -> {
|
||||||
|
val favoritesCount = channel.readInt()
|
||||||
|
val historyCount = channel.readInt()
|
||||||
|
val downloadsCount = channel.readInt()
|
||||||
|
ListResponse(favoritesCount, historyCount, downloadsCount)
|
||||||
|
}
|
||||||
|
else -> throw IllegalArgumentException("Unknown packet type: $type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package xyz.quaver.pupil.services
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import io.ktor.network.selector.SelectorManager
|
||||||
|
import io.ktor.network.sockets.ServerSocket
|
||||||
|
import io.ktor.network.sockets.Socket
|
||||||
|
import io.ktor.network.sockets.aSocket
|
||||||
|
import io.ktor.network.sockets.openReadChannel
|
||||||
|
import io.ktor.network.sockets.openWriteChannel
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.favorites
|
||||||
|
import xyz.quaver.pupil.histories
|
||||||
|
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||||
|
|
||||||
|
class TransferServerService : Service() {
|
||||||
|
private val selectorManager = SelectorManager(Dispatchers.IO)
|
||||||
|
private var serverSocket: ServerSocket? = null
|
||||||
|
private val job = Job()
|
||||||
|
|
||||||
|
private fun startForeground() = runCatching {
|
||||||
|
val notification = NotificationCompat.Builder(this, "transfer")
|
||||||
|
.setContentTitle("Pupil")
|
||||||
|
.setContentText("Transfer server is running")
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
ServiceCompat.startForeground(
|
||||||
|
this,
|
||||||
|
1,
|
||||||
|
notification,
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
|
} else 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateListResponse(): TransferPacket.ListResponse {
|
||||||
|
val favoritesCount = favorites.size
|
||||||
|
val historyCount = histories.size
|
||||||
|
val downloadsCount = DownloadManager.getInstance(this).downloadFolderMap.size
|
||||||
|
return TransferPacket.ListResponse(favoritesCount, historyCount, downloadsCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleConnection(socket: Socket) {
|
||||||
|
val readChannel = socket.openReadChannel()
|
||||||
|
val writeChannel = socket.openWriteChannel(autoFlush = true)
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
while (true) {
|
||||||
|
val packet = TransferPacket.readFromChannel(readChannel)
|
||||||
|
|
||||||
|
Log.d("PUPILD", "Received packet $packet")
|
||||||
|
|
||||||
|
binder.channel.trySend(packet)
|
||||||
|
|
||||||
|
val response = when (packet) {
|
||||||
|
is TransferPacket.Hello -> TransferPacket.Hello()
|
||||||
|
is TransferPacket.Ping -> TransferPacket.Pong
|
||||||
|
is TransferPacket.ListRequest -> generateListResponse()
|
||||||
|
else -> TransferPacket.Invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeToChannel(writeChannel)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val address = intent?.getStringExtra("address") ?: run {
|
||||||
|
stopSelf(startId)
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverSocket != null) {
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
startForeground()
|
||||||
|
|
||||||
|
val serverSocket = aSocket(selectorManager).tcp().bind(address, 12221).also {
|
||||||
|
this@TransferServerService.serverSocket = it
|
||||||
|
}
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO + job).launch {
|
||||||
|
while (true) {
|
||||||
|
Log.d("PUPILD", "Waiting for connection")
|
||||||
|
val socket = serverSocket.accept()
|
||||||
|
Log.d("PUPILD", "Accepted connection from ${socket.remoteAddress}")
|
||||||
|
launch { handleConnection(socket) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
job.cancel()
|
||||||
|
serverSocket?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class Binder: android.os.Binder() {
|
||||||
|
val channel = Channel<TransferPacket>()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val binder = Binder()
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder = binder
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* Pupil, Hitomi.la viewer for Android
|
* Pupil, Hitomi.la viewer for Android
|
||||||
* Copyright (C) 2020 tom5079
|
* Copyright (C) 2022 tom5079
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
@@ -16,9 +16,7 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package xyz.quaver.pupil.util
|
package xyz.quaver.pupil.types
|
||||||
|
|
||||||
const val REQUEST_LOCK = 38238
|
class SendLogException : Exception()
|
||||||
const val REQUEST_RESTORE = 16546
|
class JavascriptException(message: String?) : Exception(message)
|
||||||
const val REQUEST_DOWNLOAD_FOLDER = 3874
|
|
||||||
const val REQUEST_DOWNLOAD_FOLDER_OLD = 3425
|
|
||||||
@@ -18,15 +18,33 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.types
|
package xyz.quaver.pupil.types
|
||||||
|
|
||||||
import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion
|
import kotlinx.parcelize.IgnoredOnParcel
|
||||||
import kotlinx.android.parcel.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import xyz.quaver.hitomi.Suggestion
|
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
|
||||||
|
import xyz.quaver.pupil.hitomi.Suggestion
|
||||||
|
import xyz.quaver.pupil.util.translations
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class TagSuggestion(val s: String, val t: Int, val u: String, val n: String) : SearchSuggestion {
|
data class TagSuggestion(val s: String, val t: Int, val u: String, val n: String) : SearchSuggestion {
|
||||||
constructor(s: Suggestion) : this(s.s, s.t, s.u, s.n)
|
constructor(s: Suggestion) : this(s.s, s.t, s.u, s.n)
|
||||||
|
|
||||||
override fun getBody(): String {
|
@IgnoredOnParcel
|
||||||
return s
|
override val body =
|
||||||
}
|
if (translations[s] != null)
|
||||||
|
"${translations[s]} ($s)"
|
||||||
|
else
|
||||||
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
class Suggestion(override val body: String) : SearchSuggestion
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
class NoResultSuggestion(override val body: String) : SearchSuggestion
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
class LoadingSuggestion(override val body: String) : SearchSuggestion
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
@Suppress("PARCELABLE_PRIMARY_CONSTRUCTOR_IS_EMPTY")
|
||||||
|
class FavoriteHistorySwitch(override val body: String) : SearchSuggestion
|
||||||
@@ -24,7 +24,7 @@ import kotlinx.serialization.Serializable
|
|||||||
data class Tag(val area: String?, val tag: String, val isNegative: Boolean = false) {
|
data class Tag(val area: String?, val tag: String, val isNegative: Boolean = false) {
|
||||||
companion object {
|
companion object {
|
||||||
fun parse(tag: String) : Tag {
|
fun parse(tag: String) : Tag {
|
||||||
if (tag.first() == '-') {
|
if (tag.firstOrNull() == '-') {
|
||||||
tag.substring(1).split(Regex(":"), 2).let {
|
tag.substring(1).split(Regex(":"), 2).let {
|
||||||
return when(it.size) {
|
return when(it.size) {
|
||||||
2 -> Tag(it[0], it[1], true)
|
2 -> Tag(it[0], it[1], true)
|
||||||
@@ -62,12 +62,10 @@ data class Tag(val area: String?, val tag: String, val isNegative: Boolean = fal
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
override fun hashCode() = toString().hashCode()
|
||||||
return super.hashCode()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Tags(tag: List<Tag?>?) : ArrayList<Tag>() {
|
class Tags(val tags: MutableSet<Tag> = mutableSetOf()) : MutableSet<Tag> by tags {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun parse(tags: String) : Tags {
|
fun parse(tags: String) : Tags {
|
||||||
@@ -77,20 +75,13 @@ class Tags(tag: List<Tag?>?) : ArrayList<Tag>() {
|
|||||||
Tag.parse(it)
|
Tag.parse(it)
|
||||||
else
|
else
|
||||||
null
|
null
|
||||||
}
|
}.filterNotNull().toMutableSet()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
|
||||||
tag?.forEach {
|
|
||||||
if (it != null)
|
|
||||||
add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun contains(element: String): Boolean {
|
fun contains(element: String): Boolean {
|
||||||
forEach {
|
tags.forEach {
|
||||||
if (it.toString() == element)
|
if (it.toString() == element)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -99,23 +90,22 @@ class Tags(tag: List<Tag?>?) : ArrayList<Tag>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun add(element: String): Boolean {
|
fun add(element: String): Boolean {
|
||||||
return super.add(Tag.parse(element))
|
return tags.add(Tag.parse(element))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(element: String) {
|
fun remove(element: String) {
|
||||||
filter { it.toString() == element }.forEach {
|
tags.filter { it.toString() == element }.forEach {
|
||||||
remove(it)
|
tags.remove(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeByArea(area: String, isNegative: Boolean? = null) {
|
fun removeByArea(area: String, isNegative: Boolean? = null) {
|
||||||
filter { it.area == area && (if(isNegative == null) true else (it.isNegative == isNegative)) }.forEach {
|
tags.filter { it.area == area && (if(isNegative == null) true else (it.isNegative == isNegative)) }.forEach {
|
||||||
remove(it)
|
tags.remove(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return joinToString(" ") { it.toString() }
|
return tags.joinToString(" ") { it.toString() }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
67
app/src/main/java/xyz/quaver/pupil/ui/BaseActivity.kt
Normal file
67
app/src/main/java/xyz/quaver/pupil/ui/BaseActivity.kt
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.ui
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PersistableBundle
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.annotation.CallSuper
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.util.LockManager
|
||||||
|
import xyz.quaver.pupil.util.Preferences
|
||||||
|
import xyz.quaver.pupil.util.normalizeID
|
||||||
|
|
||||||
|
open class BaseActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private var locked: Boolean = true
|
||||||
|
|
||||||
|
private val lockLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
|
if (it.resultCode == Activity.RESULT_OK)
|
||||||
|
locked = false
|
||||||
|
else
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
|
||||||
|
super.onCreate(savedInstanceState, persistentState)
|
||||||
|
|
||||||
|
locked = !LockManager(this).locks.isNullOrEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
|
||||||
|
if (Preferences["security_mode"])
|
||||||
|
window.setFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
else
|
||||||
|
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
|
||||||
|
if (locked)
|
||||||
|
lockLauncher.launch(Intent(this, LockActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -21,23 +21,162 @@ package xyz.quaver.pupil.ui
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.animation.Animation
|
||||||
|
import android.view.animation.AnimationUtils
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.andrognito.patternlockview.PatternLockView
|
import com.andrognito.patternlockview.PatternLockView
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import kotlinx.android.synthetic.main.activity_lock.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_pattern_lock.*
|
|
||||||
import xyz.quaver.pupil.R
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.databinding.LockActivityBinding
|
||||||
|
import xyz.quaver.pupil.ui.fragment.PINLockFragment
|
||||||
import xyz.quaver.pupil.ui.fragment.PatternLockFragment
|
import xyz.quaver.pupil.ui.fragment.PatternLockFragment
|
||||||
import xyz.quaver.pupil.util.Lock
|
import xyz.quaver.pupil.util.Lock
|
||||||
import xyz.quaver.pupil.util.LockManager
|
import xyz.quaver.pupil.util.LockManager
|
||||||
|
import xyz.quaver.pupil.util.Preferences
|
||||||
|
|
||||||
|
private var lastUnlocked = 0L
|
||||||
class LockActivity : AppCompatActivity() {
|
class LockActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var lockManager: LockManager
|
||||||
|
private var mode: String? = null
|
||||||
|
|
||||||
|
private lateinit var binding: LockActivityBinding
|
||||||
|
|
||||||
|
private val patternLockFragment = PatternLockFragment().apply {
|
||||||
|
var lastPass = ""
|
||||||
|
onPatternDrawn = {
|
||||||
|
when(mode) {
|
||||||
|
null -> {
|
||||||
|
val result = lockManager.check(it)
|
||||||
|
|
||||||
|
if (result == true) {
|
||||||
|
lastUnlocked = System.currentTimeMillis()
|
||||||
|
setResult(Activity.RESULT_OK)
|
||||||
|
finish()
|
||||||
|
} else
|
||||||
|
binding.patternLockView.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
||||||
|
}
|
||||||
|
"add_lock" -> {
|
||||||
|
if (lastPass.isEmpty()) {
|
||||||
|
lastPass = it
|
||||||
|
|
||||||
|
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
|
||||||
|
} else {
|
||||||
|
if (lastPass == it) {
|
||||||
|
LockManager(context!!).add(Lock.generate(Lock.Type.PATTERN, it))
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
binding.patternLockView.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
||||||
|
lastPass = ""
|
||||||
|
|
||||||
|
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val pinLockFragment = PINLockFragment().apply {
|
||||||
|
var lastPass = ""
|
||||||
|
onPINEntered = {
|
||||||
|
when(mode) {
|
||||||
|
null -> {
|
||||||
|
val result = lockManager.check(it)
|
||||||
|
|
||||||
|
if (result == true) {
|
||||||
|
lastUnlocked = System.currentTimeMillis()
|
||||||
|
setResult(Activity.RESULT_OK)
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
binding.indicatorDots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
|
||||||
|
setAnimationListener(object: Animation.AnimationListener {
|
||||||
|
override fun onAnimationEnd(animation: Animation?) {
|
||||||
|
binding.pinLockView.resetPinLockView()
|
||||||
|
binding.pinLockView.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAnimationStart(animation: Animation?) {
|
||||||
|
binding.pinLockView.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAnimationRepeat(animation: Animation?) {
|
||||||
|
// Do Nothing
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"add_lock" -> {
|
||||||
|
if (lastPass.isEmpty()) {
|
||||||
|
lastPass = it
|
||||||
|
|
||||||
|
binding.pinLockView.resetPinLockView()
|
||||||
|
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
|
||||||
|
} else {
|
||||||
|
if (lastPass == it) {
|
||||||
|
LockManager(context!!).add(Lock.generate(Lock.Type.PIN, it))
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
binding.indicatorDots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
|
||||||
|
setAnimationListener(object: Animation.AnimationListener {
|
||||||
|
override fun onAnimationEnd(animation: Animation?) {
|
||||||
|
binding.pinLockView.resetPinLockView()
|
||||||
|
binding.pinLockView.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAnimationStart(animation: Animation?) {
|
||||||
|
binding.pinLockView.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAnimationRepeat(animation: Animation?) {
|
||||||
|
// Do Nothing
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
lastPass = ""
|
||||||
|
|
||||||
|
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBiometricPrompt() {
|
||||||
|
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(getText(R.string.settings_lock_fingerprint_prompt))
|
||||||
|
.setSubtitle(getText(R.string.settings_lock_fingerprint_prompt_subtitle))
|
||||||
|
.setNegativeButtonText(getText(android.R.string.cancel))
|
||||||
|
.setConfirmationRequired(false)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val biometricPrompt = BiometricPrompt(this, ContextCompat.getMainExecutor(this),
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(
|
||||||
|
result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
super.onAuthenticationSucceeded(result)
|
||||||
|
lastUnlocked = System.currentTimeMillis()
|
||||||
|
setResult(RESULT_OK)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Displays the "log in" prompt.
|
||||||
|
biometricPrompt.authenticate(promptInfo)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_lock)
|
binding = LockActivityBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
val lockManager = try {
|
lockManager = try {
|
||||||
LockManager(this)
|
LockManager(this)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
AlertDialog.Builder(this).apply {
|
AlertDialog.Builder(this).apply {
|
||||||
@@ -50,12 +189,8 @@ class LockActivity : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val mode = intent.getStringExtra("mode")
|
mode = intent.getStringExtra("mode")
|
||||||
|
val force = intent.getBooleanExtra("force", false)
|
||||||
lock_pattern.isEnabled = false
|
|
||||||
lock_pin.isEnabled = false
|
|
||||||
lock_fingerprint.isEnabled = false
|
|
||||||
lock_password.isEnabled = false
|
|
||||||
|
|
||||||
when(mode) {
|
when(mode) {
|
||||||
null -> {
|
null -> {
|
||||||
@@ -64,52 +199,82 @@ class LockActivity : AppCompatActivity() {
|
|||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
"add_lock" -> {
|
|
||||||
when(intent.getStringExtra("type")!!) {
|
|
||||||
"pattern" -> {
|
|
||||||
|
|
||||||
}
|
if (System.currentTimeMillis() - lastUnlocked < 5*60*1000 && !force) {
|
||||||
}
|
lastUnlocked = System.currentTimeMillis()
|
||||||
}
|
setResult(RESULT_OK)
|
||||||
}
|
|
||||||
|
|
||||||
supportFragmentManager.beginTransaction().add(
|
|
||||||
R.id.lock_content,
|
|
||||||
PatternLockFragment().apply {
|
|
||||||
var lastPass = ""
|
|
||||||
onPatternDrawn = {
|
|
||||||
when(mode) {
|
|
||||||
null -> {
|
|
||||||
val result = lockManager.check(it)
|
|
||||||
|
|
||||||
if (result == true) {
|
|
||||||
setResult(Activity.RESULT_OK)
|
|
||||||
finish()
|
finish()
|
||||||
} else
|
return
|
||||||
lock_pattern_view.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
|
||||||
}
|
}
|
||||||
"add_lock" -> {
|
|
||||||
if (lastPass.isEmpty()) {
|
|
||||||
lastPass = it
|
|
||||||
|
|
||||||
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
|
if (
|
||||||
} else {
|
Preferences["lock_fingerprint"]
|
||||||
if (lastPass == it) {
|
&& BiometricManager.from(this).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
LockManager(context!!).add(Lock.generate(Lock.Type.PATTERN, it))
|
) {
|
||||||
finish()
|
binding.fingerprintBtn.apply {
|
||||||
} else {
|
isEnabled = true
|
||||||
lock_pattern_view.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
setOnClickListener {
|
||||||
lastPass = ""
|
showBiometricPrompt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showBiometricPrompt()
|
||||||
|
}
|
||||||
|
|
||||||
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
|
binding.patternBtn.apply {
|
||||||
}
|
isEnabled = lockManager.contains(Lock.Type.PATTERN)
|
||||||
}
|
setOnClickListener {
|
||||||
}
|
supportFragmentManager.beginTransaction().replace(
|
||||||
}
|
R.id.lock_content, patternLockFragment
|
||||||
}
|
|
||||||
}
|
|
||||||
).commit()
|
).commit()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
binding.pinBtn.apply {
|
||||||
|
isEnabled = lockManager.contains(Lock.Type.PIN)
|
||||||
|
setOnClickListener {
|
||||||
|
supportFragmentManager.beginTransaction().replace(
|
||||||
|
R.id.lock_content, pinLockFragment
|
||||||
|
).commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.passwordBtn.isEnabled = false
|
||||||
|
|
||||||
|
when (lockManager.locks!!.first().type) {
|
||||||
|
Lock.Type.PIN -> {
|
||||||
|
|
||||||
|
supportFragmentManager.beginTransaction().add(
|
||||||
|
R.id.lock_content, pinLockFragment
|
||||||
|
).commit()
|
||||||
|
}
|
||||||
|
Lock.Type.PATTERN -> {
|
||||||
|
supportFragmentManager.beginTransaction().add(
|
||||||
|
R.id.lock_content, patternLockFragment
|
||||||
|
).commit()
|
||||||
|
}
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"add_lock" -> {
|
||||||
|
binding.patternBtn.isEnabled = false
|
||||||
|
binding.pinBtn.isEnabled = false
|
||||||
|
binding.fingerprintBtn.isEnabled = false
|
||||||
|
binding.passwordBtn.isEnabled = false
|
||||||
|
|
||||||
|
when(intent.getStringExtra("type")!!) {
|
||||||
|
"pattern" -> {
|
||||||
|
binding.patternBtn.isEnabled = true
|
||||||
|
supportFragmentManager.beginTransaction().add(
|
||||||
|
R.id.lock_content, patternLockFragment
|
||||||
|
).commit()
|
||||||
|
}
|
||||||
|
"pin" -> {
|
||||||
|
binding.pinBtn.isEnabled = true
|
||||||
|
supportFragmentManager.beginTransaction().add(
|
||||||
|
R.id.lock_content, pinLockFragment
|
||||||
|
).commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,38 +18,54 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.ui
|
package xyz.quaver.pupil.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.drawable.Animatable
|
import android.graphics.drawable.Animatable
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
import android.view.*
|
import android.view.*
|
||||||
|
import android.view.animation.Animation
|
||||||
|
import android.view.animation.AnticipateInterpolator
|
||||||
|
import android.view.animation.OvershootInterpolator
|
||||||
|
import android.view.animation.TranslateAnimation
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.PagerSnapHelper
|
import androidx.recyclerview.widget.PagerSnapHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
|
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
|
||||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||||
import com.crashlytics.android.Crashlytics
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import io.fabric.sdk.android.Fabric
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
import kotlinx.android.synthetic.main.activity_reader.*
|
import com.google.mlkit.vision.face.Face
|
||||||
import kotlinx.android.synthetic.main.activity_reader.view.*
|
import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
|
||||||
import kotlinx.android.synthetic.main.dialog_numberpicker.view.*
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.serialization.ImplicitReflectionSerializer
|
import kotlinx.coroutines.Dispatchers
|
||||||
import xyz.quaver.Code
|
import kotlinx.coroutines.delay
|
||||||
import xyz.quaver.pupil.Pupil
|
import kotlinx.coroutines.launch
|
||||||
import xyz.quaver.pupil.R
|
import xyz.quaver.pupil.R
|
||||||
import xyz.quaver.pupil.adapters.ReaderAdapter
|
import xyz.quaver.pupil.adapters.ReaderAdapter
|
||||||
import xyz.quaver.pupil.util.Histories
|
import xyz.quaver.pupil.databinding.NumberpickerDialogBinding
|
||||||
import xyz.quaver.pupil.util.download.Cache
|
import xyz.quaver.pupil.databinding.ReaderActivityBinding
|
||||||
import xyz.quaver.pupil.util.download.DownloadWorker
|
import xyz.quaver.pupil.favorites
|
||||||
import java.util.*
|
import xyz.quaver.pupil.services.DownloadService
|
||||||
import kotlin.concurrent.schedule
|
import xyz.quaver.pupil.util.Preferences
|
||||||
|
import xyz.quaver.pupil.util.camera
|
||||||
|
import xyz.quaver.pupil.util.checkNotificationEnabled
|
||||||
|
import xyz.quaver.pupil.util.closeCamera
|
||||||
|
import xyz.quaver.pupil.util.downloader.Cache
|
||||||
|
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||||
|
import xyz.quaver.pupil.util.requestNotificationPermission
|
||||||
|
import xyz.quaver.pupil.util.showNotificationPermissionExplanationDialog
|
||||||
|
import xyz.quaver.pupil.util.startCamera
|
||||||
|
|
||||||
class ReaderActivity : AppCompatActivity() {
|
class ReaderActivity : BaseActivity() {
|
||||||
|
|
||||||
private var galleryID = 0
|
private var galleryID = 0
|
||||||
private var currentPage = 0
|
private var currentPage = 0
|
||||||
@@ -59,48 +75,76 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
|
|
||||||
(reader_recyclerview.adapter as ReaderAdapter).isFullScreen = value
|
(binding.recyclerview.adapter as ReaderAdapter).isFullScreen = value
|
||||||
|
}
|
||||||
|
|
||||||
reader_progressbar.visibility = when {
|
private lateinit var cache: Cache
|
||||||
value -> View.VISIBLE
|
var downloader: DownloadService? = null
|
||||||
else -> View.GONE
|
private val conn = object: ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
downloader = (service as DownloadService.Binder).service.also {
|
||||||
|
it.priority = 0
|
||||||
|
|
||||||
|
if (!it.progress.containsKey(galleryID))
|
||||||
|
DownloadService.download(this@ReaderActivity, galleryID, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val timer = Timer()
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
downloader = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val snapHelper = PagerSnapHelper()
|
private val snapHelper = PagerSnapHelper()
|
||||||
|
|
||||||
private var menu: Menu? = null
|
private var menu: Menu? = null
|
||||||
|
|
||||||
private lateinit var favorites: Histories
|
private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||||
|
if (isGranted)
|
||||||
|
toggleCamera()
|
||||||
|
else
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle(R.string.error)
|
||||||
|
.setMessage(R.string.camera_denied)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Eye {
|
||||||
|
LEFT,
|
||||||
|
RIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cameraEnabled = false
|
||||||
|
private var eyeType: Eye? = null
|
||||||
|
private var eyeTime: Long = 0L
|
||||||
|
|
||||||
|
private lateinit var binding: ReaderActivityBinding
|
||||||
|
|
||||||
|
private val requestNotificationPermssionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||||
|
if (!isGranted) {
|
||||||
|
showNotificationPermissionExplanationDialog(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
binding = ReaderActivityBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
title = getString(R.string.reader_loading)
|
title = getString(R.string.reader_loading)
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||||
|
|
||||||
favorites = (application as Pupil).favorites
|
|
||||||
|
|
||||||
window.setFlags(
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
|
|
||||||
setContentView(R.layout.activity_reader)
|
|
||||||
|
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
|
cache = Cache.getInstance(this, galleryID)
|
||||||
if (Fabric.isInitialized())
|
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", galleryID)
|
||||||
Crashlytics.setInt("GalleryID", galleryID)
|
|
||||||
|
|
||||||
if (galleryID == 0) {
|
if (galleryID == 0) {
|
||||||
onBackPressed()
|
onBackPressed()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initDownloadListener()
|
||||||
initView()
|
initView()
|
||||||
initDownloader()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
@@ -113,14 +157,13 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
val uri = intent.data
|
val uri = intent.data
|
||||||
val lastPathSegment = uri?.lastPathSegment
|
val lastPathSegment = uri?.lastPathSegment
|
||||||
if (uri != null && lastPathSegment != null) {
|
if (uri != null && lastPathSegment != null) {
|
||||||
val nonNumber = Regex("[^-?0-9]+")
|
galleryID = if (uri.host?.endsWith("hasha.in") == true) {
|
||||||
|
lastPathSegment?.toInt() ?: 0
|
||||||
galleryID = when (uri.host) {
|
} else when (uri.host) {
|
||||||
"hitomi.la" -> lastPathSegment.replace(nonNumber, "").toInt()
|
"hitomi.la" ->
|
||||||
"히요비.asia" -> lastPathSegment.toInt()
|
Regex("([0-9]+).html").find(lastPathSegment)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||||
"xn--9w3b15m8vo.asia" -> lastPathSegment.toInt()
|
|
||||||
"e-hentai.org" -> uri.pathSegments[1].toInt()
|
"e-hentai.org" -> uri.pathSegments[1].toInt()
|
||||||
else -> return
|
else -> 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -128,26 +171,10 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
|
||||||
|
|
||||||
if (preferences.getBoolean("security_mode", false))
|
|
||||||
window.setFlags(
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
else
|
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
|
|
||||||
super.onResume()
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.reader, menu)
|
menuInflater.inflate(R.menu.reader, menu)
|
||||||
|
|
||||||
with(menu?.findItem(R.id.reader_menu_favorite)) {
|
with(menu.findItem(R.id.reader_menu_favorite)) {
|
||||||
this ?: return@with
|
|
||||||
|
|
||||||
if (favorites.contains(galleryID))
|
if (favorites.contains(galleryID))
|
||||||
(icon as Animatable).start()
|
(icon as Animatable).start()
|
||||||
}
|
}
|
||||||
@@ -156,20 +183,22 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
when(item?.itemId) {
|
when(item.itemId) {
|
||||||
R.id.reader_menu_page_indicator -> {
|
R.id.reader_menu_page_indicator -> {
|
||||||
val view = LayoutInflater.from(this).inflate(R.layout.dialog_numberpicker, findViewById(android.R.id.content), false)
|
// TODO: Switch to DialogFragment
|
||||||
with(view.dialog_number_picker) {
|
val binding = NumberpickerDialogBinding.inflate(layoutInflater, binding.root, false)
|
||||||
|
|
||||||
|
with(binding.numberPicker) {
|
||||||
minValue = 1
|
minValue = 1
|
||||||
maxValue=reader_recyclerview?.adapter?.itemCount ?: 0
|
maxValue = cache.metadata.galleryInfo?.files?.size ?: 0
|
||||||
value = currentPage
|
value = currentPage
|
||||||
}
|
}
|
||||||
val dialog = AlertDialog.Builder(this).apply {
|
val dialog = AlertDialog.Builder(this).apply {
|
||||||
setView(view)
|
setView(binding.root)
|
||||||
}.create()
|
}.create()
|
||||||
view.dialog_ok.setOnClickListener {
|
binding.okButton.setOnClickListener {
|
||||||
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(view.dialog_number_picker.value-1, 0)
|
(this@ReaderActivity.binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(binding.numberPicker.value-1, 0)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,14 +221,32 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
|
||||||
|
bindService(Intent(this, DownloadService::class.java), conn, BIND_AUTO_CREATE)
|
||||||
|
|
||||||
|
if (cameraEnabled)
|
||||||
|
startCamera(this, cameraCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
closeCamera()
|
||||||
|
|
||||||
|
if (downloader != null)
|
||||||
|
unbindService(conn)
|
||||||
|
|
||||||
|
downloader?.priority = galleryID
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|
||||||
timer.cancel()
|
update = false
|
||||||
(reader_recyclerview?.adapter as? ReaderAdapter)?.timer?.cancel()
|
|
||||||
|
|
||||||
if (!Cache(this).isDownloading(galleryID))
|
if (!DownloadManager.getInstance(this).isDownloading(galleryID))
|
||||||
DownloadWorker.getInstance(this@ReaderActivity).cancel(galleryID)
|
DownloadService.cancel(this, galleryID)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBackPressed() {
|
override fun onBackPressed() {
|
||||||
@@ -221,12 +268,12 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
//currentPage is 1-based
|
//currentPage is 1-based
|
||||||
return when(keyCode) {
|
return when(keyCode) {
|
||||||
KeyEvent.KEYCODE_VOLUME_UP -> {
|
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||||
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage-2, 0)
|
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-2, 0)
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||||
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage, 0)
|
(binding.recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage, 0)
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -234,45 +281,49 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initDownloader() {
|
private var update = true
|
||||||
val worker = DownloadWorker.getInstance(this).apply {
|
private fun initDownloadListener() {
|
||||||
queue.add(galleryID)
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
}
|
while (update) {
|
||||||
|
delay(1000)
|
||||||
|
|
||||||
timer.schedule(0, 1000) {
|
val downloader = downloader ?: continue
|
||||||
if (worker.progress.indexOfKey(galleryID) < 0) //loading
|
|
||||||
return@schedule
|
|
||||||
|
|
||||||
if (worker.progress[galleryID] == null) { //Gallery not found
|
if (!downloader.progress.containsKey(galleryID)) //loading
|
||||||
timer.cancel()
|
continue
|
||||||
|
|
||||||
|
if (downloader.progress[galleryID]?.isEmpty() == true) { //Gallery not found
|
||||||
|
update = false
|
||||||
Snackbar
|
Snackbar
|
||||||
.make(reader_layout, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE)
|
.make(binding.root, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE)
|
||||||
.show()
|
.show()
|
||||||
|
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
runOnUiThread {
|
binding.downloadProgressbar.max = binding.recyclerview.adapter?.itemCount ?: 0
|
||||||
reader_download_progressbar.max = reader_recyclerview.adapter?.itemCount ?: 0
|
binding.downloadProgressbar.progress =
|
||||||
reader_download_progressbar.progress = worker.progress[galleryID]?.count { !it.isFinite() } ?: 0
|
downloader.progress[galleryID]?.count { it.isInfinite() } ?: 0
|
||||||
reader_progressbar.max = reader_recyclerview.adapter?.itemCount ?: 0
|
|
||||||
|
|
||||||
if (title == getString(R.string.reader_loading)) {
|
if (title == getString(R.string.reader_loading)) {
|
||||||
val reader = (reader_recyclerview.adapter as ReaderAdapter).reader
|
val galleryInfo = cache.metadata.galleryInfo
|
||||||
|
|
||||||
if (reader != null) {
|
if (galleryInfo != null) {
|
||||||
title = reader.title
|
with(binding.recyclerview.adapter as ReaderAdapter) {
|
||||||
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${reader.galleryInfo.size}"
|
this.galleryInfo = galleryInfo
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity,
|
title = galleryInfo.title
|
||||||
when (reader.code) {
|
menu?.findItem(R.id.reader_menu_page_indicator)?.title =
|
||||||
Code.HITOMI -> R.drawable.hitomi
|
"$currentPage/${galleryInfo.files.size}"
|
||||||
Code.HIYOBI -> R.drawable.ic_hiyobi
|
|
||||||
else -> android.R.color.transparent
|
menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.hitomi)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (worker.progress[galleryID]?.all { !it.isFinite() } == true) { //Download finished
|
if (downloader.isCompleted(galleryID)) { //Download finished
|
||||||
reader_download_progressbar.visibility = View.GONE
|
binding.downloadProgressbar.visibility = View.GONE
|
||||||
|
|
||||||
animateDownloadFAB(false)
|
animateDownloadFAB(false)
|
||||||
}
|
}
|
||||||
@@ -281,7 +332,7 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun initView() {
|
private fun initView() {
|
||||||
with(reader_recyclerview) {
|
with(binding.recyclerview) {
|
||||||
adapter = ReaderAdapter(this@ReaderActivity, galleryID).apply {
|
adapter = ReaderAdapter(this@ReaderActivity, galleryID).apply {
|
||||||
onItemClickListener = {
|
onItemClickListener = {
|
||||||
if (isScroll) {
|
if (isScroll) {
|
||||||
@@ -291,7 +342,7 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
scrollMode(false)
|
scrollMode(false)
|
||||||
fullscreen(true)
|
fullscreen(true)
|
||||||
} else {
|
} else {
|
||||||
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPosition(currentPage) //Moves to next page because currentPage is 1-based indexing
|
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage, 0) //Moves to next page because currentPage is 1-based indexing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,9 +352,9 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
super.onScrolled(recyclerView, dx, dy)
|
super.onScrolled(recyclerView, dx, dy)
|
||||||
|
|
||||||
if (dy < 0)
|
if (dy < 0)
|
||||||
this@ReaderActivity.reader_fab.showMenuButton(true)
|
binding.fab.showMenuButton(true)
|
||||||
else if (dy > 0)
|
else if (dy > 0)
|
||||||
this@ReaderActivity.reader_fab.hideMenuButton(true)
|
binding.fab.hideMenuButton(true)
|
||||||
|
|
||||||
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
||||||
|
|
||||||
@@ -311,33 +362,66 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
currentPage = layoutManager.findFirstVisibleItemPosition()+1
|
currentPage = layoutManager.findFirstVisibleItemPosition()+1
|
||||||
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${recyclerView.adapter!!.itemCount}"
|
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${recyclerView.adapter!!.itemCount}"
|
||||||
this@ReaderActivity.reader_progressbar.progress = currentPage
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
with(reader_fab_download) {
|
with(binding.downloadFab) {
|
||||||
animateDownloadFAB(Cache(context).isDownloading(galleryID)) //If download in progress, animate button
|
animateDownloadFAB(DownloadManager.getInstance(this@ReaderActivity).getDownloadFolder(galleryID) != null) //If download in progress, animate button
|
||||||
|
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
if (Cache(context).isDownloading(galleryID)) {
|
requestNotificationPermission(
|
||||||
Cache(context).setDownloading(galleryID, false)
|
this@ReaderActivity,
|
||||||
|
requestNotificationPermssionLauncher
|
||||||
|
) {
|
||||||
|
val downloadManager = DownloadManager.getInstance(this@ReaderActivity)
|
||||||
|
|
||||||
|
if (downloadManager.isDownloading(galleryID)) {
|
||||||
|
downloadManager.deleteDownloadFolder(galleryID)
|
||||||
animateDownloadFAB(false)
|
animateDownloadFAB(false)
|
||||||
} else {
|
} else {
|
||||||
Cache(context).setDownloading(galleryID, true)
|
downloadManager.addDownloadFolder(galleryID)
|
||||||
|
DownloadService.download(context, galleryID, true)
|
||||||
animateDownloadFAB(true)
|
animateDownloadFAB(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
with(reader_fab_fullscreen) {
|
with(binding.retryFab) {
|
||||||
|
setImageResource(R.drawable.refresh)
|
||||||
|
setOnClickListener {
|
||||||
|
DownloadService.download(context, galleryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with(binding.autoFab) {
|
||||||
|
setImageResource(R.drawable.eye_white)
|
||||||
|
setOnClickListener {
|
||||||
|
when {
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
|
||||||
|
toggleCamera()
|
||||||
|
}
|
||||||
|
Build.VERSION.SDK_INT >= 23 && shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
|
||||||
|
AlertDialog.Builder(this@ReaderActivity)
|
||||||
|
.setTitle(R.string.warning)
|
||||||
|
.setMessage(R.string.camera_denied)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with(binding.fullscreenFab) {
|
||||||
setImageResource(R.drawable.ic_fullscreen)
|
setImageResource(R.drawable.ic_fullscreen)
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
isFullscreen = true
|
isFullscreen = true
|
||||||
fullscreen(isFullscreen)
|
fullscreen(isFullscreen)
|
||||||
|
|
||||||
this@ReaderActivity.reader_fab.close(true)
|
binding.fab.close(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,41 +431,58 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN
|
flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||||
supportActionBar?.hide()
|
supportActionBar?.hide()
|
||||||
this@ReaderActivity.reader_fab.visibility = View.INVISIBLE
|
binding.fab.visibility = View.INVISIBLE
|
||||||
|
binding.scroller.let {
|
||||||
|
it.handleWidth = resources.getDimensionPixelSize(R.dimen.thumb_height)
|
||||||
|
it.handleHeight = resources.getDimensionPixelSize(R.dimen.thumb_width)
|
||||||
|
it.handleDrawable = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.thumb_horizontal)
|
||||||
|
it.fastScrollDirection = RecyclerViewFastScroller.FastScrollDirection.HORIZONTAL
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
flags = flags and WindowManager.LayoutParams.FLAG_FULLSCREEN.inv()
|
flags = flags and WindowManager.LayoutParams.FLAG_FULLSCREEN.inv()
|
||||||
supportActionBar?.show()
|
supportActionBar?.show()
|
||||||
this@ReaderActivity.reader_fab.visibility = View.VISIBLE
|
binding.fab.visibility = View.VISIBLE
|
||||||
|
binding.scroller.let {
|
||||||
|
it.handleWidth = resources.getDimensionPixelSize(R.dimen.thumb_width)
|
||||||
|
it.handleHeight = resources.getDimensionPixelSize(R.dimen.thumb_height)
|
||||||
|
it.handleDrawable = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.thumb)
|
||||||
|
it.fastScrollDirection = RecyclerViewFastScroller.FastScrollDirection.VERTICAL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.attributes = this
|
window.attributes = this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.recyclerview.adapter = binding.recyclerview.adapter // Force to redraw
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scrollMode(isScroll: Boolean) {
|
private fun scrollMode(isScroll: Boolean) {
|
||||||
if (isScroll) {
|
if (isScroll) {
|
||||||
snapHelper.attachToRecyclerView(null)
|
snapHelper.attachToRecyclerView(null)
|
||||||
reader_recyclerview.layoutManager = LinearLayoutManager(this)
|
binding.recyclerview.layoutManager = LinearLayoutManager(this)
|
||||||
} else {
|
} else {
|
||||||
snapHelper.attachToRecyclerView(reader_recyclerview)
|
snapHelper.attachToRecyclerView(binding.recyclerview)
|
||||||
reader_recyclerview.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
|
binding.recyclerview.layoutManager = object: LinearLayoutManager(this, HORIZONTAL, Preferences["rtl", false]) {
|
||||||
|
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
|
||||||
|
extraLayoutSpace.fill(600)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(reader_recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0)
|
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun animateDownloadFAB(animate: Boolean) {
|
private fun animateDownloadFAB(animate: Boolean) {
|
||||||
with(reader_fab_download) {
|
with(binding.downloadFab) {
|
||||||
if (animate) {
|
if (animate) {
|
||||||
val icon = AnimatedVectorDrawableCompat.create(context, R.drawable.ic_downloading)
|
val icon = AnimatedVectorDrawableCompat.create(context, R.drawable.ic_downloading)
|
||||||
|
|
||||||
icon?.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() {
|
icon?.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() {
|
||||||
override fun onAnimationEnd(drawable: Drawable?) {
|
override fun onAnimationEnd(drawable: Drawable?) {
|
||||||
val worker = DownloadWorker.getInstance(context)
|
if (downloader?.isCompleted(galleryID) == true) // If download is finished, stop animating
|
||||||
if (worker.progress[galleryID]?.all { !it.isFinite() } == true) // If download is finished, stop animating
|
|
||||||
post {
|
post {
|
||||||
setImageResource(R.drawable.ic_download)
|
setImageResource(R.drawable.ic_download)
|
||||||
labelText = getString(R.string.reader_fab_download)
|
labelText = getString(R.string.reader_fab_download_cancel)
|
||||||
}
|
}
|
||||||
else // Or continue animate
|
else // Or continue animate
|
||||||
post {
|
post {
|
||||||
@@ -399,4 +500,120 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val cameraCallback: (List<Face>) -> Unit = callback@{ faces ->
|
||||||
|
binding.eyeCard.dot.let {
|
||||||
|
it.visibility = View.VISIBLE
|
||||||
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
delay(50)
|
||||||
|
it.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (faces.size != 1)
|
||||||
|
ContextCompat.getDrawable(this, R.drawable.eye_off).let {
|
||||||
|
with(binding.eyeCard) {
|
||||||
|
leftEye.setImageDrawable(it)
|
||||||
|
rightEye.setImageDrawable(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return@callback
|
||||||
|
}
|
||||||
|
|
||||||
|
val (left, right) = Pair(
|
||||||
|
faces[0].rightEyeOpenProbability?.let { it > 0.4 } == true,
|
||||||
|
faces[0].leftEyeOpenProbability?.let { it > 0.4 } == true
|
||||||
|
)
|
||||||
|
|
||||||
|
with(binding.eyeCard) {
|
||||||
|
leftEye.setImageDrawable(
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
leftEye.context,
|
||||||
|
if (left) R.drawable.eye else R.drawable.eye_closed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rightEye.setImageDrawable(
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
rightEye.context,
|
||||||
|
if (right) R.drawable.eye else R.drawable.eye_closed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
// Both closed / opened
|
||||||
|
!left.xor(right) -> {
|
||||||
|
eyeType = null
|
||||||
|
eyeTime = 0L
|
||||||
|
}
|
||||||
|
!left -> {
|
||||||
|
if (eyeType != Eye.LEFT) {
|
||||||
|
eyeType = Eye.LEFT
|
||||||
|
eyeTime = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!right -> {
|
||||||
|
if (eyeType != Eye.RIGHT) {
|
||||||
|
eyeType = Eye.RIGHT
|
||||||
|
eyeTime = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eyeType != null && System.currentTimeMillis() - eyeTime > 100) {
|
||||||
|
(binding.recyclerview.layoutManager as LinearLayoutManager).let {
|
||||||
|
it.scrollToPositionWithOffset(when(eyeType!!) {
|
||||||
|
Eye.RIGHT -> {
|
||||||
|
if (it.reverseLayout) currentPage - 2 else currentPage
|
||||||
|
}
|
||||||
|
Eye.LEFT -> {
|
||||||
|
if (it.reverseLayout) currentPage else currentPage - 2
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
eyeTime = System.currentTimeMillis() + 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleCamera() {
|
||||||
|
val eyes = binding.eyeCard.root
|
||||||
|
when (camera) {
|
||||||
|
null -> {
|
||||||
|
binding.autoFab.labelText = getString(R.string.reader_fab_auto_cancel)
|
||||||
|
binding.autoFab.setImageResource(R.drawable.eye_off_white)
|
||||||
|
eyes.apply {
|
||||||
|
visibility = View.VISIBLE
|
||||||
|
TranslateAnimation(0F, 0F, -100F, 0F).apply {
|
||||||
|
duration = 500
|
||||||
|
fillAfter = false
|
||||||
|
interpolator = OvershootInterpolator()
|
||||||
|
}.let { startAnimation(it) }
|
||||||
|
}
|
||||||
|
startCamera(this, cameraCallback)
|
||||||
|
cameraEnabled = true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
binding.autoFab.labelText = getString(R.string.reader_fab_auto)
|
||||||
|
binding.autoFab.setImageResource(R.drawable.eye_white)
|
||||||
|
eyes.apply {
|
||||||
|
TranslateAnimation(0F, 0F, 0F, -100F).apply {
|
||||||
|
duration = 500
|
||||||
|
fillAfter = false
|
||||||
|
interpolator = AnticipateInterpolator()
|
||||||
|
setAnimationListener(object: Animation.AnimationListener {
|
||||||
|
override fun onAnimationStart(p0: Animation?) {}
|
||||||
|
override fun onAnimationRepeat(p0: Animation?) {}
|
||||||
|
|
||||||
|
override fun onAnimationEnd(p0: Animation?) {
|
||||||
|
eyes.visibility = View.GONE
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}.let { startAnimation(it) }
|
||||||
|
}
|
||||||
|
closeCamera()
|
||||||
|
cameraEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,42 +18,15 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.ui
|
package xyz.quaver.pupil.ui
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import kotlinx.android.synthetic.main.settings_activity.*
|
|
||||||
import kotlinx.serialization.ImplicitReflectionSerializer
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.parseList
|
|
||||||
import net.rdrei.android.dirchooser.DirectoryChooserActivity
|
|
||||||
import xyz.quaver.pupil.Pupil
|
|
||||||
import xyz.quaver.pupil.R
|
import xyz.quaver.pupil.R
|
||||||
import xyz.quaver.pupil.ui.fragment.LockFragment
|
|
||||||
import xyz.quaver.pupil.ui.fragment.SettingsFragment
|
import xyz.quaver.pupil.ui.fragment.SettingsFragment
|
||||||
import xyz.quaver.pupil.util.REQUEST_DOWNLOAD_FOLDER
|
|
||||||
import xyz.quaver.pupil.util.REQUEST_DOWNLOAD_FOLDER_OLD
|
|
||||||
import xyz.quaver.pupil.util.REQUEST_LOCK
|
|
||||||
import xyz.quaver.pupil.util.REQUEST_RESTORE
|
|
||||||
import java.io.File
|
|
||||||
import java.nio.charset.Charset
|
|
||||||
|
|
||||||
class SettingsActivity : AppCompatActivity() {
|
class SettingsActivity : BaseActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
window.setFlags(
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
|
|
||||||
setContentView(R.layout.settings_activity)
|
setContentView(R.layout.settings_activity)
|
||||||
supportFragmentManager
|
supportFragmentManager
|
||||||
.beginTransaction()
|
.beginTransaction()
|
||||||
@@ -62,95 +35,11 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
when (item.itemId) {
|
||||||
|
|
||||||
if (preferences.getBoolean("security_mode", false))
|
|
||||||
window.setFlags(
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
else
|
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
super.onResume()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
|
|
||||||
when (item?.itemId) {
|
|
||||||
android.R.id.home -> onBackPressed()
|
android.R.id.home -> onBackPressed()
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
|
||||||
when(requestCode) {
|
|
||||||
REQUEST_LOCK -> {
|
|
||||||
if (resultCode == Activity.RESULT_OK) {
|
|
||||||
supportFragmentManager
|
|
||||||
.beginTransaction()
|
|
||||||
.replace(R.id.settings, LockFragment())
|
|
||||||
.addToBackStack("Lock")
|
|
||||||
.commitAllowingStateLoss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
REQUEST_RESTORE -> {
|
|
||||||
if (resultCode == Activity.RESULT_OK) {
|
|
||||||
val uri = data?.data ?: return
|
|
||||||
|
|
||||||
try {
|
|
||||||
val json = contentResolver.openInputStream(uri).use { inputStream ->
|
|
||||||
inputStream!!
|
|
||||||
|
|
||||||
inputStream.readBytes().toString(Charset.defaultCharset())
|
|
||||||
}
|
|
||||||
|
|
||||||
(application as Pupil).favorites.addAll(Json.parseList<Int>(json).also {
|
|
||||||
Snackbar.make(
|
|
||||||
window.decorView,
|
|
||||||
getString(R.string.settings_restore_successful, it.size),
|
|
||||||
Snackbar.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
})
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Snackbar.make(
|
|
||||||
window.decorView,
|
|
||||||
R.string.settings_restore_failed,
|
|
||||||
Snackbar.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
REQUEST_DOWNLOAD_FOLDER -> {
|
|
||||||
if (resultCode == Activity.RESULT_OK) {
|
|
||||||
data?.data?.also { uri ->
|
|
||||||
val takeFlags: Int = intent.flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
|
|
||||||
contentResolver.takePersistableUriPermission(uri, takeFlags)
|
|
||||||
|
|
||||||
if (DocumentFile.fromTreeUri(this, uri)?.canWrite() == false)
|
|
||||||
Snackbar.make(settings, R.string.settings_dl_location_not_writable, Snackbar.LENGTH_LONG).show()
|
|
||||||
else
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(this).edit()
|
|
||||||
.putString("dl_location", uri.toString())
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
REQUEST_DOWNLOAD_FOLDER_OLD -> {
|
|
||||||
if (resultCode == DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED) {
|
|
||||||
val directory = data?.getStringExtra(DirectoryChooserActivity.RESULT_SELECTED_DIR)!!
|
|
||||||
|
|
||||||
if (!File(directory).canWrite())
|
|
||||||
Snackbar.make(settings, R.string.settings_dl_location_not_writable, Snackbar.LENGTH_LONG).show()
|
|
||||||
else
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(this).edit()
|
|
||||||
.putString("dl_location", Uri.fromFile(File(directory)).toString())
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> super.onActivityResult(requestCode, resultCode, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
382
app/src/main/java/xyz/quaver/pupil/ui/TransferActivity.kt
Normal file
382
app/src/main/java/xyz/quaver/pupil/ui/TransferActivity.kt
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
package xyz.quaver.pupil.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.wifi.WpsInfo
|
||||||
|
import android.net.wifi.p2p.WifiP2pConfig
|
||||||
|
import android.net.wifi.p2p.WifiP2pDevice
|
||||||
|
import android.net.wifi.p2p.WifiP2pDeviceList
|
||||||
|
import android.net.wifi.p2p.WifiP2pInfo
|
||||||
|
import android.net.wifi.p2p.WifiP2pManager
|
||||||
|
import android.os.Build.VERSION
|
||||||
|
import android.os.Build.VERSION_CODES
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.commit
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.flowWithLifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.consumeEach
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.receiver.WifiDirectBroadcastReceiver
|
||||||
|
import xyz.quaver.pupil.services.TransferClientService
|
||||||
|
import xyz.quaver.pupil.services.TransferPacket
|
||||||
|
import xyz.quaver.pupil.services.TransferServerService
|
||||||
|
import xyz.quaver.pupil.ui.fragment.TransferConnectedFragment
|
||||||
|
import xyz.quaver.pupil.ui.fragment.TransferDirectionFragment
|
||||||
|
import xyz.quaver.pupil.ui.fragment.TransferPermissionFragment
|
||||||
|
import xyz.quaver.pupil.ui.fragment.TransferSelectDataFragment
|
||||||
|
import xyz.quaver.pupil.ui.fragment.TransferTargetFragment
|
||||||
|
import xyz.quaver.pupil.ui.fragment.TransferWaitForConnectionFragment
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
class TransferActivity : AppCompatActivity(R.layout.transfer_activity) {
|
||||||
|
|
||||||
|
private val viewModel: TransferViewModel by viewModels()
|
||||||
|
|
||||||
|
private val intentFilter = IntentFilter().apply {
|
||||||
|
addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
|
||||||
|
addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
|
||||||
|
addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
|
||||||
|
addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var manager: WifiP2pManager
|
||||||
|
private lateinit var channel: WifiP2pManager.Channel
|
||||||
|
|
||||||
|
private var receiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
private val requestPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
viewModel.setStep(TransferStep.TARGET)
|
||||||
|
} else {
|
||||||
|
viewModel.setStep(TransferStep.PERMISSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clientServiceBinder: TransferClientService.Binder? = null
|
||||||
|
|
||||||
|
private val clientServiceConnection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
clientServiceBinder = service as TransferClientService.Binder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
clientServiceBinder = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkPermission(force: Boolean = false): Boolean {
|
||||||
|
val permissionRequired = if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) {
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
} else {
|
||||||
|
Manifest.permission.NEARBY_WIFI_DEVICES
|
||||||
|
}
|
||||||
|
|
||||||
|
val permissionGranted =
|
||||||
|
ActivityCompat.checkSelfPermission(this, permissionRequired) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
val shouldShowRationale =
|
||||||
|
ActivityCompat.shouldShowRequestPermissionRationale(this, permissionRequired)
|
||||||
|
|
||||||
|
if (!permissionGranted) {
|
||||||
|
if (shouldShowRationale && force) {
|
||||||
|
viewModel.setStep(TransferStep.PERMISSION)
|
||||||
|
} else {
|
||||||
|
requestPermissionLauncher.launch(permissionRequired)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleServerResponse(response: TransferPacket?) {
|
||||||
|
when (response) {
|
||||||
|
is TransferPacket.ListResponse -> {
|
||||||
|
Log.d("PUPILD", "Received list response $response")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.d("PUPILD", "Received invalid response $response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun WifiP2pManager.disconnect() {
|
||||||
|
suspendCoroutine { continuation ->
|
||||||
|
removeGroup(channel, object : WifiP2pManager.ActionListener {
|
||||||
|
override fun onSuccess() {
|
||||||
|
continuation.resume(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(reason: Int) {
|
||||||
|
continuation.resume(Unit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
suspendCoroutine { continuation ->
|
||||||
|
cancelConnect(channel, object: WifiP2pManager.ActionListener {
|
||||||
|
override fun onSuccess() {
|
||||||
|
continuation.resume(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(reason: Int) {
|
||||||
|
continuation.resume(Unit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SourceLockedOrientationActivity")
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
supportActionBar?.hide()
|
||||||
|
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
|
||||||
|
manager = getSystemService(WIFI_P2P_SERVICE) as WifiP2pManager
|
||||||
|
channel = manager.initialize(this, mainLooper, null)
|
||||||
|
|
||||||
|
viewModel.peerToConnect.observe(this) { peer ->
|
||||||
|
if (peer == null) { return@observe }
|
||||||
|
if (!checkPermission()) { return@observe }
|
||||||
|
|
||||||
|
val config = WifiP2pConfig().apply {
|
||||||
|
deviceAddress = peer.deviceAddress
|
||||||
|
wps.setup = WpsInfo.PBC
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.connect(channel, config, object: WifiP2pManager.ActionListener {
|
||||||
|
override fun onSuccess() { }
|
||||||
|
|
||||||
|
override fun onFailure(reason: Int) {
|
||||||
|
viewModel.connect(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.messageQueue.consumeEach {
|
||||||
|
clientServiceBinder?.sendPacket(it)?.getOrNull()?.let(::handleServerResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.step.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collectLatest step@{ step ->
|
||||||
|
when (step) {
|
||||||
|
TransferStep.TARGET,
|
||||||
|
TransferStep.TARGET_FORCE -> {
|
||||||
|
if (!checkPermission(step == TransferStep.TARGET_FORCE)) {
|
||||||
|
return@step
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.discoverPeers(channel, object: WifiP2pManager.ActionListener {
|
||||||
|
override fun onSuccess() { }
|
||||||
|
|
||||||
|
override fun onFailure(reason: Int) {
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
supportFragmentManager.commit(true) {
|
||||||
|
replace(R.id.fragment_container_view, TransferTargetFragment())
|
||||||
|
}
|
||||||
|
|
||||||
|
val hostAddress = viewModel.connectionInfo.filterNotNull().first {
|
||||||
|
it.groupFormed
|
||||||
|
}.groupOwnerAddress.hostAddress
|
||||||
|
|
||||||
|
val intent = Intent(this@TransferActivity, TransferClientService::class.java).also {
|
||||||
|
it.putExtra("address", hostAddress)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(this@TransferActivity, intent)
|
||||||
|
bindService(intent, clientServiceConnection, BIND_AUTO_CREATE)
|
||||||
|
|
||||||
|
viewModel.setStep(TransferStep.SELECT_DATA)
|
||||||
|
}
|
||||||
|
TransferStep.DIRECTION -> {
|
||||||
|
manager.disconnect()
|
||||||
|
supportFragmentManager.commit(true) {
|
||||||
|
replace(R.id.fragment_container_view, TransferDirectionFragment())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TransferStep.PERMISSION -> {
|
||||||
|
supportFragmentManager.commit(true) {
|
||||||
|
replace(R.id.fragment_container_view, TransferPermissionFragment())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TransferStep.WAIT_FOR_CONNECTION -> {
|
||||||
|
Log.d("PUPILD", "wait for connection")
|
||||||
|
if (!checkPermission()) { return@step }
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
suspendCoroutine { continuation ->
|
||||||
|
manager.createGroup(channel, object: WifiP2pManager.ActionListener {
|
||||||
|
override fun onSuccess() {
|
||||||
|
continuation.resume(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(reason: Int) {
|
||||||
|
continuation.resumeWithException(Exception("Failed to create group $reason"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
supportFragmentManager.commit(true) {
|
||||||
|
replace(R.id.fragment_container_view, TransferWaitForConnectionFragment())
|
||||||
|
}
|
||||||
|
|
||||||
|
val address = viewModel.connectionInfo.filterNotNull().first {
|
||||||
|
it.groupFormed && it.isGroupOwner
|
||||||
|
}.groupOwnerAddress.hostAddress
|
||||||
|
|
||||||
|
val intent = Intent(this@TransferActivity, TransferServerService::class.java).also {
|
||||||
|
it.putExtra("address", address)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(this@TransferActivity, intent)
|
||||||
|
val binder: TransferServerService.Binder = suspendCoroutine { continuation ->
|
||||||
|
bindService(intent, object: ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
continuation.resume(service as TransferServerService.Binder)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) { }
|
||||||
|
}, BIND_AUTO_CREATE)
|
||||||
|
}
|
||||||
|
binder.channel.receive()
|
||||||
|
|
||||||
|
viewModel.setStep(TransferStep.CONNECTED)
|
||||||
|
}.onFailure {
|
||||||
|
Log.e("PUPILD", "Failed to create group", it)
|
||||||
|
}
|
||||||
|
|
||||||
|
supportFragmentManager.commit(true) {
|
||||||
|
replace(R.id.fragment_container_view, TransferWaitForConnectionFragment())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TransferStep.CONNECTED -> {
|
||||||
|
supportFragmentManager.commit(true) {
|
||||||
|
replace(R.id.fragment_container_view, TransferConnectedFragment())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TransferStep.SELECT_DATA -> {
|
||||||
|
supportFragmentManager.commit(true) {
|
||||||
|
replace(R.id.fragment_container_view, TransferSelectDataFragment())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
bindService(Intent(this, TransferClientService::class.java), clientServiceConnection, BIND_AUTO_CREATE)
|
||||||
|
WifiDirectBroadcastReceiver(manager, channel, viewModel).also {
|
||||||
|
receiver = it
|
||||||
|
registerReceiver(it, intentFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
unbindService(clientServiceConnection)
|
||||||
|
receiver?.let { unregisterReceiver(it) }
|
||||||
|
receiver = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class TransferStep {
|
||||||
|
TARGET, TARGET_FORCE, DIRECTION, PERMISSION, WAIT_FOR_CONNECTION, CONNECTED, SELECT_DATA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ErrorType {
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransferViewModel : ViewModel() {
|
||||||
|
private val _step: MutableStateFlow<TransferStep> = MutableStateFlow(TransferStep.DIRECTION)
|
||||||
|
val step: StateFlow<TransferStep> = _step
|
||||||
|
|
||||||
|
private val _error = MutableLiveData<ErrorType?>(null)
|
||||||
|
val error: LiveData<ErrorType?> = _error
|
||||||
|
|
||||||
|
private val _wifiP2pEnabled: MutableLiveData<Boolean> = MutableLiveData(false)
|
||||||
|
val wifiP2pEnabled: LiveData<Boolean> = _wifiP2pEnabled
|
||||||
|
|
||||||
|
private val _thisDevice: MutableLiveData<WifiP2pDevice?> = MutableLiveData(null)
|
||||||
|
val thisDevice: LiveData<WifiP2pDevice?> = _thisDevice
|
||||||
|
|
||||||
|
private val _peers: MutableLiveData<WifiP2pDeviceList?> = MutableLiveData(null)
|
||||||
|
val peers: LiveData<WifiP2pDeviceList?> = _peers
|
||||||
|
|
||||||
|
private val _connectionInfo: MutableStateFlow<WifiP2pInfo?> = MutableStateFlow(null)
|
||||||
|
val connectionInfo: StateFlow<WifiP2pInfo?> = _connectionInfo
|
||||||
|
|
||||||
|
private val _peerToConnect: MutableLiveData<WifiP2pDevice?> = MutableLiveData(null)
|
||||||
|
val peerToConnect: LiveData<WifiP2pDevice?> = _peerToConnect
|
||||||
|
|
||||||
|
val messageQueue: Channel<TransferPacket> = Channel()
|
||||||
|
|
||||||
|
fun setStep(step: TransferStep) {
|
||||||
|
Log.d("PUPILD", "Set step: $step")
|
||||||
|
_step.value = step
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWifiP2pEnabled(enabled: Boolean) {
|
||||||
|
_wifiP2pEnabled.value = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setThisDevice(device: WifiP2pDevice?) {
|
||||||
|
_thisDevice.value = device
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPeers(peers: WifiP2pDeviceList?) {
|
||||||
|
_peers.value = peers
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setConnectionInfo(info: WifiP2pInfo?) {
|
||||||
|
_connectionInfo.value = info
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setError(error: ErrorType?) {
|
||||||
|
_error.value = error
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(device: WifiP2pDevice?) {
|
||||||
|
_peerToConnect.value = device
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ping() {
|
||||||
|
messageQueue.trySend(TransferPacket.Ping)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun list() {
|
||||||
|
messageQueue.trySend(TransferPacket.ListRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,76 +18,46 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.ui.dialog
|
package xyz.quaver.pupil.ui.dialog
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.content.Context
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.fragment.app.DialogFragment
|
||||||
import kotlinx.android.synthetic.main.dialog_default_query.*
|
|
||||||
import kotlinx.android.synthetic.main.dialog_default_query.view.*
|
|
||||||
import xyz.quaver.pupil.R
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.databinding.DefaultQueryDialogBinding
|
||||||
import xyz.quaver.pupil.types.Tags
|
import xyz.quaver.pupil.types.Tags
|
||||||
|
import xyz.quaver.pupil.util.Preferences
|
||||||
|
|
||||||
class DefaultQueryDialog(context : Context) : AlertDialog(context) {
|
class DefaultQueryDialog : DialogFragment() {
|
||||||
|
|
||||||
private val languages = context.resources.getStringArray(R.array.languages).map {
|
private val languages: Map<String, String> by lazy {
|
||||||
|
requireContext().resources.getStringArray(R.array.languages).map {
|
||||||
it.split("|").let { split ->
|
it.split("|").let { split ->
|
||||||
Pair(split[0], split[1])
|
Pair(split[0], split[1])
|
||||||
}
|
}
|
||||||
}.toMap()
|
}.toMap()
|
||||||
private val reverseLanguages = languages.entries.associate { (k, v) -> v to k }
|
}
|
||||||
|
private val reverseLanguages: Map<String, String> by lazy {
|
||||||
|
languages.entries.associate { (k, v) -> v to k }
|
||||||
|
}
|
||||||
|
|
||||||
private val excludeBL = "-male:yaoi"
|
private val excludeBL = "-male:yaoi"
|
||||||
private val excludeGuro = listOf("-female:guro", "-male:guro")
|
private val excludeGuro = listOf("-female:guro", "-male:guro")
|
||||||
|
private val excludeLoli = listOf("-female:loli", "-male:shota")
|
||||||
private lateinit var dialogView : View
|
|
||||||
|
|
||||||
var onPositiveButtonClickListener : ((Tags) -> (Unit))? = null
|
var onPositiveButtonClickListener : ((Tags) -> (Unit))? = null
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
private var _binding: DefaultQueryDialogBinding? = null
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
private val binding get() = _binding!!
|
||||||
initDialog()
|
|
||||||
|
|
||||||
setTitle(R.string.default_query_dialog_title)
|
private fun initView() {
|
||||||
setView(dialogView)
|
|
||||||
setButton(Dialog.BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ ->
|
|
||||||
val newTags = Tags.parse(default_query_dialog_edittext.text.toString())
|
|
||||||
|
|
||||||
with(default_query_dialog_language_selector) {
|
|
||||||
if (selectedItemPosition != 0)
|
|
||||||
newTags.add("language:${reverseLanguages[selectedItem]}")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (default_query_dialog_BL_checkbox.isChecked)
|
|
||||||
newTags.add(excludeBL)
|
|
||||||
|
|
||||||
if (default_query_dialog_guro_checkbox.isChecked)
|
|
||||||
excludeGuro.forEach { tag ->
|
|
||||||
newTags.add(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
onPositiveButtonClickListener?.invoke(newTags)
|
|
||||||
}
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
private fun initDialog() {
|
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
val tags = Tags.parse(
|
val tags = Tags.parse(
|
||||||
preferences.getString("default_query", "") ?: ""
|
Preferences["default_query"]
|
||||||
)
|
)
|
||||||
|
|
||||||
dialogView = LayoutInflater.from(context).inflate(R.layout.dialog_default_query, null)
|
with(binding.languageSelector) {
|
||||||
|
|
||||||
with(dialogView.default_query_dialog_language_selector) {
|
|
||||||
adapter =
|
adapter =
|
||||||
ArrayAdapter(
|
ArrayAdapter(
|
||||||
context,
|
context,
|
||||||
@@ -110,13 +80,13 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with(dialogView.default_query_dialog_BL_checkbox) {
|
with(binding.BLCheckbox) {
|
||||||
isChecked = tags.contains(excludeBL)
|
isChecked = tags.contains(excludeBL)
|
||||||
if (tags.contains(excludeBL))
|
if (tags.contains(excludeBL))
|
||||||
tags.remove(excludeBL)
|
tags.remove(excludeBL)
|
||||||
}
|
}
|
||||||
|
|
||||||
with(dialogView.default_query_dialog_guro_checkbox) {
|
with(binding.guroCheckbox) {
|
||||||
isChecked = excludeGuro.all { tags.contains(it) }
|
isChecked = excludeGuro.all { tags.contains(it) }
|
||||||
if (excludeGuro.all { tags.contains(it) })
|
if (excludeGuro.all { tags.contains(it) })
|
||||||
excludeGuro.forEach {
|
excludeGuro.forEach {
|
||||||
@@ -124,7 +94,15 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with(dialogView.default_query_dialog_edittext) {
|
with(binding.loliCheckbox) {
|
||||||
|
isChecked = excludeLoli.all { tags.contains(it) }
|
||||||
|
if (excludeLoli.all { tags.contains(it) })
|
||||||
|
excludeLoli.forEach {
|
||||||
|
tags.remove(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with(binding.edittext) {
|
||||||
setText(tags.toString(), android.widget.TextView.BufferType.EDITABLE)
|
setText(tags.toString(), android.widget.TextView.BufferType.EDITABLE)
|
||||||
addTextChangedListener(object : TextWatcher {
|
addTextChangedListener(object : TextWatcher {
|
||||||
override fun beforeTextChanged(
|
override fun beforeTextChanged(
|
||||||
@@ -151,4 +129,43 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
_binding = DefaultQueryDialogBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
|
initView()
|
||||||
|
|
||||||
|
return AlertDialog.Builder(requireContext()).apply {
|
||||||
|
setTitle(R.string.default_query_dialog_title)
|
||||||
|
setView(binding.root)
|
||||||
|
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
val newTags = Tags.parse(binding.edittext.text.toString())
|
||||||
|
|
||||||
|
with(binding.languageSelector) {
|
||||||
|
if (selectedItemPosition != 0)
|
||||||
|
newTags.add("language:${reverseLanguages[selectedItem]}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.BLCheckbox.isChecked)
|
||||||
|
newTags.add(excludeBL)
|
||||||
|
|
||||||
|
if (binding.guroCheckbox.isChecked)
|
||||||
|
excludeGuro.forEach { tag ->
|
||||||
|
newTags.add(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.loliCheckbox.isChecked)
|
||||||
|
excludeLoli.forEach { tag ->
|
||||||
|
newTags.add(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
onPositiveButtonClickListener?.invoke(newTags)
|
||||||
|
}
|
||||||
|
}.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.ui.dialog
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.databinding.DownloadFolderNameDialogBinding
|
||||||
|
import xyz.quaver.pupil.util.Preferences
|
||||||
|
import xyz.quaver.pupil.util.downloader.Cache
|
||||||
|
import xyz.quaver.pupil.util.formatDownloadFolder
|
||||||
|
import xyz.quaver.pupil.util.formatDownloadFolderTest
|
||||||
|
import xyz.quaver.pupil.util.formatMap
|
||||||
|
|
||||||
|
class DownloadFolderNameDialogFragment : DialogFragment() {
|
||||||
|
|
||||||
|
private var _binding: DownloadFolderNameDialogBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
_binding = DownloadFolderNameDialogBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
|
initView()
|
||||||
|
|
||||||
|
return Dialog(requireContext()).apply {
|
||||||
|
setContentView(binding.root)
|
||||||
|
window?.attributes?.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initView() {
|
||||||
|
val galleryID = Cache.instances.let { if (it.size == 0) 1199708 else it.keys.elementAt((0 until it.size).random()) }
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val galleryBlock = Cache.getInstance(requireContext(), galleryID).getGalleryBlock()
|
||||||
|
|
||||||
|
binding.message.text = getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolder() ?: "")
|
||||||
|
binding.edittext.addTextChangedListener {
|
||||||
|
binding.message.text = requireContext().getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolderTest(it.toString()) ?: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.edittext.setText(Preferences["download_folder_name", "[-id-] -title-"])
|
||||||
|
binding.okButton.setOnClickListener {
|
||||||
|
val newValue = binding.edittext.text.toString()
|
||||||
|
|
||||||
|
if ((newValue as? String)?.contains("/") != false) {
|
||||||
|
Snackbar.make(binding.root, R.string.settings_invalid_download_folder_name, Snackbar.LENGTH_SHORT).show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
Preferences["download_folder_name"] = binding.edittext.text.toString()
|
||||||
|
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pupil, Hitomi.la viewer for Android
|
|
||||||
* Copyright (C) 2020 tom5079
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.ui.dialog
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Activity
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.RadioButton
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import kotlinx.android.synthetic.main.item_dl_location.view.*
|
|
||||||
import net.rdrei.android.dirchooser.DirectoryChooserActivity
|
|
||||||
import net.rdrei.android.dirchooser.DirectoryChooserConfig
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.util.REQUEST_DOWNLOAD_FOLDER
|
|
||||||
import xyz.quaver.pupil.util.REQUEST_DOWNLOAD_FOLDER_OLD
|
|
||||||
import xyz.quaver.pupil.util.byteToString
|
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
class DownloadLocationDialog(val activity: Activity) : AlertDialog(activity) {
|
|
||||||
|
|
||||||
private val preference = PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
private val buttons = mutableListOf<Pair<RadioButton, Uri?>>()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
val view = layoutInflater.inflate(R.layout.dialog_dl_location, null) as LinearLayout
|
|
||||||
|
|
||||||
val externalFilesDirs = ContextCompat.getExternalFilesDirs(context, null)
|
|
||||||
|
|
||||||
externalFilesDirs.forEachIndexed { index, dir ->
|
|
||||||
|
|
||||||
dir ?: return@forEachIndexed
|
|
||||||
|
|
||||||
view.addView(layoutInflater.inflate(R.layout.item_dl_location, view, false).apply {
|
|
||||||
location_type.text = context.getString(when (index) {
|
|
||||||
0 -> R.string.settings_dl_location_internal
|
|
||||||
else -> R.string.settings_dl_location_removable
|
|
||||||
})
|
|
||||||
location_available.text = context.getString(
|
|
||||||
R.string.settings_dl_location_available,
|
|
||||||
byteToString(dir.freeSpace)
|
|
||||||
)
|
|
||||||
setOnClickListener {
|
|
||||||
buttons.forEach { pair ->
|
|
||||||
pair.first.isChecked = false
|
|
||||||
}
|
|
||||||
button.performClick()
|
|
||||||
preference.edit().putString("dl_location", Uri.fromFile(dir).toString()).apply()
|
|
||||||
}
|
|
||||||
buttons.add(button to Uri.fromFile(dir))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
view.addView(layoutInflater.inflate(R.layout.item_dl_location, view, false).apply {
|
|
||||||
location_type.text = context.getString(R.string.settings_dl_location_custom)
|
|
||||||
setOnClickListener {
|
|
||||||
buttons.forEach { pair ->
|
|
||||||
pair.first.isChecked = false
|
|
||||||
}
|
|
||||||
button.performClick()
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
|
||||||
putExtra("android.content.extra.SHOW_ADVANCED", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
activity.startActivityForResult(intent, REQUEST_DOWNLOAD_FOLDER)
|
|
||||||
|
|
||||||
dismiss()
|
|
||||||
} else { // Can't use SAF on old Androids!
|
|
||||||
val config = DirectoryChooserConfig.builder()
|
|
||||||
.newDirectoryName("Pupil")
|
|
||||||
.allowNewDirectoryNameModification(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val intent = Intent(context, DirectoryChooserActivity::class.java).apply {
|
|
||||||
putExtra(DirectoryChooserActivity.EXTRA_CONFIG, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
activity.startActivityForResult(intent, REQUEST_DOWNLOAD_FOLDER_OLD)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buttons.add(button to null)
|
|
||||||
})
|
|
||||||
|
|
||||||
val pref = Uri.parse(preference.getString("dl_location", null))
|
|
||||||
val index = externalFilesDirs.indexOfFirst {
|
|
||||||
Uri.fromFile(it).toString() == pref.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index < 0)
|
|
||||||
buttons.last().first.isChecked = true
|
|
||||||
else
|
|
||||||
buttons[index].first.isChecked = true
|
|
||||||
|
|
||||||
setTitle(R.string.settings_dl_location)
|
|
||||||
|
|
||||||
setView(view)
|
|
||||||
|
|
||||||
setButton(Dialog.BUTTON_POSITIVE, context.getText(android.R.string.ok)) { _, _ ->
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.ui.dialog
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import net.rdrei.android.dirchooser.DirectoryChooserActivity
|
||||||
|
import net.rdrei.android.dirchooser.DirectoryChooserConfig
|
||||||
|
import xyz.quaver.io.FileX
|
||||||
|
import xyz.quaver.io.util.toFile
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.databinding.DownloadLocationDialogBinding
|
||||||
|
import xyz.quaver.pupil.databinding.DownloadLocationItemBinding
|
||||||
|
import xyz.quaver.pupil.util.Preferences
|
||||||
|
import xyz.quaver.pupil.util.byteToString
|
||||||
|
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class DownloadLocationDialogFragment : DialogFragment() {
|
||||||
|
|
||||||
|
private var _binding: DownloadLocationDialogBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val entries = mutableMapOf<File?, DownloadLocationItemBinding>()
|
||||||
|
|
||||||
|
private val requestDownloadFolderLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
|
if (it.resultCode == Activity.RESULT_OK) {
|
||||||
|
val context = context ?: return@registerForActivityResult
|
||||||
|
val dialog = dialog ?: return@registerForActivityResult
|
||||||
|
|
||||||
|
it.data?.data?.also { uri ->
|
||||||
|
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
|
||||||
|
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||||
|
|
||||||
|
if (kotlin.runCatching { FileX(context, uri).canWrite() }.getOrDefault(false)) {
|
||||||
|
entries[null]?.locationAvailable?.text = uri.toFile(context)?.canonicalPath
|
||||||
|
Preferences["download_folder"] = uri.toString()
|
||||||
|
} else {
|
||||||
|
Snackbar.make(
|
||||||
|
dialog.window!!.decorView.rootView,
|
||||||
|
R.string.settings_download_folder_not_writable,
|
||||||
|
Snackbar.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
|
||||||
|
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath
|
||||||
|
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
|
||||||
|
entries[key]!!.button.isChecked = true
|
||||||
|
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val downloadFolder = DownloadManager.getInstance(context ?: return@registerForActivityResult).downloadFolder.canonicalPath
|
||||||
|
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
|
||||||
|
if (key == null)
|
||||||
|
entries[key]!!.locationAvailable.text = downloadFolder
|
||||||
|
else {
|
||||||
|
entries[null]!!.button.isChecked = false
|
||||||
|
entries[key]!!.button.isChecked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val requestDownloadFolderOldLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
|
val context = context ?: return@registerForActivityResult
|
||||||
|
val dialog = dialog ?: return@registerForActivityResult
|
||||||
|
|
||||||
|
if (it.resultCode == DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED) {
|
||||||
|
val directory = it.data?.getStringExtra(DirectoryChooserActivity.RESULT_SELECTED_DIR)!!
|
||||||
|
|
||||||
|
if (!File(directory).canWrite()) {
|
||||||
|
Snackbar.make(
|
||||||
|
dialog.window!!.decorView.rootView,
|
||||||
|
R.string.settings_download_folder_not_writable,
|
||||||
|
Snackbar.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
|
||||||
|
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath
|
||||||
|
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
|
||||||
|
entries[key]!!.button.isChecked = true
|
||||||
|
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
entries[null]?.locationAvailable?.text = directory
|
||||||
|
Preferences["download_folder"] = File(directory).toURI().toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initView() {
|
||||||
|
val externalFilesDirs = ContextCompat.getExternalFilesDirs(requireContext(), null)
|
||||||
|
|
||||||
|
externalFilesDirs.forEachIndexed { index, dir ->
|
||||||
|
dir ?: return@forEachIndexed
|
||||||
|
|
||||||
|
DownloadLocationItemBinding.inflate(layoutInflater, binding.root, true).apply {
|
||||||
|
locationType.text = requireContext().getString(when (index) {
|
||||||
|
0 -> R.string.settings_download_folder_internal
|
||||||
|
else -> R.string.settings_download_folder_removable
|
||||||
|
})
|
||||||
|
locationAvailable.text = requireContext().getString(
|
||||||
|
R.string.settings_download_folder_available,
|
||||||
|
byteToString(dir.freeSpace)
|
||||||
|
)
|
||||||
|
root.setOnClickListener {
|
||||||
|
entries.values.forEach { entry ->
|
||||||
|
entry.button.isChecked = false
|
||||||
|
}
|
||||||
|
button.performClick()
|
||||||
|
Preferences["download_folder"] = dir.toUri().toString()
|
||||||
|
}
|
||||||
|
entries[dir] = this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DownloadLocationItemBinding.inflate(layoutInflater, binding.root, true).apply {
|
||||||
|
locationType.text = requireContext().getString(R.string.settings_download_folder_custom)
|
||||||
|
root.setOnClickListener {
|
||||||
|
entries.values.forEach { entry ->
|
||||||
|
entry.button.isChecked = false
|
||||||
|
}
|
||||||
|
button.performClick()
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||||
|
putExtra("android.content.extra.SHOW_ADVANCED", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestDownloadFolderLauncher.launch(intent)
|
||||||
|
} else { // Can't use SAF on old Androids!
|
||||||
|
val config = DirectoryChooserConfig.builder()
|
||||||
|
.newDirectoryName("Pupil")
|
||||||
|
.allowNewDirectoryNameModification(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val intent = Intent(context, DirectoryChooserActivity::class.java).apply {
|
||||||
|
putExtra(DirectoryChooserActivity.EXTRA_CONFIG, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestDownloadFolderOldLauncher.launch(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries[null] = this
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadFolder = DownloadManager.getInstance(requireContext()).downloadFolder.canonicalPath
|
||||||
|
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
|
||||||
|
entries[key]!!.button.isChecked = true
|
||||||
|
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
_binding = DownloadLocationDialogBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
|
initView()
|
||||||
|
|
||||||
|
return AlertDialog.Builder(requireContext()).apply {
|
||||||
|
setTitle(R.string.settings_download_folder)
|
||||||
|
setView(binding.root)
|
||||||
|
setPositiveButton(requireContext().getText(android.R.string.ok)) { _, _ ->
|
||||||
|
if (Preferences["download_folder", ""].isEmpty())
|
||||||
|
Preferences["download_folder"] = context.getExternalFilesDir(null)?.toUri()?.toString() ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
isCancelable = false
|
||||||
|
}.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,56 +18,49 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.ui.dialog
|
package xyz.quaver.pupil.ui.dialog
|
||||||
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.widget.LinearLayout.LayoutParams
|
import android.widget.LinearLayout.LayoutParams
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.bumptech.glide.Glide
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.google.android.material.chip.Chip
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import kotlinx.android.synthetic.main.dialog_gallery.*
|
|
||||||
import kotlinx.android.synthetic.main.gallery_details.view.*
|
|
||||||
import kotlinx.android.synthetic.main.item_gallery_details.view.*
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import xyz.quaver.hitomi.Gallery
|
import kotlinx.coroutines.withContext
|
||||||
import xyz.quaver.hitomi.GalleryBlock
|
import xyz.quaver.pupil.hitomi.Gallery
|
||||||
import xyz.quaver.hitomi.getGallery
|
import xyz.quaver.pupil.hitomi.getGallery
|
||||||
import xyz.quaver.pupil.BuildConfig
|
|
||||||
import xyz.quaver.pupil.Pupil
|
|
||||||
import xyz.quaver.pupil.R
|
import xyz.quaver.pupil.R
|
||||||
import xyz.quaver.pupil.adapters.GalleryBlockAdapter
|
import xyz.quaver.pupil.adapters.GalleryBlockAdapter
|
||||||
import xyz.quaver.pupil.adapters.ThumbnailAdapter
|
import xyz.quaver.pupil.adapters.ThumbnailPageAdapter
|
||||||
|
import xyz.quaver.pupil.databinding.*
|
||||||
|
import xyz.quaver.pupil.favoriteTags
|
||||||
import xyz.quaver.pupil.types.Tag
|
import xyz.quaver.pupil.types.Tag
|
||||||
import xyz.quaver.pupil.ui.ReaderActivity
|
import xyz.quaver.pupil.ui.ReaderActivity
|
||||||
|
import xyz.quaver.pupil.ui.view.TagChip
|
||||||
import xyz.quaver.pupil.util.ItemClickSupport
|
import xyz.quaver.pupil.util.ItemClickSupport
|
||||||
import xyz.quaver.pupil.util.download.Cache
|
import xyz.quaver.pupil.util.downloader.Cache
|
||||||
import xyz.quaver.pupil.util.wordCapitalize
|
import xyz.quaver.pupil.util.wordCapitalize
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
class GalleryDialog(context: Context, private val galleryID: Int) : Dialog(context) {
|
class GalleryDialog(context: Context, private val galleryID: Int) : AlertDialog(context) {
|
||||||
|
|
||||||
private val languages = context.resources.getStringArray(R.array.languages).map {
|
|
||||||
it.split("|").let { split ->
|
|
||||||
Pair(split[0], split[1])
|
|
||||||
}
|
|
||||||
}.toMap()
|
|
||||||
|
|
||||||
private val glide = Glide.with(context)
|
|
||||||
|
|
||||||
val onChipClickedHandler = ArrayList<((Tag) -> (Unit))>()
|
val onChipClickedHandler = ArrayList<((Tag) -> (Unit))>()
|
||||||
|
|
||||||
|
private lateinit var binding: GalleryDialogBinding
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.dialog_gallery)
|
binding = GalleryDialogBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
window?.attributes.apply {
|
window?.attributes.apply {
|
||||||
this ?: return@apply
|
this ?: return@apply
|
||||||
@@ -76,13 +69,12 @@ class GalleryDialog(context: Context, private val galleryID: Int) : Dialog(conte
|
|||||||
height = LayoutParams.MATCH_PARENT
|
height = LayoutParams.MATCH_PARENT
|
||||||
}
|
}
|
||||||
|
|
||||||
with(gallery_fab) {
|
with(binding.fab) {
|
||||||
setImageDrawable(ContextCompat.getDrawable(context, R.drawable.arrow_right))
|
setImageDrawable(ContextCompat.getDrawable(context, R.drawable.arrow_right))
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
context.startActivity(Intent(context, ReaderActivity::class.java).apply {
|
context.startActivity(Intent(context, ReaderActivity::class.java).apply {
|
||||||
putExtra("galleryID", galleryID)
|
putExtra("galleryID", galleryID)
|
||||||
})
|
})
|
||||||
(context.applicationContext as Pupil).histories.add(galleryID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,11 +83,11 @@ class GalleryDialog(context: Context, private val galleryID: Int) : Dialog(conte
|
|||||||
val gallery = getGallery(galleryID)
|
val gallery = getGallery(galleryID)
|
||||||
|
|
||||||
launch (Dispatchers.Main) {
|
launch (Dispatchers.Main) {
|
||||||
gallery_progressbar.visibility = View.GONE
|
binding.progressbar.visibility = View.GONE
|
||||||
gallery_title.text = gallery.title
|
binding.title.text = gallery.title
|
||||||
gallery_artist.text = gallery.artists.joinToString(", ") { it.wordCapitalize() }
|
binding.artist.text = gallery.artists.joinToString(", ") { it.wordCapitalize() }
|
||||||
|
|
||||||
with(gallery_type) {
|
with(binding.type) {
|
||||||
text = gallery.type.wordCapitalize()
|
text = gallery.type.wordCapitalize()
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
gallery.type.let {
|
gallery.type.let {
|
||||||
@@ -112,28 +104,26 @@ class GalleryDialog(context: Context, private val galleryID: Int) : Dialog(conte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Glide.with(context)
|
binding.cover.showImage(Uri.parse(gallery.cover))
|
||||||
.load(gallery.cover)
|
|
||||||
.apply {
|
|
||||||
if (BuildConfig.CENSOR)
|
|
||||||
override(5, 8)
|
|
||||||
}.into(gallery_cover)
|
|
||||||
|
|
||||||
addDetails(gallery)
|
addDetails(gallery)
|
||||||
addThumbnails(gallery)
|
addThumbnails(gallery)
|
||||||
addRelated(gallery)
|
addRelated(gallery)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Snackbar.make(gallery_layout, R.string.unable_to_connect, Snackbar.LENGTH_INDEFINITE).show()
|
Snackbar.make(binding.root, R.string.unable_to_connect, Snackbar.LENGTH_INDEFINITE).apply {
|
||||||
|
if (Locale.getDefault().language == "ko")
|
||||||
|
setAction(context.getText(R.string.https_text)) {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(context.getString(R.string.https))))
|
||||||
|
}
|
||||||
|
}.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addDetails(gallery: Gallery) {
|
private fun addDetails(gallery: Gallery) {
|
||||||
val inflater = LayoutInflater.from(context)
|
GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
|
||||||
|
type.setText(R.string.gallery_details)
|
||||||
inflater.inflate(R.layout.gallery_details, gallery_contents, false).apply {
|
|
||||||
gallery_details.setText(R.string.gallery_details)
|
|
||||||
|
|
||||||
listOf(
|
listOf(
|
||||||
R.string.gallery_artists,
|
R.string.gallery_artists,
|
||||||
@@ -149,7 +139,18 @@ class GalleryDialog(context: Context, private val galleryID: Int) : Dialog(conte
|
|||||||
listOf(gallery.language).map { Tag("language", it) },
|
listOf(gallery.language).map { Tag("language", it) },
|
||||||
gallery.series.map { Tag("series", it) },
|
gallery.series.map { Tag("series", it) },
|
||||||
gallery.characters.map { Tag("character", it) },
|
gallery.characters.map { Tag("character", it) },
|
||||||
gallery.tags.map {
|
gallery.tags.sortedBy {
|
||||||
|
val tag = Tag.parse(it)
|
||||||
|
|
||||||
|
if (favoriteTags.contains(tag))
|
||||||
|
-1
|
||||||
|
else
|
||||||
|
when(Tag.parse(it).area) {
|
||||||
|
"female" -> 0
|
||||||
|
"male" -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
}.map {
|
||||||
Tag.parse(it).let { tag ->
|
Tag.parse(it).let { tag ->
|
||||||
when {
|
when {
|
||||||
tag.area != null -> tag
|
tag.area != null -> tag
|
||||||
@@ -161,33 +162,12 @@ class GalleryDialog(context: Context, private val galleryID: Int) : Dialog(conte
|
|||||||
).filter {
|
).filter {
|
||||||
(_, content) -> content.isNotEmpty()
|
(_, content) -> content.isNotEmpty()
|
||||||
}.forEach { (title, content) ->
|
}.forEach { (title, content) ->
|
||||||
inflater.inflate(R.layout.item_gallery_details, gallery_details_contents, false).apply {
|
GalleryDialogTagsBinding.inflate(layoutInflater, contents, true).apply {
|
||||||
gallery_details_type.setText(title)
|
type.setText(title)
|
||||||
|
|
||||||
content.forEach { tag ->
|
content.forEach { tag ->
|
||||||
gallery_details_tags.addView(
|
tags.addView(
|
||||||
Chip(context).apply {
|
TagChip(context, tag).apply {
|
||||||
chipIcon = when(tag.area) {
|
|
||||||
"male" -> {
|
|
||||||
setChipBackgroundColorResource(R.color.material_blue_700)
|
|
||||||
setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
|
||||||
ContextCompat.getDrawable(context, R.drawable.ic_gender_male_white)
|
|
||||||
}
|
|
||||||
"female" -> {
|
|
||||||
setChipBackgroundColorResource(R.color.material_pink_600)
|
|
||||||
setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
|
||||||
ContextCompat.getDrawable(context, R.drawable.ic_gender_female_white)
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
text = when (tag.area) {
|
|
||||||
"language" -> languages[tag.tag]
|
|
||||||
else -> tag.tag.wordCapitalize()
|
|
||||||
}
|
|
||||||
|
|
||||||
setEnsureMinTouchTargetSize(false)
|
|
||||||
|
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
onChipClickedHandler.forEach { handler ->
|
onChipClickedHandler.forEach { handler ->
|
||||||
handler.invoke(tag)
|
handler.invoke(tag)
|
||||||
@@ -196,37 +176,36 @@ class GalleryDialog(context: Context, private val galleryID: Int) : Dialog(conte
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.let {
|
|
||||||
gallery_details_contents.addView(it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.let {
|
|
||||||
gallery_contents.addView(it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addThumbnails(gallery: Gallery) {
|
private fun addThumbnails(gallery: Gallery) {
|
||||||
val inflater = LayoutInflater.from(context)
|
GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
|
||||||
|
type.setText(R.string.gallery_thumbnails)
|
||||||
|
|
||||||
inflater.inflate(R.layout.gallery_details, gallery_contents, false).apply {
|
val pager = ViewPager2(context).apply {
|
||||||
gallery_details.setText(R.string.gallery_thumbnails)
|
adapter = ThumbnailPageAdapter(gallery.thumbnails)
|
||||||
|
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||||
RecyclerView(context).apply {
|
}
|
||||||
layoutManager = GridLayoutManager(context, 3)
|
|
||||||
adapter = ThumbnailAdapter(glide, gallery.thumbnails)
|
contents.addView(
|
||||||
}.let {
|
pager,
|
||||||
gallery_details_contents.addView(it, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: Change to direct allocation
|
||||||
|
GalleryDialogDotindicatorBinding.inflate(layoutInflater, contents, true).apply {
|
||||||
|
dotindicator.setViewPager2(pager)
|
||||||
}
|
}
|
||||||
}.let {
|
|
||||||
gallery_contents.addView(it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addRelated(gallery: Gallery) {
|
private fun addRelated(gallery: Gallery) {
|
||||||
val inflater = LayoutInflater.from(context)
|
val galleries = mutableListOf<Int>()
|
||||||
val galleries = ArrayList<GalleryBlock>()
|
|
||||||
|
|
||||||
val adapter = GalleryBlockAdapter(context, galleries).apply {
|
val adapter = GalleryBlockAdapter(galleries).apply {
|
||||||
onChipClickedHandler.add { tag ->
|
onChipClickedHandler.add { tag ->
|
||||||
this@GalleryDialog.onChipClickedHandler.forEach { handler ->
|
this@GalleryDialog.onChipClickedHandler.forEach { handler ->
|
||||||
handler.invoke(tag)
|
handler.invoke(tag)
|
||||||
@@ -234,38 +213,21 @@ class GalleryDialog(context: Context, private val galleryID: Int) : Dialog(conte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
|
||||||
gallery.related.forEachIndexed { i, galleryID ->
|
type.setText(R.string.gallery_related)
|
||||||
async(Dispatchers.IO) {
|
|
||||||
Cache(context).getGalleryBlock(galleryID)
|
|
||||||
}.let {
|
|
||||||
val galleryBlock = it.await() ?: return@let
|
|
||||||
|
|
||||||
galleries.add(galleryBlock)
|
contents.addView(RecyclerView(context).apply {
|
||||||
adapter.notifyItemInserted(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inflater.inflate(R.layout.gallery_details, gallery_contents, false).apply {
|
|
||||||
gallery_details.setText(R.string.gallery_related)
|
|
||||||
|
|
||||||
RecyclerView(context).apply {
|
|
||||||
layoutManager = LinearLayoutManager(context)
|
layoutManager = LinearLayoutManager(context)
|
||||||
this.adapter = adapter
|
this.adapter = adapter
|
||||||
|
|
||||||
ItemClickSupport.addTo(this)
|
ItemClickSupport.addTo(this).apply {
|
||||||
.setOnItemClickListener { _, position, _ ->
|
onItemClickListener = { _, position, _ ->
|
||||||
context.startActivity(Intent(context, ReaderActivity::class.java).apply {
|
context.startActivity(Intent(context, ReaderActivity::class.java).apply {
|
||||||
putExtra("galleryID", galleries[position].id)
|
putExtra("galleryID", galleries[position])
|
||||||
})
|
})
|
||||||
(context.applicationContext as Pupil).histories.add(galleries[position].id)
|
|
||||||
}
|
}
|
||||||
.setOnItemLongClickListener { _, position, _ ->
|
onItemLongClickListener = { _, position, _ ->
|
||||||
GalleryDialog(
|
GalleryDialog(context, galleries[position]).apply {
|
||||||
context,
|
|
||||||
galleries[position].id
|
|
||||||
).apply {
|
|
||||||
onChipClickedHandler.add { tag ->
|
onChipClickedHandler.add { tag ->
|
||||||
this@GalleryDialog.onChipClickedHandler.forEach { it.invoke(tag) }
|
this@GalleryDialog.onChipClickedHandler.forEach { it.invoke(tag) }
|
||||||
}
|
}
|
||||||
@@ -273,11 +235,20 @@ class GalleryDialog(context: Context, private val galleryID: Int) : Dialog(conte
|
|||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}.let {
|
|
||||||
gallery_details_contents.addView(it, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
|
||||||
}
|
}
|
||||||
}.let {
|
})
|
||||||
gallery_contents.addView(it)
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
gallery.related.forEach { galleryID ->
|
||||||
|
Cache.getInstance(context, galleryID).getGalleryBlock()?.let {
|
||||||
|
galleries.add(galleryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pupil, Hitomi.la viewer for Android
|
|
||||||
* Copyright (C) 2020 tom5079
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.ui.dialog
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.adapters.MirrorAdapter
|
|
||||||
|
|
||||||
class MirrorDialog(context: Context) : AlertDialog(context) {
|
|
||||||
|
|
||||||
class ItemTouchHelperCallback : ItemTouchHelper.Callback() {
|
|
||||||
|
|
||||||
var onMoveItem : ((Int, Int) -> (Unit))? = null
|
|
||||||
|
|
||||||
override fun getMovementFlags(
|
|
||||||
recyclerView: RecyclerView,
|
|
||||||
viewHolder: RecyclerView.ViewHolder
|
|
||||||
) = makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
|
|
||||||
|
|
||||||
override fun onMove(
|
|
||||||
recyclerView: RecyclerView,
|
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
|
||||||
target: RecyclerView.ViewHolder
|
|
||||||
): Boolean {
|
|
||||||
onMoveItem?.invoke(viewHolder.adapterPosition, target.adapterPosition)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var recyclerView: RecyclerView
|
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
initDialog()
|
|
||||||
|
|
||||||
setTitle(R.string.settings_mirror_title)
|
|
||||||
setView(recyclerView)
|
|
||||||
setButton(Dialog.BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ -> }
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initDialog() {
|
|
||||||
recyclerView = RecyclerView(context).apply recyclerview@{
|
|
||||||
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
|
|
||||||
layoutManager = LinearLayoutManager(context)
|
|
||||||
adapter = MirrorAdapter(context).apply adapter@{
|
|
||||||
val itemTouchHelper = ItemTouchHelper(ItemTouchHelperCallback().apply {
|
|
||||||
onMoveItem = this@adapter.onItemMove
|
|
||||||
}).apply {
|
|
||||||
attachToRecyclerView(this@recyclerview)
|
|
||||||
}
|
|
||||||
|
|
||||||
onStartDrag = {
|
|
||||||
itemTouchHelper.startDrag(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
onItemMoved = {
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
.edit()
|
|
||||||
.putString("mirrors", it.joinToString(">"))
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.ui.dialog
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.AdapterView
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.client
|
||||||
|
import xyz.quaver.pupil.clientBuilder
|
||||||
|
import xyz.quaver.pupil.clientHolder
|
||||||
|
import xyz.quaver.pupil.databinding.ProxyDialogBinding
|
||||||
|
import xyz.quaver.pupil.util.Preferences
|
||||||
|
import xyz.quaver.pupil.util.ProxyInfo
|
||||||
|
import xyz.quaver.pupil.util.getProxyInfo
|
||||||
|
import xyz.quaver.pupil.util.proxyInfo
|
||||||
|
import java.net.Proxy
|
||||||
|
|
||||||
|
class ProxyDialogFragment : DialogFragment() {
|
||||||
|
|
||||||
|
private var _binding: ProxyDialogBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
_binding = ProxyDialogBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
|
initView()
|
||||||
|
|
||||||
|
return AlertDialog.Builder(requireContext()).apply {
|
||||||
|
setView(binding.root)
|
||||||
|
}.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initView() {
|
||||||
|
val proxyInfo = getProxyInfo()
|
||||||
|
|
||||||
|
val enabler = { enable: Boolean ->
|
||||||
|
binding.addr.isEnabled = enable
|
||||||
|
binding.port.isEnabled = enable
|
||||||
|
binding.username.isEnabled = enable
|
||||||
|
binding.password.isEnabled = enable
|
||||||
|
|
||||||
|
if (!enable) {
|
||||||
|
binding.addr.text = null
|
||||||
|
binding.port.text = null
|
||||||
|
binding.username.text = null
|
||||||
|
binding.password.text = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with(binding.typeSelector) {
|
||||||
|
adapter = ArrayAdapter(
|
||||||
|
context,
|
||||||
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
|
context.resources.getStringArray(R.array.proxy_type)
|
||||||
|
)
|
||||||
|
|
||||||
|
setSelection(proxyInfo.type.ordinal)
|
||||||
|
|
||||||
|
onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||||
|
enabler.invoke(position != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.addr.setText(proxyInfo.host)
|
||||||
|
binding.port.setText(proxyInfo.port?.toString())
|
||||||
|
binding.username.setText(proxyInfo.username)
|
||||||
|
binding.password.setText(proxyInfo.password)
|
||||||
|
|
||||||
|
enabler.invoke(proxyInfo.type != Proxy.Type.DIRECT)
|
||||||
|
|
||||||
|
binding.cancelButton.setOnClickListener {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.okButton.setOnClickListener {
|
||||||
|
val type = Proxy.Type.values()[binding.typeSelector.selectedItemPosition]
|
||||||
|
val addr = binding.addr.text?.toString()
|
||||||
|
val port = binding.port.text?.toString()?.toIntOrNull()
|
||||||
|
val username = binding.username.text?.toString()
|
||||||
|
val password = binding.password.text?.toString()
|
||||||
|
|
||||||
|
if (type != Proxy.Type.DIRECT) {
|
||||||
|
if (addr == null || addr.isEmpty())
|
||||||
|
binding.addr.error = requireContext().getText(R.string.proxy_dialog_error)
|
||||||
|
if (port == null)
|
||||||
|
binding.port.error = requireContext().getText(R.string.proxy_dialog_error)
|
||||||
|
|
||||||
|
if (addr == null || addr.isEmpty() || port == null)
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
ProxyInfo(type, addr, port, username, password).let {
|
||||||
|
Preferences["proxy"] = Json.encodeToString(it)
|
||||||
|
|
||||||
|
clientBuilder
|
||||||
|
.proxyInfo(it)
|
||||||
|
clientHolder = null
|
||||||
|
client
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pupil, Hitomi.la viewer for Android
|
|
||||||
* Copyright (C) 2020 tom5079
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.ui.fragment
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.preference.Preference
|
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.ui.LockActivity
|
|
||||||
import xyz.quaver.pupil.util.Lock
|
|
||||||
import xyz.quaver.pupil.util.LockManager
|
|
||||||
|
|
||||||
class LockFragment : PreferenceFragmentCompat() {
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
|
|
||||||
val lockManager = LockManager(context!!)
|
|
||||||
|
|
||||||
findPreference<Preference>("lock_pattern")?.summary =
|
|
||||||
if (lockManager.contains(Lock.Type.PATTERN))
|
|
||||||
getString(R.string.settings_lock_enabled)
|
|
||||||
else
|
|
||||||
""
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
|
||||||
setPreferencesFromResource(R.xml.lock_preferences, rootKey)
|
|
||||||
|
|
||||||
with(findPreference<Preference>("lock_pattern")) {
|
|
||||||
this!!
|
|
||||||
|
|
||||||
if (LockManager(context!!).contains(Lock.Type.PATTERN))
|
|
||||||
summary = getString(R.string.settings_lock_enabled)
|
|
||||||
|
|
||||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
|
||||||
val lockManager = LockManager(context!!)
|
|
||||||
|
|
||||||
if (lockManager.contains(Lock.Type.PATTERN)) {
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setTitle(R.string.warning)
|
|
||||||
setMessage(R.string.settings_lock_remove_message)
|
|
||||||
|
|
||||||
setPositiveButton(android.R.string.yes) { _, _ ->
|
|
||||||
lockManager.remove(Lock.Type.PATTERN)
|
|
||||||
onResume()
|
|
||||||
}
|
|
||||||
setNegativeButton(android.R.string.no) { _, _ -> }
|
|
||||||
}.show()
|
|
||||||
} else {
|
|
||||||
val intent = Intent(context, LockActivity::class.java).apply {
|
|
||||||
putExtra("mode", "add_lock")
|
|
||||||
putExtra("type", "pattern")
|
|
||||||
}
|
|
||||||
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.ui.fragment
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.ui.LockActivity
|
||||||
|
import xyz.quaver.pupil.util.Lock
|
||||||
|
import xyz.quaver.pupil.util.LockManager
|
||||||
|
import xyz.quaver.pupil.util.Preferences
|
||||||
|
|
||||||
|
class LockSettingsFragment : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
|
||||||
|
val lockManager = LockManager(requireContext())
|
||||||
|
|
||||||
|
findPreference<Preference>("lock_pattern")?.summary =
|
||||||
|
if (lockManager.contains(Lock.Type.PATTERN))
|
||||||
|
getString(R.string.settings_lock_enabled)
|
||||||
|
else
|
||||||
|
""
|
||||||
|
|
||||||
|
findPreference<Preference>("lock_pin")?.summary =
|
||||||
|
if (lockManager.contains(Lock.Type.PIN))
|
||||||
|
getString(R.string.settings_lock_enabled)
|
||||||
|
else
|
||||||
|
""
|
||||||
|
|
||||||
|
if (lockManager.isEmpty()) {
|
||||||
|
(findPreference<Preference>("lock_fingerprint") as SwitchPreferenceCompat).isChecked = false
|
||||||
|
|
||||||
|
Preferences["lock_fingerprint"] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
setPreferencesFromResource(R.xml.lock_preferences, rootKey)
|
||||||
|
|
||||||
|
with(findPreference<Preference>("lock_pattern")) {
|
||||||
|
this!!
|
||||||
|
|
||||||
|
if (LockManager(requireContext()).contains(Lock.Type.PATTERN))
|
||||||
|
summary = getString(R.string.settings_lock_enabled)
|
||||||
|
|
||||||
|
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||||
|
val lockManager = LockManager(requireContext())
|
||||||
|
|
||||||
|
if (lockManager.contains(Lock.Type.PATTERN)) {
|
||||||
|
AlertDialog.Builder(requireContext()).apply {
|
||||||
|
setTitle(R.string.warning)
|
||||||
|
setMessage(R.string.settings_lock_remove_message)
|
||||||
|
|
||||||
|
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
lockManager.remove(Lock.Type.PATTERN)
|
||||||
|
onResume()
|
||||||
|
}
|
||||||
|
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||||
|
}.show()
|
||||||
|
} else {
|
||||||
|
val intent = Intent(requireContext(), LockActivity::class.java).apply {
|
||||||
|
putExtra("mode", "add_lock")
|
||||||
|
putExtra("type", "pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with(findPreference<Preference>("lock_pin")) {
|
||||||
|
this!!
|
||||||
|
|
||||||
|
if (LockManager(requireContext()).contains(Lock.Type.PIN))
|
||||||
|
summary = getString(R.string.settings_lock_enabled)
|
||||||
|
|
||||||
|
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||||
|
val lockManager = LockManager(requireContext())
|
||||||
|
|
||||||
|
if (lockManager.contains(Lock.Type.PIN)) {
|
||||||
|
AlertDialog.Builder(requireContext()).apply {
|
||||||
|
setTitle(R.string.warning)
|
||||||
|
setMessage(R.string.settings_lock_remove_message)
|
||||||
|
|
||||||
|
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
lockManager.remove(Lock.Type.PIN)
|
||||||
|
onResume()
|
||||||
|
}
|
||||||
|
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||||
|
}.show()
|
||||||
|
} else {
|
||||||
|
val intent = Intent(requireContext(), LockActivity::class.java).apply {
|
||||||
|
putExtra("mode", "add_lock")
|
||||||
|
putExtra("type", "pin")
|
||||||
|
}
|
||||||
|
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with(findPreference<Preference>("lock_fingerprint")) {
|
||||||
|
this!!
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
this as SwitchPreferenceCompat
|
||||||
|
|
||||||
|
if (newValue == true && LockManager(requireContext()).isEmpty()) {
|
||||||
|
isChecked = false
|
||||||
|
|
||||||
|
Toast.makeText(requireContext(), R.string.settings_lock_fingerprint_without_lock, Toast.LENGTH_SHORT).show()
|
||||||
|
} else
|
||||||
|
isChecked = newValue as Boolean
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.ui.fragment
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.graphics.PorterDuffColorFilter
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import kotlinx.coroutines.MainScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
|
import okhttp3.*
|
||||||
|
import xyz.quaver.io.FileX
|
||||||
|
import xyz.quaver.io.util.readText
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.client
|
||||||
|
import xyz.quaver.pupil.favoriteTags
|
||||||
|
import xyz.quaver.pupil.favorites
|
||||||
|
import xyz.quaver.pupil.types.Tag
|
||||||
|
import xyz.quaver.pupil.util.get
|
||||||
|
import xyz.quaver.pupil.util.restore
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class ManageFavoritesFragment : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
|
private val requestBackupFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) {
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = result.data?.data ?: return@registerForActivityResult
|
||||||
|
val context = context ?: return@registerForActivityResult
|
||||||
|
val view = view ?: return@registerForActivityResult
|
||||||
|
|
||||||
|
val backupData = runCatching {
|
||||||
|
FileX(context, uri).readText()?.let { Json.parseToJsonElement(it) }
|
||||||
|
}.getOrNull() ?: run{
|
||||||
|
Snackbar.make(view, context.getString(R.string.error), Toast.LENGTH_LONG).show()
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val newFavorites = backupData["favorites"]?.let { Json.decodeFromJsonElement<List<Int>>(it) }.orEmpty()
|
||||||
|
val newFavoriteTags = backupData["favorite_tags"]?.let { Json.decodeFromJsonElement<List<Tag>>(it) }.orEmpty()
|
||||||
|
|
||||||
|
favorites.addAll(newFavorites)
|
||||||
|
favoriteTags.addAll(newFavoriteTags)
|
||||||
|
|
||||||
|
Snackbar.make(view, context.getString(R.string.settings_restore_success, newFavorites.size + newFavoriteTags.size), Snackbar.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
setPreferencesFromResource(R.xml.manage_favorites_preferences, rootKey)
|
||||||
|
|
||||||
|
initPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initPreferences() {
|
||||||
|
val context = context ?: return
|
||||||
|
|
||||||
|
findPreference<Preference>("backup")?.setOnPreferenceClickListener {
|
||||||
|
val favorites = runCatching {
|
||||||
|
Json.parseToJsonElement(File(ContextCompat.getDataDir(context), "favorites.json").readText())
|
||||||
|
}.getOrNull()
|
||||||
|
val favoriteTags = kotlin.runCatching {
|
||||||
|
Json.parseToJsonElement(File(ContextCompat.getDataDir(context), "favorites_tags.json").readText())
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
val favoriteJson = buildJsonObject {
|
||||||
|
favorites?.let {
|
||||||
|
put("favorites", it)
|
||||||
|
}
|
||||||
|
favoriteTags?.let {
|
||||||
|
put("favorite_tags", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val backupFile = File(context.filesDir, "pupil-backup.json").also {
|
||||||
|
it.writeText(favoriteJson.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent(Intent.ACTION_SEND).apply {
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", backupFile)
|
||||||
|
setDataAndType(uri, "application/json")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
}.let {
|
||||||
|
context.startActivity(Intent.createChooser(it, getString(R.string.settings_backup_share)))
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
findPreference<Preference>("restore")?.setOnPreferenceClickListener {
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
type = "*/*"
|
||||||
|
}
|
||||||
|
|
||||||
|
requestBackupFileLauncher.launch(intent)
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.ui.fragment
|
||||||
|
|
||||||
|
import android.graphics.ColorFilter
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.graphics.PorterDuffColorFilter
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import xyz.quaver.io.FileX
|
||||||
|
import xyz.quaver.io.SAFileX
|
||||||
|
import xyz.quaver.io.util.deleteRecursively
|
||||||
|
import xyz.quaver.io.util.getChild
|
||||||
|
import xyz.quaver.io.util.readText
|
||||||
|
import xyz.quaver.io.util.writeText
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.histories
|
||||||
|
import xyz.quaver.pupil.hitomi.json
|
||||||
|
import xyz.quaver.pupil.util.byteToString
|
||||||
|
import xyz.quaver.pupil.util.downloader.Cache
|
||||||
|
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||||
|
import xyz.quaver.pupil.util.downloader.Metadata
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClickListener {
|
||||||
|
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
setPreferencesFromResource(R.xml.manage_storage_preferences, rootKey)
|
||||||
|
|
||||||
|
initPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceClick(preference: Preference): Boolean {
|
||||||
|
val context = context ?: return false
|
||||||
|
|
||||||
|
with(preference) {
|
||||||
|
when (key) {
|
||||||
|
"delete_cache" -> {
|
||||||
|
val dir = File(context.cacheDir, "imageCache")
|
||||||
|
|
||||||
|
AlertDialog.Builder(context).apply {
|
||||||
|
setTitle(R.string.warning)
|
||||||
|
setMessage(R.string.settings_clear_cache_alert_message)
|
||||||
|
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
if (dir.exists())
|
||||||
|
dir.deleteRecursively()
|
||||||
|
|
||||||
|
Cache.instances.clear()
|
||||||
|
|
||||||
|
summary = context.getString(R.string.settings_storage_usage, byteToString(0))
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
var size = 0L
|
||||||
|
|
||||||
|
dir.walk().forEach {
|
||||||
|
size += it.length()
|
||||||
|
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
"recover_downloads" -> {
|
||||||
|
val density = context.resources.displayMetrics.density
|
||||||
|
this.icon = object: CircularProgressDrawable(context) {
|
||||||
|
override fun getIntrinsicHeight() = (24*density).roundToInt()
|
||||||
|
override fun getIntrinsicWidth() = (24*density).roundToInt()
|
||||||
|
}.apply {
|
||||||
|
setStyle(CircularProgressDrawable.DEFAULT)
|
||||||
|
colorFilter = PorterDuffColorFilter(ContextCompat.getColor(context, R.color.colorAccent), PorterDuff.Mode.SRC_IN)
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadManager = DownloadManager.getInstance(context)
|
||||||
|
|
||||||
|
val downloadFolderMap = downloadManager.downloadFolderMap
|
||||||
|
|
||||||
|
downloadFolderMap.clear()
|
||||||
|
|
||||||
|
downloadManager.downloadFolder.listFiles { file -> file.isDirectory }?.forEach { folder ->
|
||||||
|
val metadataFile = FileX(context, folder, ".metadata")
|
||||||
|
|
||||||
|
if (!metadataFile.exists()) return@forEach
|
||||||
|
|
||||||
|
val metadata = metadataFile.readText()?.let {
|
||||||
|
runCatching {
|
||||||
|
json.decodeFromString<Metadata>(it)
|
||||||
|
}.getOrNull()
|
||||||
|
} ?: return@forEach
|
||||||
|
|
||||||
|
val galleryID = metadata.galleryBlock?.id ?: metadata.galleryInfo?.id?.toIntOrNull() ?: return@forEach
|
||||||
|
|
||||||
|
downloadFolderMap[galleryID] = folder.name
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadManager.downloadFolderMap.putAll(downloadFolderMap)
|
||||||
|
val downloads = FileX(context, downloadManager.downloadFolder, ".download")
|
||||||
|
|
||||||
|
if (!downloads.exists()) downloads.createNewFile()
|
||||||
|
downloads.writeText(Json.encodeToString(downloadFolderMap))
|
||||||
|
|
||||||
|
this.icon = null
|
||||||
|
Toast.makeText(context, android.R.string.ok, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
"delete_downloads" -> {
|
||||||
|
val dir = DownloadManager.getInstance(context).downloadFolder
|
||||||
|
|
||||||
|
AlertDialog.Builder(context).apply {
|
||||||
|
setTitle(R.string.warning)
|
||||||
|
setMessage(R.string.settings_clear_downloads_alert_message)
|
||||||
|
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
job?.cancel()
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
summary = context.getString(R.string.settings_storage_usage_loading)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dir.exists())
|
||||||
|
dir.listFiles()?.forEach {
|
||||||
|
when (it) {
|
||||||
|
is FileX -> it.deleteRecursively()
|
||||||
|
else -> it.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
job = launch {
|
||||||
|
var size = 0L
|
||||||
|
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
|
||||||
|
}
|
||||||
|
dir.walk().forEach {
|
||||||
|
size += it.length()
|
||||||
|
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
"clear_history" -> {
|
||||||
|
AlertDialog.Builder(context).apply {
|
||||||
|
setTitle(R.string.warning)
|
||||||
|
setMessage(R.string.settings_clear_history_alert_message)
|
||||||
|
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
histories.clear()
|
||||||
|
summary = context.getString(R.string.settings_clear_history_summary, histories.size)
|
||||||
|
}
|
||||||
|
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initPreferences() {
|
||||||
|
val context = context ?: return
|
||||||
|
|
||||||
|
with(findPreference<Preference>("delete_cache")) {
|
||||||
|
this ?: return@with
|
||||||
|
|
||||||
|
val dir = File(context.cacheDir, "imageCache")
|
||||||
|
|
||||||
|
summary = context.getString(R.string.settings_storage_usage, byteToString(0))
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
var size = 0L
|
||||||
|
|
||||||
|
dir.walk().forEach {
|
||||||
|
size += it.length()
|
||||||
|
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPreferenceClickListener = this@ManageStorageFragment
|
||||||
|
}
|
||||||
|
|
||||||
|
with(findPreference<Preference>("delete_downloads")) {
|
||||||
|
this ?: return@with
|
||||||
|
|
||||||
|
val dir = DownloadManager.getInstance(context).downloadFolder
|
||||||
|
|
||||||
|
summary = context.getString(R.string.settings_storage_usage, byteToString(0))
|
||||||
|
job?.cancel()
|
||||||
|
job = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
var size = 0L
|
||||||
|
|
||||||
|
dir.walk().forEach {
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
size += it.length()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPreferenceClickListener = this@ManageStorageFragment
|
||||||
|
}
|
||||||
|
|
||||||
|
with(findPreference<Preference>("clear_history")) {
|
||||||
|
this ?: return@with
|
||||||
|
|
||||||
|
summary = context.getString(R.string.settings_clear_history_summary, histories.size)
|
||||||
|
|
||||||
|
onPreferenceClickListener = this@ManageStorageFragment
|
||||||
|
}
|
||||||
|
|
||||||
|
with(findPreference<Preference>("recover_downloads")) {
|
||||||
|
this ?: return@with
|
||||||
|
|
||||||
|
onPreferenceClickListener = this@ManageStorageFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
job?.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.ui.fragment
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.andrognito.pinlockview.PinLockListener
|
||||||
|
import xyz.quaver.pupil.databinding.PinLockFragmentBinding
|
||||||
|
|
||||||
|
class PINLockFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: PinLockFragmentBinding? = null
|
||||||
|
val binding get() = _binding!!
|
||||||
|
|
||||||
|
var onPINEntered: ((String) -> Unit)? = null
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = PinLockFragmentBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
binding.pinLockView.attachIndicatorDots(binding.indicatorDots)
|
||||||
|
binding.pinLockView.setPinLockListener(object: PinLockListener {
|
||||||
|
override fun onComplete(p0: String?) {
|
||||||
|
onPINEntered?.invoke(p0 ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEmpty() {}
|
||||||
|
override fun onPinChange(p0: Int, p1: String?) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -26,38 +26,36 @@ import androidx.fragment.app.Fragment
|
|||||||
import com.andrognito.patternlockview.PatternLockView
|
import com.andrognito.patternlockview.PatternLockView
|
||||||
import com.andrognito.patternlockview.listener.PatternLockViewListener
|
import com.andrognito.patternlockview.listener.PatternLockViewListener
|
||||||
import com.andrognito.patternlockview.utils.PatternLockUtils
|
import com.andrognito.patternlockview.utils.PatternLockUtils
|
||||||
import kotlinx.android.synthetic.main.fragment_pattern_lock.*
|
import xyz.quaver.pupil.databinding.PatternLockFragmentBinding
|
||||||
import kotlinx.android.synthetic.main.fragment_pattern_lock.view.*
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
|
|
||||||
class PatternLockFragment : Fragment(), PatternLockViewListener {
|
class PatternLockFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: PatternLockFragmentBinding? = null
|
||||||
|
val binding get() = _binding!!
|
||||||
|
|
||||||
var onPatternDrawn: ((String) -> Unit)? = null
|
var onPatternDrawn: ((String) -> Unit)? = null
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View? {
|
): View {
|
||||||
return inflater.inflate(R.layout.fragment_pattern_lock, container, false).apply {
|
_binding = PatternLockFragmentBinding.inflate(inflater, container, false)
|
||||||
lock_pattern_view.addPatternLockListener(this@PatternLockFragment)
|
binding.patternLockView.addPatternLockListener(object: PatternLockViewListener {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onComplete(pattern: MutableList<PatternLockView.Dot>?) {
|
override fun onComplete(pattern: MutableList<PatternLockView.Dot>?) {
|
||||||
val password = PatternLockUtils.patternToMD5(lock_pattern_view, pattern)
|
val password = PatternLockUtils.patternToMD5(binding.patternLockView, pattern)
|
||||||
onPatternDrawn?.invoke(password)
|
onPatternDrawn?.invoke(password)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onProgress(progressPattern: MutableList<PatternLockView.Dot>?) {
|
override fun onCleared() {}
|
||||||
|
override fun onProgress(progressPattern: MutableList<PatternLockView.Dot>?) {}
|
||||||
|
override fun onStarted() {}
|
||||||
|
})
|
||||||
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStarted() {
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,28 +18,36 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.ui.fragment
|
package xyz.quaver.pupil.ui.fragment
|
||||||
|
|
||||||
import android.content.Intent
|
import android.app.Activity
|
||||||
import android.content.SharedPreferences
|
import android.content.*
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.appcompat.app.AlertDialog
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.preference.*
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
|
||||||
import androidx.preference.Preference
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
import androidx.preference.PreferenceCategory
|
import com.google.firebase.crashlytics.internal.model.CrashlyticsReport
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import androidx.preference.PreferenceManager
|
import kotlinx.coroutines.Dispatchers
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import kotlinx.coroutines.launch
|
||||||
import xyz.quaver.pupil.Pupil
|
import okhttp3.Dispatcher
|
||||||
|
import xyz.quaver.io.FileX
|
||||||
|
import xyz.quaver.io.util.getChild
|
||||||
import xyz.quaver.pupil.R
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.client
|
||||||
|
import xyz.quaver.pupil.clientBuilder
|
||||||
|
import xyz.quaver.pupil.clientHolder
|
||||||
|
import xyz.quaver.pupil.types.SendLogException
|
||||||
import xyz.quaver.pupil.ui.LockActivity
|
import xyz.quaver.pupil.ui.LockActivity
|
||||||
import xyz.quaver.pupil.ui.SettingsActivity
|
import xyz.quaver.pupil.ui.SettingsActivity
|
||||||
import xyz.quaver.pupil.ui.dialog.DefaultQueryDialog
|
import xyz.quaver.pupil.ui.TransferActivity
|
||||||
import xyz.quaver.pupil.ui.dialog.DownloadLocationDialog
|
import xyz.quaver.pupil.ui.dialog.*
|
||||||
import xyz.quaver.pupil.ui.dialog.MirrorDialog
|
|
||||||
import xyz.quaver.pupil.util.*
|
import xyz.quaver.pupil.util.*
|
||||||
import java.io.File
|
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
class SettingsFragment :
|
class SettingsFragment :
|
||||||
PreferenceFragmentCompat(),
|
PreferenceFragmentCompat(),
|
||||||
@@ -47,16 +55,20 @@ class SettingsFragment :
|
|||||||
Preference.OnPreferenceChangeListener,
|
Preference.OnPreferenceChangeListener,
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
private val lockLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
super.onCreate(savedInstanceState)
|
if (it.resultCode == Activity.RESULT_OK) {
|
||||||
|
parentFragmentManager
|
||||||
PreferenceManager.getDefaultSharedPreferences(context).registerOnSharedPreferenceChangeListener(this)
|
.beginTransaction()
|
||||||
|
.replace(R.id.settings, LockSettingsFragment())
|
||||||
|
.addToBackStack("Lock")
|
||||||
|
.commitAllowingStateLoss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
|
||||||
val lockManager = LockManager(context!!)
|
val lockManager = LockManager(requireContext())
|
||||||
|
|
||||||
findPreference<Preference>("app_lock")?.summary = if (lockManager.locks.isNullOrEmpty()) {
|
findPreference<Preference>("app_lock")?.summary = if (lockManager.locks.isNullOrEmpty()) {
|
||||||
getString(R.string.settings_lock_none)
|
getString(R.string.settings_lock_none)
|
||||||
@@ -71,103 +83,41 @@ class SettingsFragment :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDirSize(dir: DocumentFile) : String {
|
override fun onPreferenceClick(preference: Preference): Boolean {
|
||||||
val size = dir.walk().map { it.length() }.sum()
|
|
||||||
|
|
||||||
return getString(R.string.settings_clear_summary, byteToString(size))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPreferenceClick(preference: Preference?): Boolean {
|
|
||||||
with (preference) {
|
with (preference) {
|
||||||
this ?: return false
|
|
||||||
|
|
||||||
when (key) {
|
when (key) {
|
||||||
"app_version" -> {
|
"app_version" -> {
|
||||||
checkUpdate(activity as SettingsActivity, true)
|
checkUpdate(activity as SettingsActivity, true)
|
||||||
}
|
}
|
||||||
"delete_cache" -> {
|
"download_folder" -> {
|
||||||
val dir = DocumentFile.fromFile(File(context.cacheDir, "imageCache"))
|
DownloadLocationDialogFragment().show(parentFragmentManager, "Download Location Dialog")
|
||||||
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setTitle(R.string.warning)
|
|
||||||
setMessage(R.string.settings_clear_cache_alert_message)
|
|
||||||
setPositiveButton(android.R.string.yes) { _, _ ->
|
|
||||||
if (dir.exists())
|
|
||||||
dir.deleteRecursively()
|
|
||||||
|
|
||||||
summary = getDirSize(dir)
|
|
||||||
}
|
|
||||||
setNegativeButton(android.R.string.no) { _, _ -> }
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
"delete_downloads" -> {
|
|
||||||
val dir = getDownloadDirectory(context)
|
|
||||||
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setTitle(R.string.warning)
|
|
||||||
setMessage(R.string.settings_clear_downloads_alert_message)
|
|
||||||
setPositiveButton(android.R.string.yes) { _, _ ->
|
|
||||||
if (dir.exists())
|
|
||||||
dir.deleteRecursively()
|
|
||||||
|
|
||||||
summary = getDirSize(dir)
|
|
||||||
}
|
|
||||||
setNegativeButton(android.R.string.no) { _, _ -> }
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
"clear_history" -> {
|
|
||||||
val histories = (context.applicationContext as Pupil).histories
|
|
||||||
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setTitle(R.string.warning)
|
|
||||||
setMessage(R.string.settings_clear_history_alert_message)
|
|
||||||
setPositiveButton(android.R.string.yes) { _, _ ->
|
|
||||||
histories.clear()
|
|
||||||
summary = getString(R.string.settings_clear_history_summary, histories.size)
|
|
||||||
}
|
|
||||||
setNegativeButton(android.R.string.no) { _, _ -> }
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
"dl_location" -> {
|
|
||||||
DownloadLocationDialog(activity!!).show()
|
|
||||||
}
|
}
|
||||||
"default_query" -> {
|
"default_query" -> {
|
||||||
DefaultQueryDialog(context).apply {
|
DefaultQueryDialog().apply {
|
||||||
onPositiveButtonClickListener = { newTags ->
|
onPositiveButtonClickListener = { newTags ->
|
||||||
sharedPreferences.edit().putString("default_query", newTags.toString()).apply()
|
Preferences["default_query"] = newTags.toString()
|
||||||
summary = newTags.toString()
|
summary = newTags.toString()
|
||||||
}
|
}
|
||||||
}.show()
|
}.show(parentFragmentManager, "Default Query Dialog")
|
||||||
}
|
}
|
||||||
"app_lock" -> {
|
"app_lock" -> {
|
||||||
val intent = Intent(context, LockActivity::class.java)
|
val intent = Intent(requireContext(), LockActivity::class.java).apply {
|
||||||
activity?.startActivityForResult(intent, REQUEST_LOCK)
|
putExtra("force", true)
|
||||||
}
|
}
|
||||||
"mirrors" -> {
|
lockLauncher.launch(intent)
|
||||||
MirrorDialog(context)
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
"backup" -> {
|
"proxy" -> {
|
||||||
File(ContextCompat.getDataDir(context), "favorites.json").copyTo(
|
ProxyDialogFragment().show(parentFragmentManager, "Proxy Dialog")
|
||||||
context,
|
|
||||||
getDownloadDirectory(context).let {
|
|
||||||
if (it.findFile("favorites.json") != null)
|
|
||||||
it
|
|
||||||
else
|
|
||||||
it.createFile("null", "favorites.json")!!
|
|
||||||
}
|
}
|
||||||
|
"user_id" -> {
|
||||||
|
FirebaseCrashlytics.getInstance().recordException(SendLogException())
|
||||||
|
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
|
||||||
|
ClipData.newPlainText("user_id", Preferences.get<String>("user_id"))
|
||||||
)
|
)
|
||||||
|
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||||
Snackbar.make(this@SettingsFragment.listView, R.string.settings_backup_snackbar, Snackbar.LENGTH_LONG)
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
"restore" -> {
|
"transfer_data" -> {
|
||||||
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
activity?.startActivity(Intent(activity, TransferActivity::class.java))
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
type = "*/*"
|
|
||||||
}
|
|
||||||
|
|
||||||
activity?.startActivityForResult(intent, REQUEST_RESTORE)
|
|
||||||
}
|
}
|
||||||
else -> return false
|
else -> return false
|
||||||
}
|
}
|
||||||
@@ -176,11 +126,24 @@ class SettingsFragment :
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean {
|
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean {
|
||||||
with (preference) {
|
with (preference) {
|
||||||
this ?: return false
|
|
||||||
|
|
||||||
when (key) {
|
when (key) {
|
||||||
|
"tag_translation" -> {
|
||||||
|
updateTranslations()
|
||||||
|
}
|
||||||
|
"nomedia" -> {
|
||||||
|
val create = (newValue as? Boolean) ?: return false
|
||||||
|
|
||||||
|
return kotlin.runCatching {
|
||||||
|
val nomedia = DownloadManager.getInstance(context).downloadFolder.getChild(".nomedia")
|
||||||
|
|
||||||
|
if (create)
|
||||||
|
nomedia.createNewFile()
|
||||||
|
else
|
||||||
|
nomedia.delete()
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
"dark_mode" -> {
|
"dark_mode" -> {
|
||||||
AppCompatDelegate.setDefaultNightMode(when (newValue as Boolean) {
|
AppCompatDelegate.setDefaultNightMode(when (newValue as Boolean) {
|
||||||
true -> AppCompatDelegate.MODE_NIGHT_YES
|
true -> AppCompatDelegate.MODE_NIGHT_YES
|
||||||
@@ -195,9 +158,33 @@ class SettingsFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||||
|
key ?: return
|
||||||
|
|
||||||
|
with(findPreference<Preference>(key)) {
|
||||||
|
this ?: return
|
||||||
|
|
||||||
when (key) {
|
when (key) {
|
||||||
"dl_location" -> {
|
"proxy" -> {
|
||||||
findPreference<Preference>(key)?.summary = getDownloadDirectory(context!!).uri.path
|
summary = context.let { getProxyInfo().type.name }
|
||||||
|
}
|
||||||
|
"download_folder" -> {
|
||||||
|
summary = FileX(context, Preferences.get<String>("download_folder")).canonicalPath
|
||||||
|
}
|
||||||
|
"download_folder_name" -> {
|
||||||
|
summary = Preferences["download_folder_name", "[-id-] -title-"]
|
||||||
|
}
|
||||||
|
"max_concurrent_download" -> {
|
||||||
|
val newValue = Preferences.get<String>(key).toIntOrNull() ?: 0
|
||||||
|
|
||||||
|
if (newValue == 0)
|
||||||
|
clientBuilder.dispatcher(Dispatcher())
|
||||||
|
else
|
||||||
|
clientBuilder.dispatcher((Dispatcher(Executors.newFixedThreadPool(newValue))))
|
||||||
|
|
||||||
|
clientHolder = null
|
||||||
|
client
|
||||||
|
}
|
||||||
|
else -> return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,9 +192,16 @@ class SettingsFragment :
|
|||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
setPreferencesFromResource(R.xml.root_preferences, rootKey)
|
setPreferencesFromResource(R.xml.root_preferences, rootKey)
|
||||||
|
|
||||||
|
Preferences.registerOnSharedPreferenceChangeListener(this)
|
||||||
|
|
||||||
initPreferences()
|
initPreferences()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
Preferences.unregisterOnSharedPreferenceChangeListener(this)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
private fun initPreferences() {
|
private fun initPreferences() {
|
||||||
for (i in 0 until preferenceScreen.preferenceCount) {
|
for (i in 0 until preferenceScreen.preferenceCount) {
|
||||||
|
|
||||||
@@ -217,47 +211,44 @@ class SettingsFragment :
|
|||||||
else
|
else
|
||||||
listOf(this)
|
listOf(this)
|
||||||
}.forEach { preference ->
|
}.forEach { preference ->
|
||||||
with (preference) {
|
with (preference) with@{
|
||||||
|
|
||||||
when (key) {
|
when (key) {
|
||||||
"app_version" -> {
|
"app_version" -> {
|
||||||
val manager = context.packageManager
|
val manager = requireContext().packageManager
|
||||||
val info = manager.getPackageInfo(context.packageName, 0)
|
val info = manager.getPackageInfo(requireContext().packageName, 0)
|
||||||
summary = context.getString(R.string.settings_app_version_description, info.versionName)
|
summary = requireContext().getString(R.string.settings_app_version_description, info.versionName)
|
||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
"delete_cache" -> {
|
"download_folder_name" -> {
|
||||||
val dir = DocumentFile.fromFile(File(context.cacheDir, "imageCache"))
|
summary = Preferences["download_folder_name", "[-id-] -title-"]
|
||||||
summary = getDirSize(dir)
|
|
||||||
|
setOnPreferenceClickListener {
|
||||||
|
DownloadFolderNameDialogFragment().show(requireActivity().supportFragmentManager, "Download Location Dialog")
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"download_folder" -> {
|
||||||
|
summary = FileX(context, Preferences.get<String>("download_folder")).canonicalPath
|
||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
"delete_downloads" -> {
|
"nomedia" -> {
|
||||||
val dir = getDownloadDirectory(context)
|
(this as SwitchPreferenceCompat).isChecked = kotlin.runCatching {
|
||||||
summary = getDirSize(dir)
|
DownloadManager.getInstance(context).downloadFolder.getChild(".nomedia").exists()
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceChangeListener = this@SettingsFragment
|
||||||
}
|
|
||||||
"clear_history" -> {
|
|
||||||
val histories = (activity!!.application as Pupil).histories
|
|
||||||
summary = getString(R.string.settings_clear_history_summary, histories.size)
|
|
||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
|
||||||
}
|
|
||||||
"dl_location" -> {
|
|
||||||
summary = getDownloadDirectory(context).uri.path
|
|
||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
|
||||||
}
|
}
|
||||||
"default_query" -> {
|
"default_query" -> {
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
summary = Preferences.get<String>("default_query")
|
||||||
summary = preferences.getString("default_query", "") ?: ""
|
|
||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
"app_lock" -> {
|
"app_lock" -> {
|
||||||
val lockManager = LockManager(context)
|
val lockManager = LockManager(requireContext())
|
||||||
summary =
|
summary =
|
||||||
if (lockManager.locks.isNullOrEmpty()) {
|
if (lockManager.locks.isNullOrEmpty()) {
|
||||||
getString(R.string.settings_lock_none)
|
getString(R.string.settings_lock_none)
|
||||||
@@ -273,16 +264,49 @@ class SettingsFragment :
|
|||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
"mirrors" -> {
|
"proxy" -> {
|
||||||
|
summary = getProxyInfo().type.name
|
||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
|
"tag_translation" -> {
|
||||||
|
this as ListPreference
|
||||||
|
|
||||||
|
isEnabled = false
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
kotlin.runCatching {
|
||||||
|
val languages = getAvailableLanguages().distinct().toTypedArray()
|
||||||
|
|
||||||
|
entries = languages.map { Locale(it).let { loc -> loc.getDisplayLanguage(loc) } }.toTypedArray()
|
||||||
|
entryValues = languages
|
||||||
|
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPreferenceChangeListener = this@SettingsFragment
|
||||||
|
|
||||||
|
}
|
||||||
"dark_mode" -> {
|
"dark_mode" -> {
|
||||||
onPreferenceChangeListener = this@SettingsFragment
|
onPreferenceChangeListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
"backup" -> {
|
"old_import_galleries" -> {
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
"restore" -> {
|
"user_id" -> {
|
||||||
|
summary = Preferences.get<String>("user_id")
|
||||||
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
|
}
|
||||||
|
"oss" -> {
|
||||||
|
setOnPreferenceClickListener {
|
||||||
|
context.startActivity(Intent(context, OssLicensesMenuActivity::class.java))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"transfer_data" -> {
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package xyz.quaver.pupil.ui.fragment
|
||||||
|
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
|
||||||
|
class TransferConnectedFragment: Fragment(R.layout.transfer_connected_fragment) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package xyz.quaver.pupil.ui.fragment
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.databinding.TransferDirectionFragmentBinding
|
||||||
|
import xyz.quaver.pupil.ui.TransferStep
|
||||||
|
import xyz.quaver.pupil.ui.TransferViewModel
|
||||||
|
|
||||||
|
class TransferDirectionFragment : Fragment(R.layout.transfer_direction_fragment) {
|
||||||
|
|
||||||
|
private var _binding: TransferDirectionFragmentBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val viewModel: TransferViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = TransferDirectionFragmentBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
binding.inButton.setOnClickListener {
|
||||||
|
viewModel.setStep(TransferStep.TARGET)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.outButton.setOnClickListener {
|
||||||
|
viewModel.setStep(TransferStep.WAIT_FOR_CONNECTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package xyz.quaver.pupil.ui.fragment
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import xyz.quaver.pupil.databinding.TransferPermissionFragmentBinding
|
||||||
|
import xyz.quaver.pupil.ui.TransferStep
|
||||||
|
import xyz.quaver.pupil.ui.TransferViewModel
|
||||||
|
|
||||||
|
class TransferPermissionFragment: Fragment() {
|
||||||
|
|
||||||
|
private var _binding: TransferPermissionFragmentBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val viewModel: TransferViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = TransferPermissionFragmentBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
binding.permissionsButton.setOnClickListener {
|
||||||
|
viewModel.setStep(TransferStep.TARGET_FORCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package xyz.quaver.pupil.ui.fragment
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import xyz.quaver.pupil.databinding.TransferSelectDataFragmentBinding
|
||||||
|
import xyz.quaver.pupil.ui.TransferViewModel
|
||||||
|
|
||||||
|
class TransferSelectDataFragment: Fragment() {
|
||||||
|
|
||||||
|
private var _binding: TransferSelectDataFragmentBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val viewModel: TransferViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
_binding = TransferSelectDataFragmentBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
binding.checkAll.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
viewModel.list()
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package xyz.quaver.pupil.ui.fragment
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.adapters.TransferPeersAdapter
|
||||||
|
import xyz.quaver.pupil.databinding.TransferTargetFragmentBinding
|
||||||
|
import xyz.quaver.pupil.ui.TransferStep
|
||||||
|
import xyz.quaver.pupil.ui.TransferViewModel
|
||||||
|
|
||||||
|
class TransferTargetFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: TransferTargetFragmentBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val viewModel: TransferViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = TransferTargetFragmentBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
viewModel.thisDevice.observe(viewLifecycleOwner) { device ->
|
||||||
|
if (device == null) {
|
||||||
|
return@observe
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.status == 3) {
|
||||||
|
binding.ripple.startRippleAnimation()
|
||||||
|
binding.retryButton.visibility = View.INVISIBLE
|
||||||
|
} else {
|
||||||
|
binding.ripple.stopRippleAnimation()
|
||||||
|
binding.retryButton.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.peers.observe(viewLifecycleOwner) { peers ->
|
||||||
|
if (peers == null) {
|
||||||
|
return@observe
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.deviceList.adapter = TransferPeersAdapter(peers.deviceList) {
|
||||||
|
viewModel.connect(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.ripple.startRippleAnimation()
|
||||||
|
|
||||||
|
binding.retryButton.setOnClickListener {
|
||||||
|
viewModel.setStep(TransferStep.TARGET)
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package xyz.quaver.pupil.ui.fragment
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import xyz.quaver.pupil.databinding.TransferWaitForConnectionFragmentBinding
|
||||||
|
|
||||||
|
class TransferWaitForConnectionFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: TransferWaitForConnectionFragmentBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
_binding = TransferWaitForConnectionFragmentBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
|
binding.ripple.startRippleAnimation()
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
216
app/src/main/java/xyz/quaver/pupil/ui/view/FloatingSearchView.kt
Normal file
216
app/src/main/java/xyz/quaver/pupil/ui/view/FloatingSearchView.kt
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.ui.view
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.graphics.PorterDuffColorFilter
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||||
|
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||||
|
import xyz.quaver.floatingsearchview.FloatingSearchView
|
||||||
|
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
|
||||||
|
import xyz.quaver.floatingsearchview.util.view.SearchInputView
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.favoriteTags
|
||||||
|
import xyz.quaver.pupil.types.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class FloatingSearchView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
|
||||||
|
FloatingSearchView(context, attrs),
|
||||||
|
FloatingSearchView.OnSearchListener,
|
||||||
|
TextWatcher
|
||||||
|
{
|
||||||
|
private val searchInputView = findViewById<SearchInputView>(R.id.search_bar_text)
|
||||||
|
|
||||||
|
var onHistoryDeleteClickedListener: ((String) -> Unit)? = null
|
||||||
|
var onFavoriteHistorySwitchClickListener: (() -> Unit)? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
searchInputView.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or searchInputView.imeOptions
|
||||||
|
|
||||||
|
searchInputView.addTextChangedListener(this)
|
||||||
|
onSearchListener = this
|
||||||
|
onBindSuggestionCallback = { binding, item, itemPosition ->
|
||||||
|
onBindSuggestion(binding.root, binding.leftIcon, binding.body, item, itemPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
s ?: return
|
||||||
|
|
||||||
|
if (s.any { it.isUpperCase() })
|
||||||
|
s.replace(0, s.length, s.toString().toLowerCase(Locale.getDefault()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSuggestionClicked(searchSuggestion: SearchSuggestion?) {
|
||||||
|
when (searchSuggestion) {
|
||||||
|
is TagSuggestion -> {
|
||||||
|
val tag = "${searchSuggestion.n}:${searchSuggestion.s.replace(Regex("\\s"), "_")}"
|
||||||
|
with(searchInputView.text!!) {
|
||||||
|
delete(if (lastIndexOf(' ') == -1) 0 else lastIndexOf(' ') + 1, length)
|
||||||
|
|
||||||
|
if (!this.contains(tag))
|
||||||
|
append("$tag ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Suggestion -> {
|
||||||
|
with(searchInputView.text!!) {
|
||||||
|
clear()
|
||||||
|
append(searchSuggestion.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is FavoriteHistorySwitch -> onFavoriteHistorySwitchClickListener?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSearchAction(currentQuery: String?) {}
|
||||||
|
|
||||||
|
fun onBindSuggestion(
|
||||||
|
suggestionView: View?,
|
||||||
|
leftIcon: ImageView?,
|
||||||
|
textView: TextView?,
|
||||||
|
item: SearchSuggestion?,
|
||||||
|
itemPosition: Int
|
||||||
|
) {
|
||||||
|
when(item) {
|
||||||
|
is TagSuggestion -> {
|
||||||
|
val tag = "${item.n}:${item.s}"
|
||||||
|
|
||||||
|
leftIcon?.setImageDrawable(
|
||||||
|
ResourcesCompat.getDrawable(
|
||||||
|
resources,
|
||||||
|
when(item.n) {
|
||||||
|
"female" -> R.drawable.gender_female
|
||||||
|
"male" -> R.drawable.gender_male
|
||||||
|
"language" -> R.drawable.translate
|
||||||
|
"group" -> R.drawable.account_group
|
||||||
|
"character" -> R.drawable.account_star
|
||||||
|
"series" -> R.drawable.book_open
|
||||||
|
"artist" -> R.drawable.brush
|
||||||
|
else -> R.drawable.tag
|
||||||
|
},
|
||||||
|
context.theme)
|
||||||
|
)
|
||||||
|
|
||||||
|
with(suggestionView?.findViewById<ImageView>(R.id.right_icon)) {
|
||||||
|
this ?: return@with
|
||||||
|
|
||||||
|
if (favoriteTags.contains(Tag.parse(tag)))
|
||||||
|
setImageResource(R.drawable.ic_star_filled)
|
||||||
|
else
|
||||||
|
setImageResource(R.drawable.ic_star_empty)
|
||||||
|
|
||||||
|
visibility = View.VISIBLE
|
||||||
|
rotation = 0f
|
||||||
|
|
||||||
|
isEnabled = true
|
||||||
|
isClickable = true
|
||||||
|
|
||||||
|
setOnClickListener {
|
||||||
|
val tag = Tag.parse(tag)
|
||||||
|
|
||||||
|
if (favoriteTags.contains(tag)) {
|
||||||
|
setImageResource(R.drawable.ic_star_empty)
|
||||||
|
favoriteTags.remove(tag)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setImageDrawable(
|
||||||
|
AnimatedVectorDrawableCompat.create(context,
|
||||||
|
R.drawable.avd_star
|
||||||
|
))
|
||||||
|
(drawable as Animatable).start()
|
||||||
|
|
||||||
|
favoriteTags.add(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.t > 0) {
|
||||||
|
(suggestionView as? LinearLayout)?.let {
|
||||||
|
val count = it.findViewById<TextView>(R.id.count)
|
||||||
|
if (count == null)
|
||||||
|
it.addView(
|
||||||
|
LayoutInflater.from(context).inflate(R.layout.suggestion_count, suggestionView, false)
|
||||||
|
.apply {
|
||||||
|
this as TextView
|
||||||
|
|
||||||
|
text = item.t.toString()
|
||||||
|
}, 2
|
||||||
|
)
|
||||||
|
else
|
||||||
|
count.text = item.t.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is FavoriteHistorySwitch -> {
|
||||||
|
leftIcon?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.swap_horizontal, context.theme))
|
||||||
|
}
|
||||||
|
is Suggestion -> {
|
||||||
|
leftIcon?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.history, context.theme))
|
||||||
|
|
||||||
|
with(suggestionView?.findViewById<ImageView>(R.id.right_icon)) {
|
||||||
|
this ?: return@with
|
||||||
|
|
||||||
|
setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.delete, context.theme))
|
||||||
|
|
||||||
|
visibility = View.VISIBLE
|
||||||
|
rotation = 0f
|
||||||
|
|
||||||
|
isEnabled = true
|
||||||
|
isClickable = true
|
||||||
|
|
||||||
|
setOnClickListener {
|
||||||
|
onHistoryDeleteClickedListener?.invoke(item.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LoadingSuggestion -> {
|
||||||
|
leftIcon?.setImageDrawable(CircularProgressDrawable(context).also {
|
||||||
|
it.setStyle(CircularProgressDrawable.DEFAULT)
|
||||||
|
it.colorFilter = PorterDuffColorFilter(ContextCompat.getColor(context, R.color.colorAccent), PorterDuff.Mode.SRC_IN)
|
||||||
|
it.start()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
is NoResultSuggestion -> {
|
||||||
|
leftIcon?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.close, context.theme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
462
app/src/main/java/xyz/quaver/pupil/ui/view/MainView.java
Normal file
462
app/src/main/java/xyz/quaver/pupil/ui/view/MainView.java
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.ui.view;
|
||||||
|
|
||||||
|
import android.animation.ValueAnimator;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.res.Configuration;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Rect;
|
||||||
|
import android.os.Vibrator;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.util.DisplayMetrics;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.view.animation.DecelerateInterpolator;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate;
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
import androidx.core.view.NestedScrollingChild;
|
||||||
|
import androidx.core.view.NestedScrollingChildHelper;
|
||||||
|
import androidx.core.view.NestedScrollingParent;
|
||||||
|
import androidx.core.view.NestedScrollingParentHelper;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
|
import androidx.core.widget.TextViewCompat;
|
||||||
|
|
||||||
|
import xyz.quaver.pupil.R;
|
||||||
|
|
||||||
|
@SuppressWarnings("NullableProblems")
|
||||||
|
public class MainView extends ViewGroup implements NestedScrollingChild, NestedScrollingParent {
|
||||||
|
|
||||||
|
private static final int PAGE_TURN_LAYOUT_SIZE = 48;
|
||||||
|
private static final int PAGE_TURN_ANIM_DURATION = 500;
|
||||||
|
private static final int PREV_OFFSET = 64;
|
||||||
|
private static final int RIPPLE_GIVE = 4;
|
||||||
|
|
||||||
|
private final float adjustedPageTurnLayoutSize;
|
||||||
|
private final float adjustedPrevOffset;
|
||||||
|
private final float adjustedRippleGive;
|
||||||
|
|
||||||
|
final private NestedScrollingParentHelper mNestedScrollingParentHelper;
|
||||||
|
final private NestedScrollingChildHelper mNestedScrollingChildHelper;
|
||||||
|
|
||||||
|
final private Vibrator mVibrator;
|
||||||
|
|
||||||
|
private View mTarget;
|
||||||
|
|
||||||
|
private TextView mPrev;
|
||||||
|
private TextView mNext;
|
||||||
|
|
||||||
|
private final Paint mRipplePaint = new Paint();
|
||||||
|
private final Rect mRippleBound = new Rect();
|
||||||
|
|
||||||
|
private int mRippleSize = 0;
|
||||||
|
private final int mRippleTargetSize;
|
||||||
|
private final ValueAnimator mRippleAnimator = new ValueAnimator();
|
||||||
|
|
||||||
|
private int mCurrentOverScroll = 0;
|
||||||
|
|
||||||
|
private int mCurrentPage = 1;
|
||||||
|
private boolean mShowPrev;
|
||||||
|
private boolean mShowNext;
|
||||||
|
|
||||||
|
private OnPageTurnListener mOnPageTurnListener;
|
||||||
|
|
||||||
|
public MainView(@NonNull Context context) {
|
||||||
|
this(context, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MainView(@NonNull Context context, AttributeSet attr) {
|
||||||
|
this(context, attr, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MainView(@NonNull Context context, AttributeSet attr, int defStyle) {
|
||||||
|
super(context, attr, defStyle);
|
||||||
|
|
||||||
|
setWillNotDraw(false);
|
||||||
|
|
||||||
|
DisplayMetrics metrics = getResources().getDisplayMetrics();
|
||||||
|
|
||||||
|
adjustedPageTurnLayoutSize = PAGE_TURN_LAYOUT_SIZE * metrics.density;
|
||||||
|
adjustedPrevOffset = PREV_OFFSET * metrics.density;
|
||||||
|
adjustedRippleGive = RIPPLE_GIVE * metrics.density;
|
||||||
|
|
||||||
|
mRippleTargetSize = metrics.widthPixels;
|
||||||
|
|
||||||
|
mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);
|
||||||
|
mNestedScrollingChildHelper = new NestedScrollingChildHelper(this);
|
||||||
|
|
||||||
|
mVibrator = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
|
||||||
|
mRippleAnimator.addUpdateListener(animation -> {
|
||||||
|
mRippleSize = (int) animation.getAnimatedValue();
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
mRippleAnimator.setDuration(PAGE_TURN_ANIM_DURATION);
|
||||||
|
|
||||||
|
initPageTurnView();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCurrentPage(int currentPage, boolean showNext) {
|
||||||
|
mCurrentPage = currentPage;
|
||||||
|
|
||||||
|
mShowPrev = currentPage > 1;
|
||||||
|
mShowNext = showNext;
|
||||||
|
|
||||||
|
mPrev.setText(getContext().getString(R.string.main_move_to_page, mCurrentPage-1));
|
||||||
|
mNext.setText(getContext().getString(R.string.main_move_to_page, mCurrentPage+1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOnPageTurnListener(OnPageTurnListener listener) {
|
||||||
|
mOnPageTurnListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initPageTurnView() {
|
||||||
|
TextView prev = new TextView(getContext());
|
||||||
|
TextView next = new TextView(getContext());
|
||||||
|
|
||||||
|
prev.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
|
next.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
|
|
||||||
|
prev.setCompoundDrawablesWithIntrinsicBounds(R.drawable.navigate_prev, 0, 0, 0);
|
||||||
|
next.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.navigate_next, 0);
|
||||||
|
|
||||||
|
TextViewCompat.setCompoundDrawableTintList(prev, AppCompatResources.getColorStateList(getContext(), R.color.colorAccent));
|
||||||
|
TextViewCompat.setCompoundDrawableTintList(next, AppCompatResources.getColorStateList(getContext(), R.color.colorAccent));
|
||||||
|
|
||||||
|
prev.setVisibility(View.INVISIBLE);
|
||||||
|
next.setVisibility(View.INVISIBLE);
|
||||||
|
|
||||||
|
mPrev = prev;
|
||||||
|
mNext = next;
|
||||||
|
|
||||||
|
addView(mPrev);
|
||||||
|
addView(mNext);
|
||||||
|
|
||||||
|
setCurrentPage(1, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureTarget() {
|
||||||
|
if (mTarget == null) {
|
||||||
|
for (int i = 0; i < getChildCount(); i++) {
|
||||||
|
View child = getChildAt(i);
|
||||||
|
|
||||||
|
if (!child.equals(mNext) && !child.equals(mPrev)) {
|
||||||
|
mTarget = child;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onLayout(boolean changed, int l, int t, int r, int b) {
|
||||||
|
final int width = getMeasuredWidth();
|
||||||
|
final int height = getMeasuredHeight();
|
||||||
|
|
||||||
|
if (getChildCount() == 0)
|
||||||
|
return;
|
||||||
|
if (mTarget == null)
|
||||||
|
ensureTarget();
|
||||||
|
if (mTarget == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
mTarget.layout(
|
||||||
|
getPaddingLeft(),
|
||||||
|
getPaddingTop(),
|
||||||
|
width - getPaddingRight(),
|
||||||
|
height - getPaddingBottom()
|
||||||
|
);
|
||||||
|
|
||||||
|
final int prevWidth = mPrev.getMeasuredWidth();
|
||||||
|
mPrev.layout(
|
||||||
|
width / 2 - prevWidth / 2,
|
||||||
|
getPaddingTop() + (int) adjustedPrevOffset,
|
||||||
|
width / 2 + prevWidth / 2,
|
||||||
|
getPaddingTop() + (int) adjustedPrevOffset + mPrev.getMeasuredHeight()
|
||||||
|
);
|
||||||
|
|
||||||
|
final int nextWidth = mNext.getMeasuredWidth();
|
||||||
|
mNext.layout(
|
||||||
|
width / 2 - nextWidth / 2,
|
||||||
|
height - getPaddingBottom() - mNext.getMeasuredHeight(),
|
||||||
|
width / 2 + nextWidth / 2,
|
||||||
|
height - getPaddingBottom()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||||
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||||
|
if (mTarget == null)
|
||||||
|
ensureTarget();
|
||||||
|
if (mTarget == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
mTarget.measure(
|
||||||
|
MeasureSpec.makeMeasureSpec(getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY),
|
||||||
|
MeasureSpec.makeMeasureSpec(getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)
|
||||||
|
);
|
||||||
|
|
||||||
|
mPrev.measure(
|
||||||
|
MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.AT_MOST),
|
||||||
|
MeasureSpec.makeMeasureSpec((int) adjustedPageTurnLayoutSize, MeasureSpec.EXACTLY)
|
||||||
|
);
|
||||||
|
|
||||||
|
mNext.measure(
|
||||||
|
MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.AT_MOST),
|
||||||
|
MeasureSpec.makeMeasureSpec((int) adjustedPageTurnLayoutSize, MeasureSpec.EXACTLY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas canvas) {
|
||||||
|
super.onDraw(canvas);
|
||||||
|
|
||||||
|
if (mCurrentOverScroll == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (mCurrentOverScroll > 0) {
|
||||||
|
mRippleBound.set(
|
||||||
|
getPaddingLeft(),
|
||||||
|
(int) (getPaddingTop() - adjustedRippleGive),
|
||||||
|
getMeasuredWidth() - getPaddingRight(),
|
||||||
|
(int) (getPaddingTop() + adjustedPrevOffset + mPrev.getMeasuredHeight() + adjustedRippleGive)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mCurrentOverScroll < 0) {
|
||||||
|
final int height = getMeasuredHeight();
|
||||||
|
mRippleBound.set(
|
||||||
|
getPaddingLeft(),
|
||||||
|
(int) (height - getPaddingBottom() - mNext.getMeasuredHeight() - adjustedRippleGive),
|
||||||
|
getMeasuredWidth() - getPaddingRight(),
|
||||||
|
height - getPaddingBottom()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
mRipplePaint.reset();
|
||||||
|
mRipplePaint.setStyle(Paint.Style.FILL);
|
||||||
|
|
||||||
|
int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
|
||||||
|
|
||||||
|
switch (currentNightMode) {
|
||||||
|
case Configuration.UI_MODE_NIGHT_YES:
|
||||||
|
mRipplePaint.setColor(ContextCompat.getColor(getContext(), R.color.material_light_blue_700));
|
||||||
|
break;
|
||||||
|
case Configuration.UI_MODE_NIGHT_NO:
|
||||||
|
mRipplePaint.setColor(ContextCompat.getColor(getContext(), R.color.material_light_blue_300));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.drawCircle(
|
||||||
|
(mRippleBound.left + mRippleBound.right) / 2F,
|
||||||
|
mCurrentOverScroll > 0 ? mRippleBound.bottom : mRippleBound.top,
|
||||||
|
mRippleSize,
|
||||||
|
mRipplePaint
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onOverscroll(int overscroll) {
|
||||||
|
if (mTarget == null)
|
||||||
|
ensureTarget();
|
||||||
|
if (mTarget == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
mCurrentOverScroll = overscroll;
|
||||||
|
|
||||||
|
if (overscroll > 0) {
|
||||||
|
mPrev.setVisibility(View.VISIBLE);
|
||||||
|
mNext.setVisibility(View.INVISIBLE);
|
||||||
|
} else if (overscroll < 0) {
|
||||||
|
mPrev.setVisibility(View.INVISIBLE);
|
||||||
|
mNext.setVisibility(View.VISIBLE);
|
||||||
|
} else {
|
||||||
|
mPrev.setVisibility(View.INVISIBLE);
|
||||||
|
mNext.setVisibility(View.INVISIBLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(overscroll) >= adjustedPageTurnLayoutSize) {
|
||||||
|
if (!mRippleAnimator.isStarted() && mRippleSize != mRippleTargetSize) {
|
||||||
|
mVibrator.vibrate(10);
|
||||||
|
|
||||||
|
mRippleAnimator.setIntValues(mRippleSize, mRippleTargetSize);
|
||||||
|
mRippleAnimator.start();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!mRippleAnimator.isStarted() && mRippleSize != 0) {
|
||||||
|
mRippleAnimator.setIntValues(mRippleSize, 0);
|
||||||
|
mRippleAnimator.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float clippedOverScrollTop = (overscroll > 0 ? 1 : -1) * Math.min(Math.abs(overscroll), adjustedPageTurnLayoutSize);
|
||||||
|
mTarget.setTranslationY(clippedOverScrollTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onOverscrollEnd(int overscroll) {
|
||||||
|
if (mTarget == null)
|
||||||
|
ensureTarget();
|
||||||
|
if (mTarget == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
mRippleAnimator.cancel();
|
||||||
|
mRippleAnimator.setIntValues(mRippleSize, 0);
|
||||||
|
mRippleAnimator.start();
|
||||||
|
|
||||||
|
mPrev.setVisibility(View.INVISIBLE);
|
||||||
|
mNext.setVisibility(View.INVISIBLE);
|
||||||
|
|
||||||
|
ViewCompat.animate(mTarget)
|
||||||
|
.setDuration(PAGE_TURN_ANIM_DURATION)
|
||||||
|
.setInterpolator(new DecelerateInterpolator())
|
||||||
|
.translationY(0);
|
||||||
|
|
||||||
|
if (Math.abs(overscroll) > adjustedPageTurnLayoutSize && mOnPageTurnListener != null) {
|
||||||
|
if (overscroll > 0)
|
||||||
|
mOnPageTurnListener.onPrev(mCurrentPage-1);
|
||||||
|
if (overscroll < 0)
|
||||||
|
mOnPageTurnListener.onNext(mCurrentPage+1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NestedScrollingParent
|
||||||
|
|
||||||
|
private int mTotalUnconsumed = 0;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
|
||||||
|
return isEnabled() && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNestedScrollAccepted(View child, View target, int axes) {
|
||||||
|
mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes);
|
||||||
|
startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL);
|
||||||
|
|
||||||
|
mTotalUnconsumed = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
|
||||||
|
if (mTotalUnconsumed != 0 && dy > 0 == mTotalUnconsumed > 0) {
|
||||||
|
if (Math.abs(dy) > Math.abs(mTotalUnconsumed)) {
|
||||||
|
consumed[1] = dy - mTotalUnconsumed;
|
||||||
|
mTotalUnconsumed = 0;
|
||||||
|
} else {
|
||||||
|
mTotalUnconsumed -= dy;
|
||||||
|
consumed[1] = dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverscroll(mTotalUnconsumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int[] parentConsumed = new int[2];
|
||||||
|
if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) {
|
||||||
|
consumed[0] += parentConsumed[0];
|
||||||
|
consumed[1] += parentConsumed[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
|
||||||
|
final int[] mParentOffsetInWindow = new int[2];
|
||||||
|
dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, mParentOffsetInWindow);
|
||||||
|
|
||||||
|
final int dy = dyUnconsumed + mParentOffsetInWindow[1];
|
||||||
|
|
||||||
|
if (mTotalUnconsumed == 0 && ((dy < 0 && !mShowPrev) || (dy > 0 && !mShowNext)))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (dy != 0) {
|
||||||
|
mTotalUnconsumed -= dy;
|
||||||
|
onOverscroll(mTotalUnconsumed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStopNestedScroll(View child) {
|
||||||
|
mNestedScrollingParentHelper.onStopNestedScroll(child);
|
||||||
|
|
||||||
|
if (Math.abs(mTotalUnconsumed) > 0) {
|
||||||
|
onOverscrollEnd(mTotalUnconsumed);
|
||||||
|
mTotalUnconsumed = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopNestedScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// NestedScrollingChild
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setNestedScrollingEnabled(boolean enabled) {
|
||||||
|
mNestedScrollingChildHelper.setNestedScrollingEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isNestedScrollingEnabled() {
|
||||||
|
return mNestedScrollingChildHelper.isNestedScrollingEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean startNestedScroll(int axes) {
|
||||||
|
return mNestedScrollingChildHelper.startNestedScroll(axes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stopNestedScroll() {
|
||||||
|
mNestedScrollingChildHelper.stopNestedScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasNestedScrollingParent() {
|
||||||
|
return mNestedScrollingChildHelper.hasNestedScrollingParent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow) {
|
||||||
|
return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow) {
|
||||||
|
return mNestedScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
|
||||||
|
return mNestedScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
|
||||||
|
return mNestedScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface OnPageTurnListener {
|
||||||
|
void onPrev(int page);
|
||||||
|
void onNext(int page);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/src/main/java/xyz/quaver/pupil/ui/view/ProgressCard.kt
Normal file
72
app/src/main/java/xyz/quaver/pupil/ui/view/ProgressCard.kt
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package xyz.quaver.pupil.ui.view
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.cardview.widget.CardView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.databinding.ProgressCardViewBinding
|
||||||
|
|
||||||
|
class ProgressCard @JvmOverloads constructor(context: Context, attr: AttributeSet? = null, defStyle: Int = R.attr.cardViewStyle) : CardView(context, attr, defStyle) {
|
||||||
|
|
||||||
|
enum class Type {
|
||||||
|
LOADING,
|
||||||
|
CACHE,
|
||||||
|
DOWNLOAD
|
||||||
|
}
|
||||||
|
|
||||||
|
var type: Type = Type.LOADING
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
|
||||||
|
when (field) {
|
||||||
|
Type.LOADING -> R.color.colorAccent
|
||||||
|
Type.CACHE -> R.color.material_blue_700
|
||||||
|
Type.DOWNLOAD -> R.color.material_green_a700
|
||||||
|
}.let {
|
||||||
|
val color = ContextCompat.getColor(context, it)
|
||||||
|
DrawableCompat.setTint(binding.progressbar.progressDrawable, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress: Int
|
||||||
|
get() = binding.progressbar.progress
|
||||||
|
set(value) {
|
||||||
|
binding.progressbar.progress = value
|
||||||
|
}
|
||||||
|
var max: Int
|
||||||
|
get() = binding.progressbar.max
|
||||||
|
set(value) {
|
||||||
|
binding.progressbar.max = value
|
||||||
|
|
||||||
|
binding.progressbar.visibility =
|
||||||
|
if (value == 0)
|
||||||
|
GONE
|
||||||
|
else
|
||||||
|
VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
val binding = ProgressCardViewBinding.inflate(LayoutInflater.from(context), this)
|
||||||
|
|
||||||
|
init {
|
||||||
|
binding.content.setOnClickListener {
|
||||||
|
performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.content.setOnLongClickListener {
|
||||||
|
performLongClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
|
||||||
|
if (childCount == 0)
|
||||||
|
super.addView(child, index, params)
|
||||||
|
else
|
||||||
|
binding.content.addView(child, index, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
100
app/src/main/java/xyz/quaver/pupil/ui/view/TagChip.kt
Normal file
100
app/src/main/java/xyz/quaver/pupil/ui/view/TagChip.kt
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.ui.view
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.favoriteTags
|
||||||
|
import xyz.quaver.pupil.types.Tag
|
||||||
|
import xyz.quaver.pupil.util.translations
|
||||||
|
import xyz.quaver.pupil.util.wordCapitalize
|
||||||
|
|
||||||
|
@SuppressLint("ViewConstructor")
|
||||||
|
class TagChip(context: Context, _tag: Tag) : Chip(context) {
|
||||||
|
|
||||||
|
val tag: Tag =
|
||||||
|
_tag.let {
|
||||||
|
when {
|
||||||
|
it.area != null -> it
|
||||||
|
else -> Tag("tag", _tag.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val languages = context.resources.getStringArray(R.array.languages).map {
|
||||||
|
it.split("|").let { split ->
|
||||||
|
Pair(split[0], split[1])
|
||||||
|
}
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
init {
|
||||||
|
when(tag.area) {
|
||||||
|
"male" -> {
|
||||||
|
setChipBackgroundColorResource(R.color.material_blue_700)
|
||||||
|
setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
||||||
|
setCloseIconTintResource(android.R.color.white)
|
||||||
|
chipIcon = ContextCompat.getDrawable(context, R.drawable.gender_male_white)
|
||||||
|
}
|
||||||
|
"female" -> {
|
||||||
|
setChipBackgroundColorResource(R.color.material_pink_600)
|
||||||
|
setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
||||||
|
setCloseIconTintResource(android.R.color.white)
|
||||||
|
chipIcon = ContextCompat.getDrawable(context, R.drawable.gender_female_white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (favoriteTags.contains(tag))
|
||||||
|
setChipBackgroundColorResource(R.color.material_orange_500)
|
||||||
|
|
||||||
|
isCloseIconVisible = true
|
||||||
|
closeIcon = ContextCompat.getDrawable(context,
|
||||||
|
if (favoriteTags.contains(tag))
|
||||||
|
R.drawable.ic_star_filled
|
||||||
|
else
|
||||||
|
R.drawable.ic_star_empty
|
||||||
|
)
|
||||||
|
|
||||||
|
setOnCloseIconClickListener {
|
||||||
|
if (favoriteTags.contains(tag)) {
|
||||||
|
favoriteTags.remove(tag)
|
||||||
|
closeIcon = ContextCompat.getDrawable(context, R.drawable.ic_star_empty)
|
||||||
|
|
||||||
|
when(tag.area) {
|
||||||
|
"male" -> setChipBackgroundColorResource(R.color.material_blue_700)
|
||||||
|
"female" -> setChipBackgroundColorResource(R.color.material_pink_600)
|
||||||
|
else -> chipBackgroundColor = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
favoriteTags.add(tag)
|
||||||
|
closeIcon = ContextCompat.getDrawable(context, R.drawable.ic_star_filled)
|
||||||
|
setChipBackgroundColorResource(R.color.material_orange_500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text = when (tag.area) {
|
||||||
|
"language" -> languages[tag.tag]
|
||||||
|
else -> (translations[tag.tag] ?: tag.tag).wordCapitalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnsureMinTouchTargetSize(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
100
app/src/main/java/xyz/quaver/pupil/ui/view/TagChipGroup.kt
Normal file
100
app/src/main/java/xyz/quaver/pupil/ui/view/TagChipGroup.kt
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.ui.view
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.TypedArray
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
|
import com.google.android.material.chip.ChipGroup
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.types.Tag
|
||||||
|
import xyz.quaver.pupil.types.Tags
|
||||||
|
|
||||||
|
class TagChipGroup @JvmOverloads constructor(context: Context, attr: AttributeSet? = null, attrStyle: Int = R.attr.chipGroupStyle, val tags: Tags = Tags()) : ChipGroup(context, attr, attrStyle), MutableSet<Tag> by tags {
|
||||||
|
|
||||||
|
object Defaults {
|
||||||
|
const val maxChipSize = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxChipSize: Int = Defaults.maxChipSize
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val moreView = Chip(context).apply {
|
||||||
|
text = "…"
|
||||||
|
|
||||||
|
setEnsureMinTouchTargetSize(false)
|
||||||
|
|
||||||
|
setOnClickListener {
|
||||||
|
removeView(this)
|
||||||
|
|
||||||
|
for (i in maxChipSize until tags.size) {
|
||||||
|
val tag = tags.elementAt(i)
|
||||||
|
|
||||||
|
addView(TagChip(context, tag).apply {
|
||||||
|
setOnClickListener {
|
||||||
|
onClickListener?.invoke(tag)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var onClickListener: ((Tag) -> Unit)? = null
|
||||||
|
|
||||||
|
private fun applyAttributes(attr: TypedArray) {
|
||||||
|
maxChipSize = attr.getInt(R.styleable.TagChipGroup_maxTag, Defaults.maxChipSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refreshJob: Job? = null
|
||||||
|
fun refresh() {
|
||||||
|
refreshJob?.cancel()
|
||||||
|
this.removeAllViews()
|
||||||
|
|
||||||
|
refreshJob = CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
tags.take(maxChipSize).map {
|
||||||
|
CoroutineScope(Dispatchers.Default).async {
|
||||||
|
TagChip(context, it).apply {
|
||||||
|
setOnClickListener {
|
||||||
|
onClickListener?.invoke(this.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.forEach {
|
||||||
|
addView(it.await())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxChipSize > 0 && tags.size > maxChipSize)
|
||||||
|
addView(moreView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
applyAttributes(context.obtainStyledAttributes(attr, R.styleable.TagChipGroup))
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pupil, Hitomi.la viewer for Android
|
|
||||||
* Copyright (C) 2020 tom5079
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.util;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Copyright (C) 2007-2008 OpenIntents.org
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import android.content.ContentUris;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.provider.DocumentsContract;
|
|
||||||
import android.provider.MediaStore;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @version 2009-07-03
|
|
||||||
* @author Peli
|
|
||||||
* @version 2013-12-11
|
|
||||||
* @author paulburke (ipaulpro)
|
|
||||||
*/
|
|
||||||
public class FileUtils {
|
|
||||||
/**
|
|
||||||
* Get a file path from a Uri. This will get the the path for Storage Access
|
|
||||||
* Framework Documents, as well as the _data field for the MediaStore and
|
|
||||||
* other file-based ContentProviders.
|
|
||||||
*
|
|
||||||
* @param context The context.
|
|
||||||
* @param uri The Uri to query.
|
|
||||||
* @author paulburke
|
|
||||||
*/
|
|
||||||
public static String getPath(final Context context, final Uri uri) {
|
|
||||||
|
|
||||||
// DocumentProvider
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && DocumentsContract.isDocumentUri(context, uri)) {
|
|
||||||
// ExternalStorageProvider
|
|
||||||
if (isExternalStorageDocument(uri)) {
|
|
||||||
final String docId = DocumentsContract.getDocumentId(uri);
|
|
||||||
final String[] split = docId.split(":");
|
|
||||||
final String type = split[0];
|
|
||||||
|
|
||||||
if ("primary".equalsIgnoreCase(type)) {
|
|
||||||
return context.getExternalFilesDir(null).getParentFile().getParentFile().getParentFile().getParent() + "/" + split[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO handle non-primary volumes
|
|
||||||
}
|
|
||||||
// DownloadsProvider
|
|
||||||
else if (isDownloadsDocument(uri)) {
|
|
||||||
|
|
||||||
final String id = DocumentsContract.getDocumentId(uri);
|
|
||||||
final Uri contentUri = ContentUris.withAppendedId(
|
|
||||||
Uri.parse("content://downloads/public_downloads"), Long.valueOf(id));
|
|
||||||
|
|
||||||
return getDataColumn(context, contentUri, null, null);
|
|
||||||
}
|
|
||||||
// MediaProvider
|
|
||||||
else if (isMediaDocument(uri)) {
|
|
||||||
final String docId = DocumentsContract.getDocumentId(uri);
|
|
||||||
final String[] split = docId.split(":");
|
|
||||||
final String type = split[0];
|
|
||||||
|
|
||||||
Uri contentUri = null;
|
|
||||||
if ("image".equals(type)) {
|
|
||||||
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
|
|
||||||
} else if ("video".equals(type)) {
|
|
||||||
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
|
|
||||||
} else if ("audio".equals(type)) {
|
|
||||||
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String selection = "_id=?";
|
|
||||||
final String[] selectionArgs = new String[] {
|
|
||||||
split[1]
|
|
||||||
};
|
|
||||||
|
|
||||||
return getDataColumn(context, contentUri, selection, selectionArgs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// MediaStore (and general)
|
|
||||||
else if ("content".equalsIgnoreCase(uri.getScheme())) {
|
|
||||||
return getDataColumn(context, uri, null, null);
|
|
||||||
}
|
|
||||||
// File
|
|
||||||
else if ("file".equalsIgnoreCase(uri.getScheme())) {
|
|
||||||
return uri.getPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the value of the data column for this Uri. This is useful for
|
|
||||||
* MediaStore Uris, and other file-based ContentProviders.
|
|
||||||
*
|
|
||||||
* @param context The context.
|
|
||||||
* @param uri The Uri to query.
|
|
||||||
* @param selection (Optional) Filter used in the query.
|
|
||||||
* @param selectionArgs (Optional) Selection arguments used in the query.
|
|
||||||
* @return The value of the _data column, which is typically a file path.
|
|
||||||
*/
|
|
||||||
public static String getDataColumn(Context context, Uri uri, String selection,
|
|
||||||
String[] selectionArgs) {
|
|
||||||
|
|
||||||
Cursor cursor = null;
|
|
||||||
final String column = "_data";
|
|
||||||
final String[] projection = {
|
|
||||||
column
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs,
|
|
||||||
null);
|
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
|
||||||
final int column_index = cursor.getColumnIndexOrThrow(column);
|
|
||||||
return cursor.getString(column_index);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (cursor != null)
|
|
||||||
cursor.close();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param uri The Uri to check.
|
|
||||||
* @return Whether the Uri authority is ExternalStorageProvider.
|
|
||||||
*/
|
|
||||||
public static boolean isExternalStorageDocument(Uri uri) {
|
|
||||||
return "com.android.externalstorage.documents".equals(uri.getAuthority());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param uri The Uri to check.
|
|
||||||
* @return Whether the Uri authority is DownloadsProvider.
|
|
||||||
*/
|
|
||||||
public static boolean isDownloadsDocument(Uri uri) {
|
|
||||||
return "com.android.providers.downloads.documents".equals(uri.getAuthority());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param uri The Uri to check.
|
|
||||||
* @return Whether the Uri authority is MediaProvider.
|
|
||||||
*/
|
|
||||||
public static boolean isMediaDocument(Uri uri) {
|
|
||||||
return "com.android.providers.media.documents".equals(uri.getAuthority());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package xyz.quaver.pupil.util;
|
|
||||||
|
|
||||||
import android.view.View;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
|
||||||
import xyz.quaver.pupil.R;
|
|
||||||
|
|
||||||
/*
|
|
||||||
Source: http://www.littlerobots.nl/blog/Handle-Android-RecyclerView-Clicks/
|
|
||||||
USAGE:
|
|
||||||
|
|
||||||
ItemClickSupport.addTo(mRecyclerView).setOnItemClickListener(new ItemClickSupport.OnItemClickListener() {
|
|
||||||
@Override
|
|
||||||
public void onItemClicked(RecyclerView recyclerView, int position, View v) {
|
|
||||||
// do it
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
*/
|
|
||||||
public class ItemClickSupport {
|
|
||||||
private final RecyclerView mRecyclerView;
|
|
||||||
private OnItemClickListener mOnItemClickListener;
|
|
||||||
private OnItemLongClickListener mOnItemLongClickListener;
|
|
||||||
private View.OnClickListener mOnClickListener = new View.OnClickListener() {
|
|
||||||
@Override
|
|
||||||
public void onClick(View v) {
|
|
||||||
if (mOnItemClickListener != null) {
|
|
||||||
RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(v);
|
|
||||||
mOnItemClickListener.onItemClicked(mRecyclerView, holder.getAdapterPosition(), v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
private View.OnLongClickListener mOnLongClickListener = new View.OnLongClickListener() {
|
|
||||||
@Override
|
|
||||||
public boolean onLongClick(View v) {
|
|
||||||
if (mOnItemLongClickListener != null) {
|
|
||||||
RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(v);
|
|
||||||
return mOnItemLongClickListener.onItemLongClicked(mRecyclerView, holder.getAdapterPosition(), v);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
private RecyclerView.OnChildAttachStateChangeListener mAttachListener
|
|
||||||
= new RecyclerView.OnChildAttachStateChangeListener() {
|
|
||||||
@Override
|
|
||||||
public void onChildViewAttachedToWindow(@NonNull View view) {
|
|
||||||
if (mOnItemClickListener != null) {
|
|
||||||
view.setOnClickListener(mOnClickListener);
|
|
||||||
}
|
|
||||||
if (mOnItemLongClickListener != null) {
|
|
||||||
view.setOnLongClickListener(mOnLongClickListener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onChildViewDetachedFromWindow(@NonNull View view) {
|
|
||||||
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private ItemClickSupport(RecyclerView recyclerView) {
|
|
||||||
mRecyclerView = recyclerView;
|
|
||||||
mRecyclerView.setTag(R.id.item_click_support, this);
|
|
||||||
mRecyclerView.addOnChildAttachStateChangeListener(mAttachListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ItemClickSupport addTo(RecyclerView view) {
|
|
||||||
ItemClickSupport support = (ItemClickSupport) view.getTag(R.id.item_click_support);
|
|
||||||
if (support == null) {
|
|
||||||
support = new ItemClickSupport(view);
|
|
||||||
}
|
|
||||||
return support;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ItemClickSupport removeFrom(RecyclerView view) {
|
|
||||||
ItemClickSupport support = (ItemClickSupport) view.getTag(R.id.item_click_support);
|
|
||||||
if (support != null) {
|
|
||||||
support.detach(view);
|
|
||||||
}
|
|
||||||
return support;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ItemClickSupport setOnItemClickListener(OnItemClickListener listener) {
|
|
||||||
mOnItemClickListener = listener;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ItemClickSupport setOnItemLongClickListener(OnItemLongClickListener listener) {
|
|
||||||
mOnItemLongClickListener = listener;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void detach(RecyclerView view) {
|
|
||||||
view.removeOnChildAttachStateChangeListener(mAttachListener);
|
|
||||||
view.setTag(R.id.item_click_support, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface OnItemClickListener {
|
|
||||||
|
|
||||||
void onItemClicked(RecyclerView recyclerView, int position, View v);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface OnItemLongClickListener {
|
|
||||||
|
|
||||||
boolean onItemLongClicked(RecyclerView recyclerView, int position, View v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
69
app/src/main/java/xyz/quaver/pupil/util/ItemClickSupport.kt
Normal file
69
app/src/main/java/xyz/quaver/pupil/util/ItemClickSupport.kt
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
|
||||||
|
class ItemClickSupport(private val recyclerView: RecyclerView) {
|
||||||
|
|
||||||
|
var onItemClickListener: ((RecyclerView, Int, View) -> Unit)? = null
|
||||||
|
var onItemLongClickListener: ((RecyclerView, Int, View) -> Boolean)? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
recyclerView.apply {
|
||||||
|
setTag(R.id.item_click_support, this)
|
||||||
|
addOnChildAttachStateChangeListener(object: RecyclerView.OnChildAttachStateChangeListener {
|
||||||
|
override fun onChildViewAttachedToWindow(view: View) {
|
||||||
|
onItemClickListener?.let { listener ->
|
||||||
|
view.setOnClickListener {
|
||||||
|
recyclerView.getChildViewHolder(view).let { holder ->
|
||||||
|
listener.invoke(recyclerView, holder.adapterPosition, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onItemLongClickListener?.let { listener ->
|
||||||
|
view.setOnLongClickListener {
|
||||||
|
recyclerView.getChildViewHolder(view).let { holder ->
|
||||||
|
listener.invoke(recyclerView, holder.adapterPosition, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onChildViewDetachedFromWindow(view: View) {
|
||||||
|
// Do Nothing
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detach() {
|
||||||
|
recyclerView.apply {
|
||||||
|
clearOnChildAttachStateChangeListeners()
|
||||||
|
setTag(R.id.item_click_support, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun addTo(view: RecyclerView) = view.let { removeFrom(it); ItemClickSupport(it) }
|
||||||
|
fun removeFrom(view: RecyclerView) = (view.tag as? ItemClickSupport)?.detach()
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/src/main/java/xyz/quaver/pupil/util/Preferences.kt
Normal file
48
app/src/main/java/xyz/quaver/pupil/util/Preferences.kt
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
lateinit var preferences: SharedPreferences
|
||||||
|
|
||||||
|
object Preferences: SharedPreferences by preferences {
|
||||||
|
|
||||||
|
val defMap = mapOf(
|
||||||
|
String::class to "",
|
||||||
|
Int::class to -1,
|
||||||
|
Long::class to -1L,
|
||||||
|
Boolean::class to false,
|
||||||
|
Set::class to emptySet<Any>()
|
||||||
|
)
|
||||||
|
|
||||||
|
operator fun set(key: String, value: String) = edit().putString(key, value).apply()
|
||||||
|
operator fun set(key: String, value: Int) = edit().putInt(key, value).apply()
|
||||||
|
operator fun set(key: String, value: Long) = edit().putLong(key, value).apply()
|
||||||
|
operator fun set(key: String, value: Boolean) = edit().putBoolean(key, value).apply()
|
||||||
|
operator fun set(key: String, value: Set<String>) = edit().putStringSet(key, value).apply()
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
inline operator fun <reified T: Any> get(key: String, defaultVal: T = defMap[T::class] as T): T = (all[key] as? T) ?: defaultVal
|
||||||
|
|
||||||
|
fun remove(key: String) {
|
||||||
|
edit().remove(key).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/src/main/java/xyz/quaver/pupil/util/SavedSet.kt
Normal file
93
app/src/main/java/xyz/quaver/pupil/util/SavedSet.kt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class SavedSet <T: Any> (private val file: File, private val any: T, private val set: MutableSet<T> = mutableSetOf()) : MutableSet<T> by set {
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
val serializer: KSerializer<List<T>>
|
||||||
|
get() = ListSerializer(serializer(any::class.java) as KSerializer<T>)
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (!file.exists()) {
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun load() {
|
||||||
|
set.clear()
|
||||||
|
kotlin.runCatching {
|
||||||
|
Json.decodeFromString(serializer, file.readText())
|
||||||
|
}.onSuccess {
|
||||||
|
set.addAll(it)
|
||||||
|
}.onFailure {
|
||||||
|
FirebaseCrashlytics.getInstance().recordException(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
fun save() {
|
||||||
|
file.writeText(Json.encodeToString(serializer, set.toList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun add(element: T): Boolean {
|
||||||
|
set.remove(element)
|
||||||
|
|
||||||
|
return set.add(element).also {
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun addAll(elements: Collection<T>): Boolean {
|
||||||
|
set.removeAll(elements)
|
||||||
|
|
||||||
|
return set.addAll(elements).also {
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun remove(element: T): Boolean {
|
||||||
|
return set.remove(element).also {
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun clear() {
|
||||||
|
set.clear()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package xyz.quaver.pupil.util
|
|
||||||
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.text.style.LineHeightSpan
|
|
||||||
|
|
||||||
class SetLineOverlap(private val overlap: Boolean) : LineHeightSpan {
|
|
||||||
companion object {
|
|
||||||
private var originalBottom = 15
|
|
||||||
private var originalDescent = 13
|
|
||||||
private var overlapSaved = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun chooseHeight(
|
|
||||||
text: CharSequence?,
|
|
||||||
start: Int,
|
|
||||||
end: Int,
|
|
||||||
spanstartv: Int,
|
|
||||||
lineHeight: Int,
|
|
||||||
fm: Paint.FontMetricsInt?
|
|
||||||
) {
|
|
||||||
fm ?: return
|
|
||||||
|
|
||||||
if (overlap) {
|
|
||||||
if (overlapSaved) {
|
|
||||||
originalBottom = fm.bottom
|
|
||||||
originalDescent = fm.descent
|
|
||||||
overlapSaved = true
|
|
||||||
}
|
|
||||||
fm.bottom += fm.top
|
|
||||||
fm.descent += fm.top
|
|
||||||
} else {
|
|
||||||
fm.bottom = originalBottom
|
|
||||||
fm.descent = originalDescent
|
|
||||||
overlapSaved = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
119
app/src/main/java/xyz/quaver/pupil/util/camera.kt
Normal file
119
app/src/main/java/xyz/quaver/pupil/util/camera.kt
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:Suppress("DEPRECATION", "Recycle")
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.ImageFormat
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.hardware.Camera
|
||||||
|
import android.view.Surface
|
||||||
|
import android.view.WindowManager
|
||||||
|
import com.google.android.gms.tasks.Task
|
||||||
|
import com.google.mlkit.vision.common.InputImage
|
||||||
|
import com.google.mlkit.vision.face.Face
|
||||||
|
import com.google.mlkit.vision.face.FaceDetection
|
||||||
|
import com.google.mlkit.vision.face.FaceDetectorOptions
|
||||||
|
|
||||||
|
/** Check if this device has a camera */
|
||||||
|
private fun Context.checkCameraHardware() =
|
||||||
|
this.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)
|
||||||
|
|
||||||
|
private fun openFrontCamera() : Pair<Camera?, Int> {
|
||||||
|
var camera: Camera? = null
|
||||||
|
var cameraID: Int = -1
|
||||||
|
|
||||||
|
val cameraInfo = Camera.CameraInfo()
|
||||||
|
|
||||||
|
for (i in 0 until Camera.getNumberOfCameras()) {
|
||||||
|
Camera.getCameraInfo(i, cameraInfo)
|
||||||
|
if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT)
|
||||||
|
runCatching { Camera.open(i) }.getOrNull()?.let { camera = it; cameraID = i }
|
||||||
|
|
||||||
|
if (camera != null) break
|
||||||
|
}
|
||||||
|
|
||||||
|
return Pair(camera, cameraID)
|
||||||
|
}
|
||||||
|
|
||||||
|
val orientations = mapOf(
|
||||||
|
Surface.ROTATION_0 to 0,
|
||||||
|
Surface.ROTATION_90 to 90,
|
||||||
|
Surface.ROTATION_180 to 180,
|
||||||
|
Surface.ROTATION_270 to 270,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getRotation(context: Context, cameraID: Int): Int {
|
||||||
|
val cameraRotation = Camera.CameraInfo().also { Camera.getCameraInfo(cameraID, it) }.orientation
|
||||||
|
val rotation = orientations[(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.rotation] ?: error("")
|
||||||
|
|
||||||
|
return (cameraRotation + rotation) % 360
|
||||||
|
}
|
||||||
|
|
||||||
|
var camera: Camera? = null
|
||||||
|
var surfaceTexture: SurfaceTexture? = null
|
||||||
|
private val detector = FaceDetection.getClient(
|
||||||
|
FaceDetectorOptions.Builder()
|
||||||
|
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
private var process: Task<List<Face>>? = null
|
||||||
|
|
||||||
|
fun startCamera(context: Context, callback: (List<Face>) -> Unit) {
|
||||||
|
if (camera != null) closeCamera()
|
||||||
|
|
||||||
|
val cameraID = openFrontCamera().let { (cam, cameraID) ->
|
||||||
|
cam ?: return
|
||||||
|
camera = cam
|
||||||
|
cameraID
|
||||||
|
}
|
||||||
|
|
||||||
|
with (camera!!) {
|
||||||
|
parameters = parameters.apply {
|
||||||
|
setPreviewSize(640, 480)
|
||||||
|
previewFormat = ImageFormat.NV21
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewTexture(surfaceTexture ?: SurfaceTexture(0).also {
|
||||||
|
surfaceTexture = it
|
||||||
|
})
|
||||||
|
startPreview()
|
||||||
|
setPreviewCallback { bytes, _ ->
|
||||||
|
if (process?.isComplete == false)
|
||||||
|
return@setPreviewCallback
|
||||||
|
|
||||||
|
val rotation = getRotation(context, cameraID)
|
||||||
|
|
||||||
|
val image = InputImage.fromByteArray(bytes, 640, 480, rotation, InputImage.IMAGE_FORMAT_NV21)
|
||||||
|
process = detector.process(image)
|
||||||
|
.addOnSuccessListener(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeCamera() {
|
||||||
|
camera?.setPreviewCallback(null)
|
||||||
|
camera?.stopPreview()
|
||||||
|
surfaceTexture?.release()
|
||||||
|
surfaceTexture = null
|
||||||
|
camera?.release()
|
||||||
|
camera = null
|
||||||
|
}
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pupil, Hitomi.la viewer for Android
|
|
||||||
* Copyright (C) 2020 tom5079
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.util.download
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import android.util.Base64
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.ImplicitReflectionSerializer
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.parse
|
|
||||||
import kotlinx.serialization.stringify
|
|
||||||
import xyz.quaver.Code
|
|
||||||
import xyz.quaver.hitomi.GalleryBlock
|
|
||||||
import xyz.quaver.hitomi.Reader
|
|
||||||
import xyz.quaver.pupil.util.*
|
|
||||||
import java.io.File
|
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
class Cache(context: Context) : ContextWrapper(context) {
|
|
||||||
|
|
||||||
private val preference = PreferenceManager.getDefaultSharedPreferences(this)
|
|
||||||
|
|
||||||
// Search in this order
|
|
||||||
// Download -> Cache
|
|
||||||
fun getCachedGallery(galleryID: Int) : DocumentFile? {
|
|
||||||
var file = getDownloadDirectory(this).findFile(galleryID.toString())
|
|
||||||
|
|
||||||
if (file?.exists() == true)
|
|
||||||
return file
|
|
||||||
|
|
||||||
file = DocumentFile.fromFile(File(cacheDir, "imageCache/$galleryID"))
|
|
||||||
|
|
||||||
return if (file.exists())
|
|
||||||
file
|
|
||||||
else
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
fun getCachedMetadata(galleryID: Int) : Metadata? {
|
|
||||||
val file = (getCachedGallery(galleryID) ?: return null).findFile(".metadata")
|
|
||||||
|
|
||||||
if (file?.exists() != true)
|
|
||||||
return null
|
|
||||||
|
|
||||||
return try {
|
|
||||||
Json.parse(file.readText(this))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
//File corrupted
|
|
||||||
file.delete()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
fun setCachedMetadata(galleryID: Int, metadata: Metadata) {
|
|
||||||
val file = getCachedGallery(galleryID)?.findFile(".metadata") ?:
|
|
||||||
DocumentFile.fromFile(File(cacheDir, "imageCache/$galleryID").also {
|
|
||||||
if (!it.exists())
|
|
||||||
it.mkdirs()
|
|
||||||
}).createFile("null", ".metadata") ?: return
|
|
||||||
|
|
||||||
file.writeText(this, Json.stringify(metadata))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getThumbnail(galleryID: Int): String? {
|
|
||||||
val metadata = Cache(this).getCachedMetadata(galleryID)
|
|
||||||
|
|
||||||
val thumbnail = if (metadata?.thumbnail == null)
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val thumbnails = getGalleryBlock(galleryID)?.thumbnails
|
|
||||||
try {
|
|
||||||
Base64.encodeToString(URL(thumbnails?.firstOrNull()).readBytes(), Base64.DEFAULT)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
metadata.thumbnail
|
|
||||||
|
|
||||||
setCachedMetadata(
|
|
||||||
galleryID,
|
|
||||||
Metadata(Cache(this).getCachedMetadata(galleryID), thumbnail = thumbnail)
|
|
||||||
)
|
|
||||||
|
|
||||||
return thumbnail
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getGalleryBlock(galleryID: Int): GalleryBlock? {
|
|
||||||
val metadata = Cache(this).getCachedMetadata(galleryID)
|
|
||||||
|
|
||||||
val source = mapOf(
|
|
||||||
Code.HITOMI to { xyz.quaver.hitomi.getGalleryBlock(galleryID) },
|
|
||||||
Code.HIYOBI to { xyz.quaver.hiyobi.getGalleryBlock(galleryID) }
|
|
||||||
)
|
|
||||||
|
|
||||||
val galleryBlock = if (metadata?.galleryBlock == null)
|
|
||||||
source.entries.map {
|
|
||||||
CoroutineScope(Dispatchers.IO).async {
|
|
||||||
kotlin.runCatching {
|
|
||||||
it.value.invoke()
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
}.firstOrNull {
|
|
||||||
it.await() != null
|
|
||||||
}?.await()
|
|
||||||
else
|
|
||||||
metadata.galleryBlock
|
|
||||||
|
|
||||||
setCachedMetadata(
|
|
||||||
galleryID,
|
|
||||||
Metadata(Cache(this).getCachedMetadata(galleryID), galleryBlock = galleryBlock)
|
|
||||||
)
|
|
||||||
|
|
||||||
return galleryBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getReaderOrNull(galleryID: Int): Reader? {
|
|
||||||
return getCachedMetadata(galleryID)?.reader
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getReader(galleryID: Int): Reader? {
|
|
||||||
val metadata = getCachedMetadata(galleryID)
|
|
||||||
val mirrors = preference.getString("mirrors", null)?.split('>') ?: listOf()
|
|
||||||
|
|
||||||
val sources = mapOf(
|
|
||||||
Code.HITOMI to { xyz.quaver.hitomi.getReader(galleryID) },
|
|
||||||
Code.HIYOBI to { xyz.quaver.hiyobi.getReader(galleryID) }
|
|
||||||
).let {
|
|
||||||
if (mirrors.isNotEmpty())
|
|
||||||
it.toSortedMap(
|
|
||||||
Comparator { o1, o2 ->
|
|
||||||
mirrors.indexOf(o1.name) - mirrors.indexOf(o2.name)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else
|
|
||||||
it
|
|
||||||
}
|
|
||||||
|
|
||||||
val reader = if (metadata?.reader == null) {
|
|
||||||
CoroutineScope(Dispatchers.IO).async {
|
|
||||||
var retval: Reader? = null
|
|
||||||
|
|
||||||
for (source in sources) {
|
|
||||||
retval = kotlin.runCatching {
|
|
||||||
source.value.invoke()
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
if (retval != null)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
retval
|
|
||||||
}.await()
|
|
||||||
} else
|
|
||||||
metadata.reader
|
|
||||||
|
|
||||||
if (reader != null)
|
|
||||||
setCachedMetadata(
|
|
||||||
galleryID,
|
|
||||||
Metadata(Cache(this).getCachedMetadata(galleryID), readers = reader)
|
|
||||||
)
|
|
||||||
|
|
||||||
return reader
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getImages(galleryID: Int): List<DocumentFile?>? {
|
|
||||||
val gallery = getCachedGallery(galleryID) ?: return null
|
|
||||||
val reader = getReaderOrNull(galleryID) ?: return null
|
|
||||||
val images = gallery.listFiles()
|
|
||||||
|
|
||||||
return reader.galleryInfo.indices.map { index ->
|
|
||||||
images.firstOrNull { file -> file.name?.startsWith("%05d".format(index)) == true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun putImage(galleryID: Int, name: String, data: ByteArray) {
|
|
||||||
val cache = getCachedGallery(galleryID) ?:
|
|
||||||
DocumentFile.fromFile(File(cacheDir, "imageCache/$galleryID").also {
|
|
||||||
if (!it.exists())
|
|
||||||
it.mkdirs()
|
|
||||||
}) ?: return
|
|
||||||
|
|
||||||
if (!Regex("""^[0-9]+.+$""").matches(name))
|
|
||||||
throw IllegalArgumentException("File name is not a number")
|
|
||||||
|
|
||||||
cache.let {
|
|
||||||
if (it.findFile(name) != null)
|
|
||||||
it
|
|
||||||
else
|
|
||||||
it.createFile("null", name)
|
|
||||||
}?.writeBytes(this, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun moveToDownload(galleryID: Int) {
|
|
||||||
val cache = getCachedGallery(galleryID)
|
|
||||||
|
|
||||||
if (cache != null) {
|
|
||||||
val download = getDownloadDirectory(this)
|
|
||||||
|
|
||||||
if (!download.isParentOf(cache)) {
|
|
||||||
cache.copyRecursively(this, download)
|
|
||||||
cache.deleteRecursively()
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
getDownloadDirectory(this).createDirectory(galleryID.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isDownloading(galleryID: Int) = getCachedMetadata(galleryID)?.isDownloading == true
|
|
||||||
|
|
||||||
fun setDownloading(galleryID: Int, isDownloading: Boolean) {
|
|
||||||
setCachedMetadata(galleryID, Metadata(getCachedMetadata(galleryID), isDownloading = isDownloading))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pupil, Hitomi.la viewer for Android
|
|
||||||
* Copyright (C) 2020 tom5079
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.util.download
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.util.SparseArray
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.app.TaskStackBuilder
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import com.crashlytics.android.Crashlytics
|
|
||||||
import io.fabric.sdk.android.Fabric
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import okhttp3.*
|
|
||||||
import okio.*
|
|
||||||
import xyz.quaver.Code
|
|
||||||
import xyz.quaver.hitomi.Reader
|
|
||||||
import xyz.quaver.hitomi.getReferer
|
|
||||||
import xyz.quaver.hitomi.urlFromUrlFromHash
|
|
||||||
import xyz.quaver.hiyobi.cookie
|
|
||||||
import xyz.quaver.hiyobi.createImgList
|
|
||||||
import xyz.quaver.hiyobi.user_agent
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.ui.ReaderActivity
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
|
||||||
|
|
||||||
@UseExperimental(ExperimentalCoroutinesApi::class)
|
|
||||||
class DownloadWorker private constructor(context: Context) : ContextWrapper(context) {
|
|
||||||
|
|
||||||
private val preferences : SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
|
|
||||||
|
|
||||||
//region ProgressListener
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
private val progressListener = object: ProgressListener {
|
|
||||||
override fun update(tag: Any?, bytesRead: Long, contentLength: Long, done: Boolean) {
|
|
||||||
val (galleryID, index) = (tag as? Pair<Int, Int>) ?: return
|
|
||||||
|
|
||||||
if (!done && progress[galleryID]?.get(index)?.isFinite() == true)
|
|
||||||
progress[galleryID]?.set(index, bytesRead * 100F / contentLength)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProgressListener {
|
|
||||||
fun update(tag: Any?, bytesRead : Long, contentLength: Long, done: Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProgressResponseBody(
|
|
||||||
val tag: Any?,
|
|
||||||
val responseBody: ResponseBody,
|
|
||||||
val progressListener : ProgressListener
|
|
||||||
) : ResponseBody() {
|
|
||||||
private var bufferedSource : BufferedSource? = null
|
|
||||||
|
|
||||||
override fun contentLength() = responseBody.contentLength()
|
|
||||||
override fun contentType() = responseBody.contentType() ?: null
|
|
||||||
|
|
||||||
override fun source(): BufferedSource {
|
|
||||||
if (bufferedSource == null)
|
|
||||||
bufferedSource = Okio.buffer(source(responseBody.source()))
|
|
||||||
|
|
||||||
return bufferedSource!!
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun source(source: Source) = object: ForwardingSource(source) {
|
|
||||||
|
|
||||||
var totalBytesRead = 0L
|
|
||||||
|
|
||||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
|
||||||
val bytesRead = super.read(sink, byteCount)
|
|
||||||
|
|
||||||
totalBytesRead += if (bytesRead == -1L) 0L else bytesRead
|
|
||||||
progressListener.update(tag, totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
|
|
||||||
|
|
||||||
return bytesRead
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//endregion
|
|
||||||
|
|
||||||
//region Singleton
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
@Volatile private var instance: DownloadWorker? = null
|
|
||||||
|
|
||||||
fun getInstance(context: Context) =
|
|
||||||
instance ?: synchronized(this) {
|
|
||||||
instance ?: DownloadWorker(context).also { instance = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//endregion
|
|
||||||
|
|
||||||
val notificationManager = NotificationManagerCompat.from(context)
|
|
||||||
|
|
||||||
val queue = LinkedBlockingQueue<Int>()
|
|
||||||
|
|
||||||
/*
|
|
||||||
* KEY
|
|
||||||
* primary galleryID
|
|
||||||
* secondary index
|
|
||||||
* PRIMARY VALUE
|
|
||||||
* MutableList -> Download in progress
|
|
||||||
* null -> Loading / Gallery doesn't exist
|
|
||||||
* SECONDARY VALUE
|
|
||||||
* 0 <= value < 100 -> Download in progress
|
|
||||||
* Float.POSITIVE_INFINITY -> Download completed
|
|
||||||
* Float.NaN -> Exception
|
|
||||||
*/
|
|
||||||
val progress = SparseArray<MutableList<Float>?>()
|
|
||||||
/*
|
|
||||||
* KEY
|
|
||||||
* primary galleryID
|
|
||||||
* secondary index
|
|
||||||
* PRIMARY VALUE
|
|
||||||
* MutableList -> Download in progress / Loading
|
|
||||||
* null -> Gallery doesn't exist
|
|
||||||
* SECONDARY VALUE
|
|
||||||
* Throwable -> Exception
|
|
||||||
* null -> Download in progress / Loading
|
|
||||||
*/
|
|
||||||
val exception = SparseArray<MutableList<Throwable?>?>()
|
|
||||||
val notification = SparseArray<NotificationCompat.Builder>()
|
|
||||||
|
|
||||||
private val loop = loop()
|
|
||||||
private val worker = SparseArray<Job?>()
|
|
||||||
@Volatile var nRunners = 0
|
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
|
||||||
.addInterceptor { chain ->
|
|
||||||
val request = chain.request()
|
|
||||||
var response = chain.proceed(request)
|
|
||||||
|
|
||||||
var retry = preferences.getInt("retry", 3)
|
|
||||||
while (!response.isSuccessful && retry > 0) {
|
|
||||||
response = chain.proceed(request)
|
|
||||||
retry--
|
|
||||||
}
|
|
||||||
|
|
||||||
response.newBuilder()
|
|
||||||
.body(ProgressResponseBody(request.tag(), response.body(), progressListener))
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
.dispatcher(Dispatcher(Executors.newFixedThreadPool(4)))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
queue.clear()
|
|
||||||
|
|
||||||
loop.cancel()
|
|
||||||
for (i in 0..worker.size()) {
|
|
||||||
val galleryID = worker.keyAt(i)
|
|
||||||
|
|
||||||
Cache(this@DownloadWorker).setDownloading(galleryID, false)
|
|
||||||
worker[galleryID]?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
client.dispatcher().cancelAll()
|
|
||||||
|
|
||||||
progress.clear()
|
|
||||||
exception.clear()
|
|
||||||
notification.clear()
|
|
||||||
notificationManager.cancelAll()
|
|
||||||
|
|
||||||
nRunners = 0
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel(galleryID: Int) {
|
|
||||||
queue.remove(galleryID)
|
|
||||||
worker[galleryID]?.cancel()
|
|
||||||
|
|
||||||
client.dispatcher().queuedCalls()
|
|
||||||
.filter {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
(it.request().tag() as? Pair<Int, Int>)?.first == galleryID
|
|
||||||
}
|
|
||||||
.forEach {
|
|
||||||
it.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.remove(galleryID)
|
|
||||||
exception.remove(galleryID)
|
|
||||||
notification.remove(galleryID)
|
|
||||||
notificationManager.cancel(galleryID)
|
|
||||||
|
|
||||||
if (progress.indexOfKey(galleryID) >= 0) {
|
|
||||||
Cache(this@DownloadWorker).setDownloading(galleryID, false)
|
|
||||||
nRunners--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isCompleted(galleryID: Int) = progress[galleryID]?.all { !it.isFinite() } == true
|
|
||||||
|
|
||||||
private fun queueDownload(galleryID: Int, reader: Reader, index: Int, callback: Callback) {
|
|
||||||
val cache = Cache(this@DownloadWorker).getImages(galleryID)
|
|
||||||
val lowQuality = preferences.getBoolean("low_quality", false)
|
|
||||||
|
|
||||||
//Cache exists :P
|
|
||||||
cache?.get(index)?.let {
|
|
||||||
progress[galleryID]?.set(index, Float.POSITIVE_INFINITY)
|
|
||||||
|
|
||||||
notify(galleryID)
|
|
||||||
|
|
||||||
if (isCompleted(galleryID)) {
|
|
||||||
with(Cache(this@DownloadWorker)) {
|
|
||||||
if (isDownloading(galleryID)) {
|
|
||||||
moveToDownload(galleryID)
|
|
||||||
setDownloading(galleryID, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nRunners--
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val request = Request.Builder().apply {
|
|
||||||
when (reader.code) {
|
|
||||||
Code.HITOMI -> {
|
|
||||||
url(
|
|
||||||
urlFromUrlFromHash(
|
|
||||||
galleryID,
|
|
||||||
reader.galleryInfo[index],
|
|
||||||
if (lowQuality) "webp" else null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
addHeader("Referer", getReferer(galleryID))
|
|
||||||
}
|
|
||||||
Code.HIYOBI -> {
|
|
||||||
url(createImgList(galleryID, reader, lowQuality)[index].path)
|
|
||||||
addHeader("User-Agent", user_agent)
|
|
||||||
addHeader("Cookie", cookie)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
//shouldn't be called anyway
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tag(galleryID to index)
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
client.newCall(request).enqueue(callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun download(galleryID: Int) = CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val reader = Cache(this@DownloadWorker).getReader(galleryID)
|
|
||||||
|
|
||||||
//gallery doesn't exist
|
|
||||||
if (reader == null) {
|
|
||||||
progress.put(galleryID, null)
|
|
||||||
exception.put(galleryID, null)
|
|
||||||
|
|
||||||
Cache(this@DownloadWorker).setDownloading(galleryID, false)
|
|
||||||
nRunners--
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.put(galleryID, reader.galleryInfo.map { 0F }.toMutableList())
|
|
||||||
exception.put(galleryID, reader.galleryInfo.map { null }.toMutableList())
|
|
||||||
|
|
||||||
if (notification[galleryID] == null)
|
|
||||||
initNotification(galleryID)
|
|
||||||
|
|
||||||
notification[galleryID].setContentTitle(reader.title)
|
|
||||||
notify(galleryID)
|
|
||||||
|
|
||||||
for (i in reader.galleryInfo.indices) {
|
|
||||||
val callback = object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
if (Fabric.isInitialized())
|
|
||||||
Crashlytics.logException(e)
|
|
||||||
|
|
||||||
progress[galleryID]?.set(i, Float.NaN)
|
|
||||||
exception[galleryID]?.set(i, e)
|
|
||||||
|
|
||||||
notify(galleryID)
|
|
||||||
|
|
||||||
if (isCompleted(galleryID)) {
|
|
||||||
val cache = Cache(this@DownloadWorker)
|
|
||||||
if (cache.isDownloading(galleryID)) {
|
|
||||||
cache.moveToDownload(galleryID)
|
|
||||||
cache.setDownloading(galleryID, false)
|
|
||||||
}
|
|
||||||
nRunners--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
response.body().use {
|
|
||||||
val res = it.bytes()
|
|
||||||
val ext =
|
|
||||||
call.request().url().encodedPath().split('.').last()
|
|
||||||
|
|
||||||
Cache(this@DownloadWorker).putImage(galleryID, "%05d.%s".format(i, ext), res)
|
|
||||||
progress[galleryID]?.set(i, Float.POSITIVE_INFINITY)
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(galleryID)
|
|
||||||
|
|
||||||
if (isCompleted(galleryID)) {
|
|
||||||
val cache = Cache(this@DownloadWorker)
|
|
||||||
if (cache.isDownloading(galleryID)) {
|
|
||||||
cache.moveToDownload(galleryID)
|
|
||||||
cache.setDownloading(galleryID, false)
|
|
||||||
}
|
|
||||||
nRunners--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
queueDownload(galleryID, reader, i, callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun notify(galleryID: Int) {
|
|
||||||
val max = progress[galleryID]?.size ?: 0
|
|
||||||
val progress = progress[galleryID]?.count { !it.isFinite() } ?: 0
|
|
||||||
|
|
||||||
if (isCompleted(galleryID))
|
|
||||||
notification[galleryID]
|
|
||||||
?.setContentText(getString(R.string.reader_notification_complete))
|
|
||||||
?.setProgress(0, 0, false)
|
|
||||||
else
|
|
||||||
notification[galleryID]
|
|
||||||
?.setProgress(max, progress, false)
|
|
||||||
?.setContentText("$progress/$max")
|
|
||||||
|
|
||||||
if (Cache(this).isDownloading(galleryID) && notification[galleryID] != null)
|
|
||||||
notificationManager.notify(galleryID, notification[galleryID].build())
|
|
||||||
else
|
|
||||||
notificationManager.cancel(galleryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initNotification(galleryID: Int) {
|
|
||||||
val intent = Intent(this, ReaderActivity::class.java).apply {
|
|
||||||
putExtra("galleryID", galleryID)
|
|
||||||
}
|
|
||||||
val pendingIntent = TaskStackBuilder.create(this).run {
|
|
||||||
addNextIntentWithParentStack(intent)
|
|
||||||
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
notification.put(galleryID, NotificationCompat.Builder(this, "download").apply {
|
|
||||||
setContentTitle(getString(R.string.reader_loading))
|
|
||||||
setContentText(getString(R.string.reader_notification_text))
|
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download) // had to use this because old android doesn't support VectorDrawable on Notification :P
|
|
||||||
setContentIntent(pendingIntent)
|
|
||||||
setProgress(0, 0, true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loop() = CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
while (true) {
|
|
||||||
if (queue.isEmpty() || nRunners > preferences.getInt("max_download", 4))
|
|
||||||
continue
|
|
||||||
|
|
||||||
val galleryID = queue.poll() ?: continue
|
|
||||||
|
|
||||||
if (progress.indexOfKey(galleryID) >= 0) // Gallery already downloading!
|
|
||||||
continue
|
|
||||||
|
|
||||||
initNotification(galleryID)
|
|
||||||
if (Cache(this@DownloadWorker).isDownloading(galleryID))
|
|
||||||
notificationManager.notify(galleryID, notification[galleryID].build())
|
|
||||||
worker.put(galleryID, download(galleryID))
|
|
||||||
nRunners++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pupil, Hitomi.la viewer for Android
|
|
||||||
* Copyright (C) 2020 tom5079
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.util.download
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import xyz.quaver.hitomi.GalleryBlock
|
|
||||||
import xyz.quaver.hitomi.Reader
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Metadata(
|
|
||||||
val thumbnail: String? = null,
|
|
||||||
val galleryBlock: GalleryBlock? = null,
|
|
||||||
val reader: Reader? = null,
|
|
||||||
val isDownloading: Boolean? = null
|
|
||||||
) {
|
|
||||||
constructor(
|
|
||||||
metadata: Metadata?,
|
|
||||||
thumbnail: String? = null,
|
|
||||||
galleryBlock: GalleryBlock? = null,
|
|
||||||
readers: Reader? = null,
|
|
||||||
isDownloading: Boolean? = null
|
|
||||||
) : this(
|
|
||||||
thumbnail ?: metadata?.thumbnail,
|
|
||||||
galleryBlock ?: metadata?.galleryBlock,
|
|
||||||
readers ?: metadata?.reader,
|
|
||||||
isDownloading ?: metadata?.isDownloading
|
|
||||||
)
|
|
||||||
}
|
|
||||||
297
app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt
Normal file
297
app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.util.downloader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import android.net.Uri
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Request
|
||||||
|
import xyz.quaver.io.FileX
|
||||||
|
import xyz.quaver.io.util.*
|
||||||
|
import xyz.quaver.pupil.client
|
||||||
|
import xyz.quaver.pupil.hitomi.*
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OldReader(
|
||||||
|
val code: String,
|
||||||
|
val galleryInfo: OldGalleryInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OldGalleryInfo(
|
||||||
|
val language_localname: String? = null,
|
||||||
|
val language: String? = null,
|
||||||
|
val date: String? = null,
|
||||||
|
val files: List<OldGalleryFiles>,
|
||||||
|
val id: Int? = null,
|
||||||
|
val type: String? = null,
|
||||||
|
val title: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OldGalleryFiles(
|
||||||
|
val width: Int,
|
||||||
|
val hash: String,
|
||||||
|
val haswebp: Int = 0,
|
||||||
|
val name: String,
|
||||||
|
val height: Int,
|
||||||
|
val hasavif: Int = 0,
|
||||||
|
val hasavifsmalltn: Int? = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OldMetadata(
|
||||||
|
var galleryBlock: GalleryBlock? = null,
|
||||||
|
var reader: OldReader? = null,
|
||||||
|
var imageList: MutableList<String?>? = null
|
||||||
|
) {
|
||||||
|
fun copy(): OldMetadata = OldMetadata(galleryBlock, reader, imageList?.let { MutableList(it.size) { i -> it[i] } })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Metadata(
|
||||||
|
var galleryBlock: GalleryBlock? = null,
|
||||||
|
var galleryInfo: GalleryInfo? = null,
|
||||||
|
var imageList: MutableList<String?>? = null
|
||||||
|
) {
|
||||||
|
constructor(old: OldMetadata) : this(
|
||||||
|
old.galleryBlock,
|
||||||
|
old.reader?.galleryInfo?.let { oldGalleryInfo ->
|
||||||
|
GalleryInfo(
|
||||||
|
oldGalleryInfo.id.toString(),
|
||||||
|
oldGalleryInfo.title ?: "",
|
||||||
|
null,
|
||||||
|
oldGalleryInfo.language,
|
||||||
|
oldGalleryInfo.type ?: "",
|
||||||
|
oldGalleryInfo.date ?: "",
|
||||||
|
files = oldGalleryInfo.files.map {
|
||||||
|
GalleryFiles(
|
||||||
|
it.width,
|
||||||
|
it.hash,
|
||||||
|
it.haswebp,
|
||||||
|
it.name,
|
||||||
|
it.height,
|
||||||
|
it.hasavif,
|
||||||
|
it.hasavifsmalltn
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
old.imageList
|
||||||
|
)
|
||||||
|
|
||||||
|
fun copy(): Metadata = Metadata(galleryBlock, galleryInfo, imageList?.let { MutableList(it.size) { i -> it[i] } })
|
||||||
|
}
|
||||||
|
|
||||||
|
class Cache private constructor(context: Context, val galleryID: Int) : ContextWrapper(context) {
|
||||||
|
companion object {
|
||||||
|
val instances = ConcurrentHashMap<Int, Cache>()
|
||||||
|
|
||||||
|
fun getInstance(context: Context, galleryID: Int) =
|
||||||
|
instances[galleryID] ?: synchronized(this) {
|
||||||
|
instances[galleryID] ?: Cache(context, galleryID).also { instances.put(galleryID, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun delete(context: Context, galleryID: Int) {
|
||||||
|
File(context.cacheDir, "imageCache/$galleryID").deleteRecursively()
|
||||||
|
instances.remove(galleryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
cacheFolder.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = kotlin.runCatching {
|
||||||
|
findFile(".metadata")?.readText()?.let { metadata ->
|
||||||
|
kotlin.runCatching {
|
||||||
|
Json.decodeFromString<Metadata>(metadata)
|
||||||
|
}.getOrElse {
|
||||||
|
Metadata(json.decodeFromString<OldMetadata>(metadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { it.printStackTrace() }.getOrNull() ?: Metadata()
|
||||||
|
|
||||||
|
val downloadFolder: FileX?
|
||||||
|
get() = DownloadManager.getInstance(this).getDownloadFolder(galleryID)
|
||||||
|
|
||||||
|
val cacheFolder: FileX
|
||||||
|
get() = FileX(this, cacheDir, "imageCache/$galleryID").also {
|
||||||
|
if (!it.exists())
|
||||||
|
it.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findFile(fileName: String): FileX? =
|
||||||
|
downloadFolder?.let { downloadFolder -> downloadFolder.getChild(fileName).let {
|
||||||
|
if (it.exists()) it else null
|
||||||
|
} } ?: cacheFolder.getChild(fileName).let {
|
||||||
|
if (it.exists()) it else null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
fun setMetadata(change: (Metadata) -> Unit) {
|
||||||
|
change.invoke(metadata)
|
||||||
|
|
||||||
|
val file = cacheFolder.getChild(".metadata")
|
||||||
|
|
||||||
|
kotlin.runCatching {
|
||||||
|
if (!file.exists()) {
|
||||||
|
file.createNewFile()
|
||||||
|
}
|
||||||
|
file.writeText(Json.encodeToString(metadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getGalleryBlock(): GalleryBlock? {
|
||||||
|
return metadata.galleryBlock
|
||||||
|
?: withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
getGalleryBlock(galleryID).also {
|
||||||
|
setMetadata { metadata -> metadata.galleryBlock = it }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { return@withContext null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
suspend fun getThumbnail(): Uri =
|
||||||
|
findFile(".thumbnail")?.uri
|
||||||
|
?: getGalleryBlock()?.thumbnails?.firstOrNull()?.let { withContext(Dispatchers.IO) {
|
||||||
|
kotlin.runCatching {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(it)
|
||||||
|
.header("Referer", "https://hitomi.la/")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
client.newCall(request).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.bytes() }
|
||||||
|
}.getOrNull()?.let { thumbnail -> kotlin.runCatching {
|
||||||
|
cacheFolder.getChild(".thumbnail").also {
|
||||||
|
if (!it.exists())
|
||||||
|
it.createNewFile()
|
||||||
|
|
||||||
|
it.writeBytes(thumbnail)
|
||||||
|
}
|
||||||
|
}.getOrNull()?.uri }
|
||||||
|
} } ?: Uri.EMPTY
|
||||||
|
|
||||||
|
suspend fun getGalleryInfo(): GalleryInfo? {
|
||||||
|
|
||||||
|
return metadata.galleryInfo
|
||||||
|
?: withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
getGalleryInfo(galleryID).also {
|
||||||
|
setMetadata { metadata ->
|
||||||
|
metadata.galleryInfo = it
|
||||||
|
|
||||||
|
if (metadata.imageList == null)
|
||||||
|
metadata.imageList = MutableList(it.files.size) { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getImage(index: Int): FileX? =
|
||||||
|
metadata.imageList?.getOrNull(index)?.let { findFile(it) }
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
suspend fun putImage(index: Int, fileName: String, data: ByteArray) = coroutineScope {
|
||||||
|
val file = cacheFolder.getChild(fileName)
|
||||||
|
|
||||||
|
if (!file.exists())
|
||||||
|
file.createNewFile()
|
||||||
|
|
||||||
|
file.writeBytes(data)
|
||||||
|
setMetadata { metadata -> metadata.imageList!![index] = fileName }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val lock = ConcurrentHashMap<Int, Mutex>()
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
fun moveToDownload() = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val downloadFolder = downloadFolder ?: return@launch
|
||||||
|
|
||||||
|
if (lock[galleryID]?.isLocked == true)
|
||||||
|
return@launch
|
||||||
|
|
||||||
|
(lock[galleryID] ?: Mutex().also { lock[galleryID] = it }).withLock {
|
||||||
|
val cacheMetadata = cacheFolder.getChild(".metadata")
|
||||||
|
val downloadMetadata = downloadFolder.getChild(".metadata")
|
||||||
|
|
||||||
|
if (!cacheMetadata.exists())
|
||||||
|
return@launch
|
||||||
|
|
||||||
|
if (cacheMetadata.exists()) {
|
||||||
|
kotlin.runCatching {
|
||||||
|
if (!downloadMetadata.exists())
|
||||||
|
downloadMetadata.createNewFile()
|
||||||
|
|
||||||
|
downloadMetadata.writeText(Json.encodeToString(metadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cacheThumbnail = cacheFolder.getChild(".thumbnail")
|
||||||
|
val downloadThumbnail = downloadFolder.getChild(".thumbnail")
|
||||||
|
|
||||||
|
if (cacheThumbnail.exists()) {
|
||||||
|
kotlin.runCatching {
|
||||||
|
if (!downloadThumbnail.exists())
|
||||||
|
downloadThumbnail.createNewFile()
|
||||||
|
|
||||||
|
downloadThumbnail.outputStream()?.use { target -> target.channel.truncate(0L); cacheThumbnail.inputStream()?.use { source ->
|
||||||
|
source.copyTo(target)
|
||||||
|
} }
|
||||||
|
cacheThumbnail.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.imageList?.forEach { imageName ->
|
||||||
|
imageName ?: return@forEach
|
||||||
|
val target = downloadFolder.getChild(imageName)
|
||||||
|
val source = cacheFolder.getChild(imageName)
|
||||||
|
|
||||||
|
if (!source.exists())
|
||||||
|
return@forEach
|
||||||
|
|
||||||
|
kotlin.runCatching {
|
||||||
|
if (!target.exists())
|
||||||
|
target.createNewFile()
|
||||||
|
|
||||||
|
target.outputStream()?.use { target -> target.channel.truncate(0L); source.inputStream()?.use { source ->
|
||||||
|
source.copyTo(target)
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheFolder.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.util.downloader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Call
|
||||||
|
import xyz.quaver.io.FileX
|
||||||
|
import xyz.quaver.io.util.deleteRecursively
|
||||||
|
import xyz.quaver.io.util.getChild
|
||||||
|
import xyz.quaver.io.util.readText
|
||||||
|
import xyz.quaver.io.util.writeText
|
||||||
|
import xyz.quaver.pupil.client
|
||||||
|
import xyz.quaver.pupil.services.DownloadService
|
||||||
|
import xyz.quaver.pupil.util.Preferences
|
||||||
|
import xyz.quaver.pupil.util.formatDownloadFolder
|
||||||
|
|
||||||
|
class DownloadManager private constructor(context: Context) : ContextWrapper(context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile private var instance: DownloadManager? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context) =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: DownloadManager(context).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!)
|
||||||
|
|
||||||
|
val downloadFolder: FileX
|
||||||
|
get() = kotlin.runCatching {
|
||||||
|
FileX(this, Preferences.get<String>("download_folder"))
|
||||||
|
}.getOrElse {
|
||||||
|
Preferences["download_folder"] = defaultDownloadFolder.uri.toString()
|
||||||
|
defaultDownloadFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
private var prevDownloadFolder: FileX? = null
|
||||||
|
private var downloadFolderMapInstance: MutableMap<Int, String>? = null
|
||||||
|
val downloadFolderMap: MutableMap<Int, String>
|
||||||
|
@Synchronized
|
||||||
|
get() {
|
||||||
|
if (prevDownloadFolder != downloadFolder) {
|
||||||
|
prevDownloadFolder = downloadFolder
|
||||||
|
downloadFolderMapInstance = run {
|
||||||
|
val file = downloadFolder.getChild(".download")
|
||||||
|
val data = if (file.exists())
|
||||||
|
kotlin.runCatching {
|
||||||
|
file.readText()?.let{ Json.decodeFromString<MutableMap<Int, String>>(it) }
|
||||||
|
}.onFailure { file.delete() }.getOrNull()
|
||||||
|
else
|
||||||
|
null
|
||||||
|
data ?: run {
|
||||||
|
file.createNewFile()
|
||||||
|
mutableMapOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadFolderMapInstance ?: mutableMapOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun isDownloading(galleryID: Int): Boolean {
|
||||||
|
val isThisGallery: (Call) -> Boolean = { !it.isCanceled && (it.request().tag() as? DownloadService.Tag)?.galleryID == galleryID }
|
||||||
|
|
||||||
|
return downloadFolderMap.containsKey(galleryID)
|
||||||
|
&& client.dispatcher().let { it.queuedCalls().any(isThisGallery) || it.runningCalls().any(isThisGallery) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getDownloadFolder(galleryID: Int): FileX? =
|
||||||
|
downloadFolderMap[galleryID]?.let { downloadFolder.getChild(it) }
|
||||||
|
|
||||||
|
fun addDownloadFolder(galleryID: Int) = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val name = Cache.getInstance(this@DownloadManager, galleryID).getGalleryBlock()
|
||||||
|
?.formatDownloadFolder() ?: return@launch
|
||||||
|
|
||||||
|
val folder = downloadFolder.getChild(name)
|
||||||
|
|
||||||
|
downloadFolderMap[galleryID] = name
|
||||||
|
|
||||||
|
downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() }
|
||||||
|
downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap))
|
||||||
|
|
||||||
|
if (folder.exists()) return@launch
|
||||||
|
folder.mkdir()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun deleteDownloadFolder(galleryID: Int) {
|
||||||
|
downloadFolderMap[galleryID]?.let {
|
||||||
|
kotlin.runCatching {
|
||||||
|
downloadFolder.getChild(it).deleteRecursively()
|
||||||
|
downloadFolderMap.remove(galleryID)
|
||||||
|
|
||||||
|
downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() }
|
||||||
|
downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,133 +19,49 @@
|
|||||||
package xyz.quaver.pupil.util
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import androidx.core.content.FileProvider
|
import kotlinx.coroutines.Dispatchers
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import kotlinx.coroutines.launch
|
||||||
import androidx.preference.PreferenceManager
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import xyz.quaver.pupil.histories
|
||||||
|
import xyz.quaver.pupil.util.downloader.Cache
|
||||||
|
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URL
|
|
||||||
import java.nio.charset.Charset
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
fun getCachedGallery(context: Context, galleryID: Int) =
|
val mutex = Mutex()
|
||||||
getDownloadDirectory(context).findFile(galleryID.toString()) ?:
|
fun cleanCache(context: Context) = CoroutineScope(Dispatchers.IO).launch {
|
||||||
DocumentFile.fromFile(File(context.cacheDir, "imageCache/$galleryID"))
|
if (mutex.isLocked) return@launch
|
||||||
|
|
||||||
fun getDownloadDirectory(context: Context) : DocumentFile {
|
mutex.withLock {
|
||||||
val uri = PreferenceManager.getDefaultSharedPreferences(context).getString("dl_location", null).let {
|
val cacheFolder = File(context.cacheDir, "imageCache")
|
||||||
if (it != null)
|
val downloadManager = DownloadManager.getInstance(context)
|
||||||
Uri.parse(it)
|
|
||||||
else
|
val limit = (Preferences.get<String>("cache_limit").toLongOrNull() ?: 0L)*1024*1024*1024
|
||||||
Uri.fromFile(context.getExternalFilesDir(null))
|
|
||||||
|
if (limit == 0L) return@withLock
|
||||||
|
|
||||||
|
val cacheSize = {
|
||||||
|
var size = 0L
|
||||||
|
|
||||||
|
cacheFolder.walk().forEach {
|
||||||
|
size += it.length()
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (uri.toString().startsWith("file"))
|
size
|
||||||
DocumentFile.fromFile(File(uri.path!!))
|
|
||||||
else
|
|
||||||
DocumentFile.fromTreeUri(context, uri) ?: DocumentFile.fromFile(context.getExternalFilesDir(null)!!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun convertUpdateUri(context: Context, uri: Uri) : Uri =
|
if (cacheSize.invoke() > limit)
|
||||||
if (uri.toString().startsWith("file"))
|
while (cacheSize.invoke() > limit/2) {
|
||||||
FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!.substringAfter("file:///")))
|
val caches = cacheFolder.list() ?: return@withLock
|
||||||
else
|
|
||||||
uri
|
|
||||||
|
|
||||||
fun URL.download(context: Context, to: DocumentFile, onDownloadProgress: ((Long, Long) -> Unit)? = null) {
|
synchronized(histories) {
|
||||||
context.contentResolver.openOutputStream(to.uri).use { out ->
|
(histories.firstOrNull {
|
||||||
out!!
|
caches.contains(it.toString()) && !downloadManager.isDownloading(it)
|
||||||
|
} ?: return@withLock).let {
|
||||||
with(openConnection()) {
|
Cache.delete(context, it)
|
||||||
val fileSize = contentLength.toLong()
|
|
||||||
|
|
||||||
getInputStream().use {
|
|
||||||
|
|
||||||
var bytesCopied: Long = 0
|
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
||||||
|
|
||||||
var bytes = it.read(buffer)
|
|
||||||
while (bytes >= 0) {
|
|
||||||
out.write(buffer, 0, bytes)
|
|
||||||
bytesCopied += bytes
|
|
||||||
onDownloadProgress?.invoke(bytesCopied, fileSize)
|
|
||||||
bytes = it.read(buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun DocumentFile.isParentOf(file: DocumentFile?) : Boolean {
|
|
||||||
var parent = file?.parentFile
|
|
||||||
while (parent != null) {
|
|
||||||
if (this.uri.path == parent.uri.path)
|
|
||||||
return true
|
|
||||||
|
|
||||||
parent = parent.parentFile
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun DocumentFile.reader(context: Context, charset: Charset = Charsets.UTF_8) = context.contentResolver.openInputStream(uri)!!.reader(charset)
|
|
||||||
fun DocumentFile.readBytes(context: Context) = context.contentResolver.openInputStream(uri)!!.readBytes()
|
|
||||||
fun DocumentFile.readText(context: Context, charset: Charset = Charsets.UTF_8) = reader(context, charset).use { it.readText() }
|
|
||||||
|
|
||||||
fun DocumentFile.writeBytes(context: Context, array: ByteArray) = context.contentResolver.openOutputStream(uri)!!.write(array)
|
|
||||||
fun DocumentFile.writeText(context: Context, text: String, charset: Charset = Charsets.UTF_8) = writeBytes(context, text.toByteArray(charset))
|
|
||||||
|
|
||||||
fun DocumentFile.copyRecursively(
|
|
||||||
context: Context,
|
|
||||||
target: DocumentFile
|
|
||||||
) {
|
|
||||||
if (!exists())
|
|
||||||
throw Exception("The source file doesn't exist.")
|
|
||||||
|
|
||||||
if (this.isFile) {
|
|
||||||
target.let {
|
|
||||||
if (it.findFile(name!!) != null)
|
|
||||||
it
|
|
||||||
else
|
|
||||||
createFile("null", name!!)!!
|
|
||||||
}.writeBytes(
|
|
||||||
context,
|
|
||||||
readBytes(context)
|
|
||||||
)
|
|
||||||
} else if (this.isDirectory) {
|
|
||||||
target.createDirectory(name!!).also { newTarget ->
|
|
||||||
listFiles().forEach { child ->
|
|
||||||
child.copyRecursively(context, newTarget!!)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun DocumentFile.deleteRecursively() {
|
|
||||||
|
|
||||||
if (this.isDirectory)
|
|
||||||
listFiles().forEach {
|
|
||||||
it.deleteRecursively()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun DocumentFile.walk(state: LinkedList<DocumentFile> = LinkedList()) : Queue<DocumentFile> {
|
|
||||||
if (state.isEmpty())
|
|
||||||
state.push(this)
|
|
||||||
|
|
||||||
listFiles().forEach {
|
|
||||||
state.push(it)
|
|
||||||
|
|
||||||
if (it.isDirectory) {
|
|
||||||
it.walk(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
fun File.copyTo(context: Context, target: DocumentFile) = target.writeBytes(context, this.readBytes())
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pupil, Hitomi.la viewer for Android
|
|
||||||
* Copyright (C) 2019 tom5079
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.util
|
|
||||||
|
|
||||||
import kotlinx.serialization.ImplicitReflectionSerializer
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonConfiguration
|
|
||||||
import kotlinx.serialization.parseList
|
|
||||||
import kotlinx.serialization.stringify
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class Histories(private val file: File) : ArrayList<Int>() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (!file.exists())
|
|
||||||
file.parentFile?.mkdirs()
|
|
||||||
|
|
||||||
try {
|
|
||||||
load()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
fun load() : Histories {
|
|
||||||
return apply {
|
|
||||||
super.clear()
|
|
||||||
addAll(
|
|
||||||
Json(JsonConfiguration.Stable).parseList(
|
|
||||||
file.bufferedReader().use { it.readText() }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
fun save() {
|
|
||||||
file.writeText(Json(JsonConfiguration.Stable).stringify(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun add(element: Int): Boolean {
|
|
||||||
load()
|
|
||||||
|
|
||||||
if (contains(element))
|
|
||||||
super.remove(element)
|
|
||||||
|
|
||||||
super.add(0, element)
|
|
||||||
|
|
||||||
save()
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun remove(element: Int): Boolean {
|
|
||||||
load()
|
|
||||||
val retval = super.remove(element)
|
|
||||||
save()
|
|
||||||
|
|
||||||
return retval
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun clear() {
|
|
||||||
super.clear()
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,9 +21,10 @@ package xyz.quaver.pupil.util
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import kotlinx.serialization.*
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonConfiguration
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ fun hashWithSalt(password: String): Pair<String, String> {
|
|||||||
return Pair(hash(password+salt), salt)
|
return Pair(hash(password+salt), salt)
|
||||||
}
|
}
|
||||||
|
|
||||||
val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
const val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Lock(val type: Type, val hash: String, val salt: String) {
|
data class Lock(val type: Type, val hash: String, val salt: String) {
|
||||||
@@ -73,7 +74,6 @@ class LockManager(base: Context): ContextWrapper(base) {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
private fun load() {
|
private fun load() {
|
||||||
val lock = File(ContextCompat.getDataDir(this), "lock.json")
|
val lock = File(ContextCompat.getDataDir(this), "lock.json")
|
||||||
|
|
||||||
@@ -82,17 +82,16 @@ class LockManager(base: Context): ContextWrapper(base) {
|
|||||||
lock.writeText("[]")
|
lock.writeText("[]")
|
||||||
}
|
}
|
||||||
|
|
||||||
locks = ArrayList(Json(JsonConfiguration.Stable).parseList(lock.readText()))
|
locks = Json.decodeFromString(lock.readText())
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
private fun save() {
|
private fun save() {
|
||||||
val lock = File(ContextCompat.getDataDir(this), "lock.json")
|
val lock = File(ContextCompat.getDataDir(this), "lock.json")
|
||||||
|
|
||||||
if (!lock.exists())
|
if (!lock.exists())
|
||||||
lock.createNewFile()
|
lock.createNewFile()
|
||||||
|
|
||||||
lock.writeText(Json(JsonConfiguration.Stable).stringify(locks?.toList() ?: listOf()))
|
lock.writeText(Json.encodeToString(locks?.toList() ?: listOf()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun add(lock: Lock) {
|
fun add(lock: Lock) {
|
||||||
|
|||||||
@@ -18,30 +18,48 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.util
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.hitomi.GalleryBlock
|
||||||
|
import xyz.quaver.pupil.hitomi.GalleryInfo
|
||||||
|
import xyz.quaver.pupil.hitomi.imageUrlFromImage
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.collections.ArrayList
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
@UseExperimental(ExperimentalStdlibApi::class)
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
fun String.wordCapitalize() : String {
|
fun String.wordCapitalize() : String {
|
||||||
val result = ArrayList<String>()
|
val result = ArrayList<String>()
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
for (word in this.split(" "))
|
for (word in this.split(" "))
|
||||||
result.add(word.capitalize(Locale.US))
|
result.add(word.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() })
|
||||||
|
|
||||||
return result.joinToString(" ")
|
return result.joinToString(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun byteToString(byte: Long, precision : Int = 1) : String {
|
private val suffix = listOf(
|
||||||
|
|
||||||
val suffix = listOf(
|
|
||||||
"B",
|
"B",
|
||||||
"kB",
|
"kB",
|
||||||
"MB",
|
"MB",
|
||||||
"GB",
|
"GB",
|
||||||
"TB" //really?
|
"TB" //really?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun byteToString(byte: Long, precision : Int = 1) : String {
|
||||||
var size = byte.toDouble(); var suffixIndex = 0
|
var size = byte.toDouble(); var suffixIndex = 0
|
||||||
|
|
||||||
while (size >= 1024) {
|
while (size >= 1024) {
|
||||||
@@ -50,5 +68,127 @@ fun byteToString(byte: Long, precision : Int = 1) : String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return "%.${precision}f ${suffix[suffixIndex]}".format(size)
|
return "%.${precision}f ${suffix[suffixIndex]}".format(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert android generated ID to requestCode
|
||||||
|
* to prevent java.lang.IllegalArgumentException: Can only use lower 16 bits for requestCode
|
||||||
|
*
|
||||||
|
* https://stackoverflow.com/questions/38072322/generate-16-bit-unique-ids-in-android-for-startactivityforresult
|
||||||
|
*/
|
||||||
|
fun Int.normalizeID() = this.and(0xFFFF)
|
||||||
|
|
||||||
|
fun OkHttpClient.Builder.proxyInfo(proxyInfo: ProxyInfo) = this.apply {
|
||||||
|
proxy(proxyInfo.proxy())
|
||||||
|
proxyInfo.authenticator()?.let {
|
||||||
|
proxyAuthenticator(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val formatMap = mapOf<String, GalleryBlock.() -> (String)>(
|
||||||
|
"-id-" to { id.toString() },
|
||||||
|
"-title-" to { title },
|
||||||
|
"-artist-" to { if (artists.isNotEmpty()) artists.joinToString() else "N/A" },
|
||||||
|
"-group-" to { if (groups.isNotEmpty()) groups.joinToString() else "N/A" }
|
||||||
|
// TODO
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* Formats download folder name with given Metadata
|
||||||
|
*/
|
||||||
|
fun GalleryBlock.formatDownloadFolder(): String =
|
||||||
|
Preferences["download_folder_name", "[-id-] -title-"].let {
|
||||||
|
formatMap.entries.fold(it) { str, (k, v) ->
|
||||||
|
str.replace(k, v.invoke(this), true)
|
||||||
|
}
|
||||||
|
}.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
|
||||||
|
|
||||||
|
fun GalleryBlock.formatDownloadFolderTest(format: String): String =
|
||||||
|
format.let {
|
||||||
|
formatMap.entries.fold(it) { str, (k, v) ->
|
||||||
|
str.replace(k, v.invoke(this), true)
|
||||||
|
}
|
||||||
|
}.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
|
||||||
|
|
||||||
|
suspend fun GalleryInfo.getRequestBuilders(): List<Request.Builder> {
|
||||||
|
val galleryID = this.id.toIntOrNull() ?: 0
|
||||||
|
return this.files.map {
|
||||||
|
Request.Builder()
|
||||||
|
.url(
|
||||||
|
runCatching {
|
||||||
|
imageUrlFromImage(galleryID, it, false)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
FirebaseCrashlytics.getInstance().recordException(it)
|
||||||
|
}
|
||||||
|
.getOrDefault("https://a/")
|
||||||
|
)
|
||||||
|
.header("Referer", "https://hitomi.la/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun byteCount(codePoint: Int): Int = when (codePoint) {
|
||||||
|
in 0 ..< 0x80 -> 1
|
||||||
|
in 0x80 ..< 0x800 -> 2
|
||||||
|
in 0x800 ..< 0x10000 -> 3
|
||||||
|
in 0x10000 ..< 0x110000 -> 4
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.ellipsize(n: Int): String = buildString {
|
||||||
|
var count = 0
|
||||||
|
var index = 0
|
||||||
|
val codePointLength = this@ellipsize.codePointCount(0, this@ellipsize.length)
|
||||||
|
|
||||||
|
while (index < codePointLength) {
|
||||||
|
val nextCodePoint = this@ellipsize.codePointAt(index)
|
||||||
|
val nextByte = byteCount(nextCodePoint)
|
||||||
|
if (count + nextByte > 124) {
|
||||||
|
append("…")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
appendCodePoint(nextCodePoint)
|
||||||
|
count += nextByte
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
operator fun JsonElement.get(index: Int) =
|
||||||
|
this.jsonArray[index]
|
||||||
|
|
||||||
|
operator fun JsonElement.get(tag: String) =
|
||||||
|
this.jsonObject[tag]
|
||||||
|
|
||||||
|
fun JsonElement.getOrNull(tag: String) = kotlin.runCatching {
|
||||||
|
this.jsonObject.getOrDefault(tag, null)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
val JsonElement.content
|
||||||
|
get() = this.jsonPrimitive.contentOrNull
|
||||||
|
|
||||||
|
fun checkNotificationEnabled(context: Context) =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
fun showNotificationPermissionExplanationDialog(context: Context) {
|
||||||
|
AlertDialog.Builder(context)
|
||||||
|
.setTitle(R.string.warning)
|
||||||
|
.setMessage(R.string.notification_denied)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ -> }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestNotificationPermission(
|
||||||
|
activity: Activity,
|
||||||
|
requestPermissionLauncher: ActivityResultLauncher<String>,
|
||||||
|
showRationale: Boolean = true,
|
||||||
|
ifGranted: () -> Unit,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
checkNotificationEnabled(activity) -> ifGranted()
|
||||||
|
showRationale && ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.POST_NOTIFICATIONS) ->
|
||||||
|
showNotificationPermissionExplanationDialog(activity)
|
||||||
|
else ->
|
||||||
|
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/src/main/java/xyz/quaver/pupil/util/proxy.kt
Normal file
58
app/src/main/java/xyz/quaver/pupil/util/proxy.kt
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Authenticator
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Proxy
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProxyInfo(
|
||||||
|
val type: Proxy.Type,
|
||||||
|
val host: String? = null,
|
||||||
|
val port: Int? = null,
|
||||||
|
val username: String? = null,
|
||||||
|
val password: String? = null
|
||||||
|
) {
|
||||||
|
fun proxy() : Proxy {
|
||||||
|
return if (host.isNullOrBlank() || port == null)
|
||||||
|
return Proxy.NO_PROXY
|
||||||
|
else
|
||||||
|
Proxy(type, InetSocketAddress.createUnresolved(host, port))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authenticator(): Authenticator? = if (username.isNullOrBlank() || password.isNullOrBlank()) null else
|
||||||
|
Authenticator { _, response ->
|
||||||
|
val credential = Credentials.basic(username, password)
|
||||||
|
|
||||||
|
response.request().newBuilder()
|
||||||
|
.header("Proxy-Authorization", credential)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getProxyInfo(): ProxyInfo =
|
||||||
|
Json.decodeFromString(Preferences["proxy", Json.encodeToString(ProxyInfo(Proxy.Type.DIRECT))])
|
||||||
68
app/src/main/java/xyz/quaver/pupil/util/translation.kt
Normal file
68
app/src/main/java/xyz/quaver/pupil/util/translation.kt
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.Request
|
||||||
|
import xyz.quaver.pupil.client
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
private val filesURL = "https://api.github.com/repos/tom5079/Pupil/git/trees/tags"
|
||||||
|
private val contentURL = "https://raw.githubusercontent.com/tom5079/Pupil/tags/"
|
||||||
|
|
||||||
|
var translations: Map<String, String> = run {
|
||||||
|
updateTranslations()
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
private set
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
fun updateTranslations() = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
translations = emptyMap()
|
||||||
|
kotlin.runCatching {
|
||||||
|
translations = Json.decodeFromString<Map<String, String>>(client.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url(contentURL + "${Preferences["tag_translation", ""].let { if (it.isEmpty()) Locale.getDefault().language else it }}.json")
|
||||||
|
.build()
|
||||||
|
).execute().also { if (it.code() != 200) return@launch }.body()?.use { it.string() } ?: return@launch).filterValues { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAvailableLanguages(): List<String> {
|
||||||
|
val languages = Locale.getISOLanguages()
|
||||||
|
|
||||||
|
val json = Json.parseToJsonElement(client.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url(filesURL)
|
||||||
|
.build()
|
||||||
|
).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.string() } ?: return emptyList())
|
||||||
|
|
||||||
|
return listOf("en") + (json["tree"]?.jsonArray?.mapNotNull {
|
||||||
|
val name = it["path"]?.jsonPrimitive?.content?.takeWhile { c -> c != '.' }
|
||||||
|
|
||||||
|
languages.firstOrNull { code -> code.equals(name, ignoreCase = true) }
|
||||||
|
} ?: emptyList())
|
||||||
|
}
|
||||||
@@ -18,49 +18,48 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.util
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.DownloadManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.net.Uri
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.URLUtil
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
import ru.noties.markwon.Markwon
|
import ru.noties.markwon.Markwon
|
||||||
import xyz.quaver.pupil.BuildConfig
|
import xyz.quaver.pupil.*
|
||||||
import xyz.quaver.pupil.R
|
import xyz.quaver.pupil.types.Tag
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
fun getReleases(url: String) : JsonArray {
|
fun getReleases(url: String) : JsonArray {
|
||||||
return try {
|
return try {
|
||||||
URL(url).readText().let {
|
URL(url).readText().let {
|
||||||
Json(JsonConfiguration.Stable).parse(JsonArray.serializer(), it)
|
Json.parseToJsonElement(it).jsonArray
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
JsonArray(emptyList())
|
JsonArray(emptyList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkUpdate(context: Context, url: String) : JsonObject? {
|
fun checkUpdate(url: String) : JsonObject? {
|
||||||
val releases = getReleases(url)
|
val releases = getReleases(url)
|
||||||
|
|
||||||
if (releases.isEmpty())
|
if (releases.isEmpty())
|
||||||
return null
|
return null
|
||||||
|
|
||||||
return releases.firstOrNull {
|
return releases.firstOrNull {
|
||||||
if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean("beta", false))
|
Preferences["beta"] || it.jsonObject["prerelease"]?.jsonPrimitive?.booleanOrNull == false
|
||||||
true
|
|
||||||
else
|
|
||||||
it.jsonObject["prerelease"]?.boolean == false
|
|
||||||
}?.let {
|
}?.let {
|
||||||
if (it.jsonObject["tag_name"]?.content == BuildConfig.VERSION_NAME)
|
if (it.jsonObject["tag_name"]?.jsonPrimitive?.contentOrNull == BuildConfig.VERSION_NAME)
|
||||||
null
|
null
|
||||||
else
|
else
|
||||||
it.jsonObject
|
it.jsonObject
|
||||||
@@ -69,14 +68,13 @@ fun checkUpdate(context: Context, url: String) : JsonObject? {
|
|||||||
|
|
||||||
fun getApkUrl(releases: JsonObject) : String? {
|
fun getApkUrl(releases: JsonObject) : String? {
|
||||||
return releases["assets"]?.jsonArray?.firstOrNull {
|
return releases["assets"]?.jsonArray?.firstOrNull {
|
||||||
Regex("Pupil-v.+\\.apk").matches(it.jsonObject["name"]?.content ?: "")
|
Regex("Pupil-v.+\\.apk").matches(it.jsonObject["name"]?.jsonPrimitive?.contentOrNull ?: "")
|
||||||
}.let {
|
}.let {
|
||||||
it?.jsonObject?.get("browser_download_url")?.content
|
it?.jsonObject?.get("browser_download_url")?.jsonPrimitive?.contentOrNull
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const val UPDATE_NOTIFICATION_ID = 384823
|
fun checkUpdate(context: Context, force: Boolean = false) {
|
||||||
fun checkUpdate(context: AppCompatActivity, force: Boolean = false) {
|
|
||||||
|
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
val ignoreUpdateUntil = preferences.getLong("ignore_update_until", 0)
|
val ignoreUpdateUntil = preferences.getLong("ignore_update_until", 0)
|
||||||
@@ -85,7 +83,7 @@ fun checkUpdate(context: AppCompatActivity, force: Boolean = false) {
|
|||||||
return
|
return
|
||||||
|
|
||||||
fun extractReleaseNote(update: JsonObject, locale: Locale) : String {
|
fun extractReleaseNote(update: JsonObject, locale: Locale) : String {
|
||||||
val markdown = update["body"]!!.content
|
val markdown = update["body"]!!.jsonPrimitive.content
|
||||||
|
|
||||||
val target = when(locale.language) {
|
val target = when(locale.language) {
|
||||||
"ko" -> "한국어"
|
"ko" -> "한국어"
|
||||||
@@ -123,12 +121,12 @@ fun checkUpdate(context: AppCompatActivity, force: Boolean = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return context.getString(R.string.update_release_note, update["tag_name"]?.content, result.toString())
|
return context.getString(R.string.update_release_note, update["tag_name"]?.jsonPrimitive?.contentOrNull, result.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.Default).launch {
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
val update =
|
val update =
|
||||||
checkUpdate(context, context.getString(R.string.release_url)) ?: return@launch
|
checkUpdate(context.getString(R.string.release_url)) ?: return@launch
|
||||||
|
|
||||||
val url = getApkUrl(update) ?: return@launch
|
val url = getApkUrl(update) ?: return@launch
|
||||||
|
|
||||||
@@ -136,66 +134,37 @@ fun checkUpdate(context: AppCompatActivity, force: Boolean = false) {
|
|||||||
setTitle(R.string.update_title)
|
setTitle(R.string.update_title)
|
||||||
val msg = extractReleaseNote(update, Locale.getDefault())
|
val msg = extractReleaseNote(update, Locale.getDefault())
|
||||||
setMessage(Markwon.create(context).toMarkdown(msg))
|
setMessage(Markwon.create(context).toMarkdown(msg))
|
||||||
setPositiveButton(android.R.string.yes) { _, _ ->
|
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
if (!checkNotificationEnabled(context)) {
|
||||||
val notificationManager = NotificationManagerCompat.from(context)
|
showNotificationPermissionExplanationDialog(context)
|
||||||
val builder = NotificationCompat.Builder(context, "download").apply {
|
return@setPositiveButton
|
||||||
setContentTitle(context.getString(R.string.update_notification_description))
|
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download)
|
|
||||||
priority = NotificationCompat.PRIORITY_LOW
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch io@{
|
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
val target = getDownloadDirectory(context).let {
|
|
||||||
if (it.findFile("Pupil.apk") != null)
|
//Cancel any download queued before
|
||||||
it
|
|
||||||
else
|
val id: Long = Preferences["update_download_id"]
|
||||||
it.createFile("null", "Pupil.apk")!!
|
|
||||||
|
if (id != -1L)
|
||||||
|
downloadManager.remove(id)
|
||||||
|
|
||||||
|
val target = File(context.getExternalFilesDir(null), "Pupil.apk").also {
|
||||||
|
it.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
val request = DownloadManager.Request(Uri.parse(url))
|
||||||
URL(url).download(context, target) { progress, fileSize ->
|
.setTitle(context.getText(R.string.update_notification_description))
|
||||||
builder.setProgress(fileSize.toInt(), progress.toInt(), false)
|
.setDestinationUri(Uri.fromFile(target))
|
||||||
notificationManager.notify(UPDATE_NOTIFICATION_ID, builder.build())
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
builder.apply {
|
|
||||||
setContentText(context.getString(R.string.update_failed))
|
|
||||||
setMessage(context.getString(R.string.update_failed_message))
|
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationManager.cancel(UPDATE_NOTIFICATION_ID)
|
downloadManager.enqueue(request).also {
|
||||||
notificationManager.notify(UPDATE_NOTIFICATION_ID, builder.build())
|
Preferences["update_download_id"] = it
|
||||||
|
|
||||||
return@io
|
|
||||||
}
|
|
||||||
|
|
||||||
val install = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
|
||||||
setDataAndType(convertUpdateUri(context, target.uri), MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.apply {
|
|
||||||
setContentIntent(PendingIntent.getActivity(context, 0, install, 0))
|
|
||||||
setProgress(0, 0, false)
|
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
|
||||||
setContentTitle(context.getString(R.string.update_download_completed))
|
|
||||||
setContentText(context.getString(R.string.update_download_completed_description))
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationManager.cancel(UPDATE_NOTIFICATION_ID)
|
|
||||||
|
|
||||||
if (context.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED))
|
|
||||||
context.startActivity(install)
|
|
||||||
else
|
|
||||||
notificationManager.notify(UPDATE_NOTIFICATION_ID, builder.build())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setNegativeButton(if (force) android.R.string.no else R.string.ignore_update) { _, _ ->
|
setNegativeButton(if (force) android.R.string.cancel else R.string.ignore) { _, _ ->
|
||||||
if (!force)
|
if (!force)
|
||||||
preferences.edit()
|
preferences.edit()
|
||||||
.putLong("ignore_update_until", System.currentTimeMillis() + 604800000)
|
.putLong("ignore_update_until", System.currentTimeMillis() + 86400000)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,3 +174,41 @@ fun checkUpdate(context: AppCompatActivity, force: Boolean = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun restore(url: String, onFailure: ((Throwable) -> Unit)? = null, onSuccess: ((Int) -> Unit)? = null) {
|
||||||
|
if (!URLUtil.isValidUrl(url)) {
|
||||||
|
onFailure?.invoke(IllegalArgumentException())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
client.newCall(request).enqueue(object: Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
onFailure?.invoke(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
kotlin.runCatching {
|
||||||
|
val data = Json.parseToJsonElement(response.also { if (it.code() != 200) throw IOException() }.body().use { it?.string() } ?: "[]")
|
||||||
|
|
||||||
|
when (data) {
|
||||||
|
is JsonArray -> favorites.addAll(data.map { it.jsonPrimitive.int })
|
||||||
|
is JsonObject -> {
|
||||||
|
val newFavorites = data["favorites"]?.let { Json.decodeFromJsonElement<List<Int>>(it) }.orEmpty()
|
||||||
|
val newFavoriteTags = data["favorite_tags"]?.let { Json.decodeFromJsonElement<List<Tag>>(it) }.orEmpty()
|
||||||
|
|
||||||
|
favorites.addAll(newFavorites)
|
||||||
|
favoriteTags.addAll(newFavoriteTags)
|
||||||
|
|
||||||
|
onSuccess?.invoke(favorites.size + favoriteTags.size)
|
||||||
|
}
|
||||||
|
else -> error("data is neither JsonArray or JsonObject")
|
||||||
|
}
|
||||||
|
}.onFailure { onFailure?.invoke(it) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
24
app/src/main/res/anim/shake.xml
Normal file
24
app/src/main/res/anim/shake.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Pupil, Hitomi.la viewer for Android
|
||||||
|
~ Copyright (C) 2020 tom5079
|
||||||
|
~
|
||||||
|
~ This program is free software: you can redistribute it and/or modify
|
||||||
|
~ it under the terms of the GNU General Public License as published by
|
||||||
|
~ the Free Software Foundation, either version 3 of the License, or
|
||||||
|
~ (at your option) any later version.
|
||||||
|
~
|
||||||
|
~ This program is distributed in the hope that it will be useful,
|
||||||
|
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
~ GNU General Public License for more details.
|
||||||
|
~
|
||||||
|
~ You should have received a copy of the GNU General Public License
|
||||||
|
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:duration="300"
|
||||||
|
android:fromXDelta="0"
|
||||||
|
android:interpolator="@anim/shake_cycle"
|
||||||
|
android:toXDelta="10" />
|
||||||
21
app/src/main/res/anim/shake_cycle.xml
Normal file
21
app/src/main/res/anim/shake_cycle.xml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Pupil, Hitomi.la viewer for Android
|
||||||
|
~ Copyright (C) 2020 tom5079
|
||||||
|
~
|
||||||
|
~ This program is free software: you can redistribute it and/or modify
|
||||||
|
~ it under the terms of the GNU General Public License as published by
|
||||||
|
~ the Free Software Foundation, either version 3 of the License, or
|
||||||
|
~ (at your option) any later version.
|
||||||
|
~
|
||||||
|
~ This program is distributed in the hope that it will be useful,
|
||||||
|
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
~ GNU General Public License for more details.
|
||||||
|
~
|
||||||
|
~ You should have received a copy of the GNU General Public License
|
||||||
|
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<cycleInterpolator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:cycles="3" />
|
||||||
23
app/src/main/res/color/lock_fab.xml
Normal file
23
app/src/main/res/color/lock_fab.xml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Pupil, Hitomi.la viewer for Android
|
||||||
|
~ Copyright (C) 2020 tom5079
|
||||||
|
~
|
||||||
|
~ This program is free software: you can redistribute it and/or modify
|
||||||
|
~ it under the terms of the GNU General Public License as published by
|
||||||
|
~ the Free Software Foundation, either version 3 of the License, or
|
||||||
|
~ (at your option) any later version.
|
||||||
|
~
|
||||||
|
~ This program is distributed in the hope that it will be useful,
|
||||||
|
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
~ GNU General Public License for more details.
|
||||||
|
~
|
||||||
|
~ You should have received a copy of the GNU General Public License
|
||||||
|
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_enabled="false" android:color="@android:color/darker_gray"/>
|
||||||
|
<item android:color="@color/colorPrimary"/>
|
||||||
|
</selector>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user