Compare commits
291 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
3
.gitignore
vendored
@@ -16,4 +16,5 @@
|
|||||||
/gh-pages
|
/gh-pages
|
||||||
|
|
||||||
#Private files
|
#Private files
|
||||||
**/google-services.json
|
**/google-services.json
|
||||||
|
**/credentials.json
|
||||||
16
.idea/codeStyles/Project.xml
generated
@@ -2,6 +2,22 @@
|
|||||||
<code_scheme name="Project" version="173">
|
<code_scheme name="Project" version="173">
|
||||||
<option name="RIGHT_MARGIN" value="120" />
|
<option name="RIGHT_MARGIN" value="120" />
|
||||||
<JetCodeStyleSettings>
|
<JetCodeStyleSettings>
|
||||||
|
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||||
|
<value>
|
||||||
|
<package name="java.util" alias="false" withSubpackages="false" />
|
||||||
|
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
|
||||||
|
<package name="io.ktor" alias="false" withSubpackages="true" />
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
<option name="PACKAGES_IMPORT_LAYOUT">
|
||||||
|
<value>
|
||||||
|
<package name="" alias="false" withSubpackages="true" />
|
||||||
|
<package name="java" alias="false" withSubpackages="true" />
|
||||||
|
<package name="javax" alias="false" withSubpackages="true" />
|
||||||
|
<package name="kotlin" alias="false" withSubpackages="true" />
|
||||||
|
<package name="" alias="true" withSubpackages="true" />
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
</JetCodeStyleSettings>
|
</JetCodeStyleSettings>
|
||||||
<codeStyleSettings language="XML">
|
<codeStyleSettings language="XML">
|
||||||
|
|||||||
6
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="1.8" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
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>
|
|
||||||
3
.idea/copyright/profiles_settings.xml
generated
@@ -1,8 +1,7 @@
|
|||||||
<component name="CopyrightManager">
|
<component name="CopyrightManager">
|
||||||
<settings>
|
<settings>
|
||||||
<module2copyright>
|
<module2copyright>
|
||||||
<element module="Pupil" copyright="GPL" />
|
<element module="Project Files" copyright="GPL" />
|
||||||
<element module="libpupil" copyright="Apache" />
|
|
||||||
</module2copyright>
|
</module2copyright>
|
||||||
</settings>
|
</settings>
|
||||||
</component>
|
</component>
|
||||||
17
.idea/deploymentTargetDropDown.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetDropDown">
|
||||||
|
<runningDeviceTargetSelectedWithDropDown>
|
||||||
|
<Target>
|
||||||
|
<type value="RUNNING_DEVICE_TARGET" />
|
||||||
|
<deviceKey>
|
||||||
|
<Key>
|
||||||
|
<type value="VIRTUAL_DEVICE_PATH" />
|
||||||
|
<value value="$USER_HOME$/.android/avd/Pixel_2_API_30.avd" />
|
||||||
|
</Key>
|
||||||
|
</deviceKey>
|
||||||
|
</Target>
|
||||||
|
</runningDeviceTargetSelectedWithDropDown>
|
||||||
|
<timeTargetWasSelectedWithDropDown value="2022-01-01T07:38:11.679673Z" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
7
.idea/dictionaries/tom50.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<component name="ProjectDictionaryState">
|
||||||
|
<dictionary name="tom50">
|
||||||
|
<words>
|
||||||
|
<w>hitomi</w>
|
||||||
|
</words>
|
||||||
|
</dictionary>
|
||||||
|
</component>
|
||||||
3
.idea/gradle.xml
generated
@@ -4,14 +4,13 @@
|
|||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="PLATFORM" />
|
<option name="testRunner" value="GRADLE" />
|
||||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
<option value="$PROJECT_DIR$/libpupil" />
|
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
<option name="resolveModulePerSourceSet" value="false" />
|
<option name="resolveModulePerSourceSet" value="false" />
|
||||||
|
|||||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="AndroidLintNewerVersionAvailable" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
85
.idea/jarRepositories.xml
generated
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RemoteRepositoriesConfiguration">
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="central" />
|
||||||
|
<option name="name" value="Maven Central repository" />
|
||||||
|
<option name="url" value="https://repo1.maven.org/maven2" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="jboss.community" />
|
||||||
|
<option name="name" value="JBoss Community repository" />
|
||||||
|
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="maven2" />
|
||||||
|
<option name="name" value="maven2" />
|
||||||
|
<option name="url" value="http://guardian.github.com/maven/repo-releases" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="BintrayJCenter" />
|
||||||
|
<option name="name" value="BintrayJCenter" />
|
||||||
|
<option name="url" value="https://jcenter.bintray.com/" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="maven" />
|
||||||
|
<option name="name" value="maven" />
|
||||||
|
<option name="url" value="https://jitpack.io" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="Google" />
|
||||||
|
<option name="name" value="Google" />
|
||||||
|
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="MavenRepo" />
|
||||||
|
<option name="name" value="MavenRepo" />
|
||||||
|
<option name="url" value="https://repo.maven.apache.org/maven2/" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="maven2" />
|
||||||
|
<option name="name" value="maven2" />
|
||||||
|
<option name="url" value="https://guardian.github.com/maven/repo-releases" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="maven3" />
|
||||||
|
<option name="name" value="maven3" />
|
||||||
|
<option name="url" value="https://s3.amazonaws.com/fabric-artifacts-private/internal-snapshots" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="maven4" />
|
||||||
|
<option name="name" value="maven4" />
|
||||||
|
<option name="url" value="https://maven.fabric.io/public" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="MavenLocal" />
|
||||||
|
<option name="name" value="MavenLocal" />
|
||||||
|
<option name="url" value="file:/$USER_HOME$/.m2/repository/" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="MavenLocal" />
|
||||||
|
<option name="name" value="MavenLocal" />
|
||||||
|
<option name="url" value="file:/$USER_HOME$/.m2/repository" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="maven3" />
|
||||||
|
<option name="name" value="maven3" />
|
||||||
|
<option name="url" value="https://dl.bintray.com/tom5079/maven" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="maven3" />
|
||||||
|
<option name="name" value="maven3" />
|
||||||
|
<option name="url" value="http://dl.bintray.com/piasy/maven" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="maven2" />
|
||||||
|
<option name="name" value="maven2" />
|
||||||
|
<option name="url" value="https://guardian.github.io/maven/repo-releases/" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="MavenLocal" />
|
||||||
|
<option name="name" value="MavenLocal" />
|
||||||
|
<option name="url" value="file:$USER_HOME$/.m2/repository/" />
|
||||||
|
</remote-repository>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
2
.idea/misc.xml
generated
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
|
||||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectType">
|
<component name="ProjectType">
|
||||||
|
|||||||
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/libpupil.xml
generated
@@ -1,3 +0,0 @@
|
|||||||
<component name="DependencyValidationManager">
|
|
||||||
<scope name="libpupil" pattern="file[libpupil]:*/" />
|
|
||||||
</component>
|
|
||||||
22
README.md
@@ -1,19 +1,12 @@
|
|||||||
# Pupil
|
|
||||||
|
|
||||||

|

|
||||||
*Pupil, Hitomi.la viewer for Android*
|
*Pupil, Hitomi.la viewer for Android*
|
||||||
|
|
||||||
[](https://discord.gg/Stj4b5v)
|

|
||||||
I can speak English, Japanese and Korean. If you have any questions, head over to my discord server or DM me!
|
[](https://github.com/tom5079/Pupil/releases/download/5.1.29/Pupil-v5.1.29.apk)
|
||||||
|
[](https://discord.gg/Stj4b5v)
|
||||||
|
|
||||||
# Screenshot
|
# Features
|
||||||

|

|
||||||
*Main Screen*
|
|
||||||
|
|
||||||

|
|
||||||
*Reader Screen*
|
|
||||||
|
|
||||||
Images are censored to be SFW
|
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
@@ -27,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)
|
||||||
|
|||||||
166
app/build.gradle
@@ -1,95 +1,141 @@
|
|||||||
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() && file("src/debug/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: 'com.google.firebase.crashlytics'
|
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
|
compileSdkVersion 30
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "xyz.quaver.pupil"
|
applicationId "xyz.quaver.pupil"
|
||||||
minSdkVersion 16
|
minSdkVersion 16
|
||||||
targetSdkVersion 29
|
targetSdkVersion 30
|
||||||
versionCode 54
|
versionCode 69
|
||||||
versionName "4.18.1"
|
versionName "5.1.29"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
multiDexEnabled true
|
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
|
defaultConfig.minSdkVersion 21
|
||||||
|
|
||||||
|
minifyEnabled false
|
||||||
|
shrinkResources false
|
||||||
|
|
||||||
debuggable true
|
debuggable true
|
||||||
applicationIdSuffix ".debug"
|
applicationIdSuffix ".debug"
|
||||||
versionNameSuffix "-DEBUG"
|
versionNameSuffix "-DEBUG"
|
||||||
|
|
||||||
buildConfigField('Boolean', 'CENSOR', 'false')
|
ext.enableCrashlytics = false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
ext.alwaysUpdateBuildId = false
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
buildConfigField('Boolean', 'CENSOR', 'false')
|
minifyEnabled true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
shrinkResources true
|
||||||
|
|
||||||
|
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
freeCompilerArgs += '-Xuse-experimental=kotlin.Experimental'
|
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||||
|
freeCompilerArgs += "-Xuse-experimental=kotlin.Experimental"
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
buildToolsVersion = '29.0.3'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
def markwonVersion = '3.1.0'
|
implementation fileTree(dir: "libs", include: ["*.jar", "*.aar"])
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3"
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.0"
|
||||||
|
|
||||||
|
implementation "androidx.appcompat:appcompat:1.3.0"
|
||||||
|
implementation "androidx.activity:activity-ktx:1.3.0-beta01"
|
||||||
|
implementation "androidx.fragment:fragment-ktx:1.3.4"
|
||||||
|
implementation "androidx.preference:preference-ktx:1.1.1"
|
||||||
|
implementation "androidx.recyclerview:recyclerview:1.2.1"
|
||||||
|
implementation "androidx.constraintlayout:constraintlayout:2.0.4"
|
||||||
|
implementation "androidx.gridlayout:gridlayout:1.0.0"
|
||||||
|
implementation "androidx.biometric:biometric:1.1.0"
|
||||||
|
implementation "androidx.work:work-runtime-ktx:2.6.0-beta01"
|
||||||
|
|
||||||
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.20.0"
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.1.0'
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
|
||||||
implementation 'androidx.preference:preference:1.1.1'
|
|
||||||
implementation 'androidx.gridlayout:gridlayout:1.0.0'
|
|
||||||
implementation "androidx.biometric:biometric:1.0.1"
|
|
||||||
implementation 'androidx.multidex:multidex:2.0.1'
|
|
||||||
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.3.0-alpha01'
|
|
||||||
implementation 'com.google.firebase:firebase-core:17.4.3'
|
|
||||||
implementation 'com.google.firebase:firebase-analytics:17.4.3'
|
|
||||||
implementation 'com.google.firebase:firebase-crashlytics:17.1.0'
|
|
||||||
implementation 'com.google.firebase:firebase-perf:19.0.7'
|
|
||||||
implementation 'com.github.arimorty:floatingsearchview:2.1.1'
|
|
||||||
implementation 'com.github.clans:fab:1.6.4'
|
|
||||||
implementation 'com.github.bumptech.glide:glide:4.11.0'
|
|
||||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'
|
|
||||||
kapt 'com.github.bumptech.glide:compiler:4.11.0'
|
|
||||||
implementation ("com.github.bumptech.glide:recyclerview-integration:4.11.0") {
|
|
||||||
transitive = false
|
|
||||||
}
|
|
||||||
implementation 'net.rdrei.android.dirchooser:library:3.2@aar'
|
|
||||||
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
|
|
||||||
implementation 'com.andrognito.patternlockview:patternlockview:1.0.0'
|
|
||||||
//implementation 'com.andrognito.pinlockview:pinlockview:2.1.0'
|
|
||||||
implementation "ru.noties.markwon:core:${markwonVersion}"
|
|
||||||
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.3.0"
|
||||||
experimental = true
|
|
||||||
}
|
implementation platform('com.google.firebase:firebase-bom:26.5.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.0"
|
||||||
|
implementation "com.google.android.gms:play-services-mlkit-face-detection:16.1.7"
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
//noinspection GradleDependency
|
||||||
|
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
||||||
|
|
||||||
|
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 "org.jsoup:jsoup:1.14.3"
|
||||||
|
implementation "com.github.seven332:quickjs-android:0.1.0"
|
||||||
|
|
||||||
|
implementation "com.google.guava:guava:31.0.1-android"
|
||||||
|
|
||||||
|
implementation "xyz.quaver:documentfilex:0.7.1"
|
||||||
|
implementation "xyz.quaver:floatingsearchview:1.1.7"
|
||||||
|
|
||||||
|
testImplementation "junit:junit:4.13.1"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:1.1.2"
|
||||||
|
androidTestImplementation "androidx.test:rules:1.3.0"
|
||||||
|
androidTestImplementation "androidx.test:runner:1.3.0"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0"
|
||||||
|
}
|
||||||
BIN
app/libs/recyclerviewfastscroller-release.aar
Normal file
22
app/proguard-rules.pro
vendored
@@ -22,17 +22,15 @@
|
|||||||
|
|
||||||
-dontobfuscate
|
-dontobfuscate
|
||||||
|
|
||||||
-keep public class * implements com.bumptech.glide.module.GlideModule
|
-keepattributes *Annotation*, InnerClasses
|
||||||
-keep class * extends com.bumptech.glide.module.AppGlideModule {
|
-dontnote kotlinx.serialization.SerializationKt
|
||||||
<init>(...);
|
-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;
|
||||||
}
|
}
|
||||||
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
|
-keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
|
||||||
**[] $VALUES;
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
public *;
|
|
||||||
}
|
}
|
||||||
-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder {
|
-keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment
|
||||||
*** rewind();
|
-keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment
|
||||||
}
|
-keep class com.hippo.quickjs.** { *; }
|
||||||
|
|
||||||
-keep public class * extends com.bumptech.glide.module.AppGlideModule
|
|
||||||
-keep class com.bumptech.glide.GeneratedAppGlideModuleImpl
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"artifactType": {
|
"artifactType": {
|
||||||
"type": "APK",
|
"type": "APK",
|
||||||
"kind": "Directory"
|
"kind": "Directory"
|
||||||
@@ -10,10 +10,8 @@
|
|||||||
{
|
{
|
||||||
"type": "SINGLE",
|
"type": "SINGLE",
|
||||||
"filters": [],
|
"filters": [],
|
||||||
"properties": [],
|
"versionCode": 69,
|
||||||
"versionCode": 54,
|
"versionName": "5.1.29",
|
||||||
"versionName": "54",
|
|
||||||
"enabled": true,
|
|
||||||
"outputFile": "app-release.apk"
|
"outputFile": "app-release.apk"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -20,24 +20,10 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil
|
package xyz.quaver.pupil
|
||||||
|
|
||||||
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.platform.app.InstrumentationRegistry
|
||||||
import androidx.test.rule.ActivityTestRule
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import xyz.quaver.hitomi.getGalleryIDsFromNozomi
|
|
||||||
import xyz.quaver.hiyobi.cookie
|
|
||||||
import xyz.quaver.hiyobi.createImgList
|
|
||||||
import xyz.quaver.hiyobi.getReader
|
|
||||||
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 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.
|
||||||
@@ -52,70 +38,4 @@ class ExampleInstrumentedTest {
|
|||||||
// Context of the app under test.
|
// Context of the app under test.
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun checkCacheDir() {
|
|
||||||
val activityTestRule = ActivityTestRule(LockActivity::class.java)
|
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
|
|
||||||
ContextCompat.getExternalFilesDirs(appContext, null).forEachIndexed { index, file ->
|
|
||||||
Log.i("PUPILD", "$index: ${file?.absolutePath}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_nozomi() {
|
|
||||||
val nozomi = getGalleryIDsFromNozomi(null, "index", "all")
|
|
||||||
|
|
||||||
Log.i("PUPILD", nozomi.size.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_doSearch() {
|
|
||||||
val reader = getReader( 1426382)
|
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_downloadWorker() {
|
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
|
|
||||||
val galleryID = 515515
|
|
||||||
|
|
||||||
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!!")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_getReaderOrNull() {
|
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
|
|
||||||
val galleryID = 1561552
|
|
||||||
|
|
||||||
runBlocking {
|
|
||||||
Log.i("PUPILD", Cache(context).getReader(galleryID)?.galleryInfo?.title ?: "null")
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i("PUPILD", Cache(context).getReaderOrNull(galleryID)?.galleryInfo?.title ?: "null")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,14 @@
|
|||||||
<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 android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission-sdk-23 android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
<uses-permission-sdk-23 android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
|
||||||
|
<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"
|
||||||
@@ -20,10 +24,14 @@
|
|||||||
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"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
tools:replace="android:theme"
|
tools:replace="android:theme"
|
||||||
android:requestLegacyExternalStorage="true"
|
|
||||||
tools:ignore="UnusedAttribute">
|
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"
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
@@ -36,9 +44,14 @@
|
|||||||
|
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<receiver android:name=".BroadcastReciever" android:exported="true">
|
<service android:name=".services.DownloadService"
|
||||||
|
android:exported="false"/>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receiver.UpdateBroadcastReceiver"
|
||||||
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.DOWNLOAD_COMPLETE"/>
|
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
@@ -204,7 +217,9 @@
|
|||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.SettingsActivity"
|
android:name=".ui.SettingsActivity"
|
||||||
android:label="@string/settings_title" />
|
android:label="@string/settings_title">
|
||||||
|
<tools:validation testUrl="http://ix.io/eer" />
|
||||||
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||||
@@ -215,6 +230,17 @@
|
|||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<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"
|
||||||
|
android:host="ix.io"
|
||||||
|
android:pathPattern="/..*" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" />
|
<activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" />
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -1,35 +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 com.arlib.floatingsearchview
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Parcelable
|
|
||||||
import android.util.AttributeSet
|
|
||||||
|
|
||||||
class FloatingSearchViewDayNight @JvmOverloads constructor(
|
|
||||||
context: Context,
|
|
||||||
attrs: AttributeSet? = null)
|
|
||||||
: FloatingSearchView(context, attrs) {
|
|
||||||
|
|
||||||
// hack to remove color attributes which should not be reused
|
|
||||||
override fun onSaveInstanceState(): Parcelable? {
|
|
||||||
super.onSaveInstanceState()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,71 +18,129 @@
|
|||||||
|
|
||||||
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.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.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 com.google.firebase.analytics.FirebaseAnalytics
|
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
import xyz.quaver.proxy
|
import com.hippo.quickjs.android.QuickJS
|
||||||
import xyz.quaver.pupil.util.Histories
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import xyz.quaver.pupil.util.getProxy
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.MainScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.Dispatcher
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Response
|
||||||
|
import xyz.quaver.io.FileX
|
||||||
|
import xyz.quaver.pupil.types.Tag
|
||||||
|
import xyz.quaver.pupil.util.*
|
||||||
|
import xyz.quaver.readText
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.net.URL
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
class Pupil : Application() {
|
||||||
val preference = PreferenceManager.getDefaultSharedPreferences(this)
|
|
||||||
|
|
||||||
val userID =
|
override fun onCreate() {
|
||||||
if (preference.getString("user_id", "").isNullOrEmpty()) {
|
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
||||||
UUID.randomUUID().toString().also {
|
|
||||||
preference.edit().putString("user_id", it).apply()
|
preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
}
|
|
||||||
} else
|
val userID = Preferences["user_id", ""].let { userID ->
|
||||||
preference.getString("user_id", "") ?: ""
|
if (userID.isEmpty()) UUID.randomUUID().toString().also { Preferences["user_id"] = it }
|
||||||
|
else userID
|
||||||
|
}
|
||||||
|
|
||||||
FirebaseCrashlytics.getInstance().setUserId(userID)
|
FirebaseCrashlytics.getInstance().setUserId(userID)
|
||||||
|
|
||||||
proxy = getProxy(this)
|
val proxyInfo = getProxyInfo()
|
||||||
|
|
||||||
|
clientBuilder = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(0, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(0, TimeUnit.SECONDS)
|
||||||
|
.proxyInfo(proxyInfo)
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
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 {
|
try {
|
||||||
preference.getString("dl_location", null).also {
|
Preferences.get<String>("download_folder").also {
|
||||||
if (!File(it!!).canWrite())
|
if (it.startsWith("content") && Build.VERSION.SDK_INT > 19)
|
||||||
|
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()
|
throw Exception()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
preference.edit().remove("dl_location").apply()
|
Preferences.remove("download_folder")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!preference.getBoolean("low_quality_reset", false)) {
|
if (!Preferences["reset_secure", false]) {
|
||||||
preference.edit()
|
Preferences["security_mode"] = false
|
||||||
.putBoolean("low_quality", true)
|
Preferences["reset_secure"] = true
|
||||||
.putBoolean("low_quality_reset", true)
|
|
||||||
.apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
histories = Histories(File(ContextCompat.getDataDir(this), "histories.json"))
|
histories = SavedSet(File(ContextCompat.getDataDir(this), "histories.json"), 0)
|
||||||
favorites = Histories(File(ContextCompat.getDataDir(this), "favorites.json"))
|
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)
|
if (BuildConfig.DEBUG)
|
||||||
FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false)
|
FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false)*/
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ProviderInstaller.installIfNeeded(this)
|
ProviderInstaller.installIfNeeded(this)
|
||||||
@@ -92,6 +150,8 @@ class Pupil : MultiDexApplication() {
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BigImageViewer.initialize(FrescoImageLoader.with(this))
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -102,6 +162,13 @@ class Pupil : MultiDexApplication() {
|
|||||||
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 {
|
manager.createNotificationChannel(NotificationChannel("update", getString(R.string.channel_update), NotificationManager.IMPORTANCE_HIGH).apply {
|
||||||
description = getString(R.string.channel_update_description)
|
description = getString(R.string.channel_update_description)
|
||||||
enableLights(true)
|
enableLights(true)
|
||||||
@@ -109,7 +176,7 @@ class Pupil : MultiDexApplication() {
|
|||||||
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||||
})
|
})
|
||||||
|
|
||||||
manager.createNotificationChannel(NotificationChannel("import", getString(R.string.channel_update), NotificationManager.IMPORTANCE_HIGH).apply {
|
manager.createNotificationChannel(NotificationChannel("import", getString(R.string.channel_update), NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
description = getString(R.string.channel_update_description)
|
description = getString(R.string.channel_update_description)
|
||||||
enableLights(false)
|
enableLights(false)
|
||||||
enableVibration(false)
|
enableVibration(false)
|
||||||
@@ -117,8 +184,7 @@ class Pupil : MultiDexApplication() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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,356 +18,294 @@
|
|||||||
|
|
||||||
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.preference.PreferenceManager
|
|
||||||
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.RequestManager
|
|
||||||
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 kotlinx.coroutines.withContext
|
|
||||||
import xyz.quaver.hitomi.GalleryBlock
|
|
||||||
import xyz.quaver.hitomi.getReader
|
|
||||||
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.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(private val glide: RequestManager, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var favorites: Histories
|
inner class GalleryViewHolder(val binding: GalleryblockItemBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
private var galleryID: Int = 0
|
||||||
val timer = Timer()
|
|
||||||
|
|
||||||
var isThin = false
|
|
||||||
|
|
||||||
inner class GalleryViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
|
|
||||||
var timerTask: TimerTask? = null
|
|
||||||
|
|
||||||
private fun updateProgress(context: Context, galleryID: Int) {
|
|
||||||
val reader = Cache(context).getReaderOrNull(galleryID)
|
|
||||||
|
|
||||||
|
init {
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
if (reader == null || PreferenceManager.getDefaultSharedPreferences(context).getBoolean("cache_disable", false)) {
|
while (updateAll) {
|
||||||
view.galleryblock_progressbar.visibility = View.GONE
|
updateProgress(itemView.context)
|
||||||
view.galleryblock_progress_complete.visibility = View.GONE
|
delay(1000)
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
with(view.galleryblock_progressbar) {
|
|
||||||
|
|
||||||
progress = Cache(context).getImages(galleryID)?.size ?: 0
|
|
||||||
|
|
||||||
if (visibility == View.GONE) {
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
max = reader.galleryInfo.files.size
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progress == max) {
|
|
||||||
if (completeFlag.get(galleryID, false)) {
|
|
||||||
with(view.galleryblock_progress_complete) {
|
|
||||||
setImageResource(R.drawable.ic_progressbar)
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
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
|
|
||||||
view.galleryblock_progress_complete.visibility = View.INVISIBLE
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bind(galleryBlock: GalleryBlock) {
|
private fun updateProgress(context: Context) = CoroutineScope(Dispatchers.Main).launch {
|
||||||
with(view) {
|
with(binding.galleryblockCard) {
|
||||||
val resources = context.resources
|
val imageList = Cache.getInstance(context, galleryID).metadata.imageList
|
||||||
val languages = resources.getStringArray(R.array.languages).map {
|
|
||||||
it.split("|").let { split ->
|
|
||||||
Pair(split[0], split[1])
|
|
||||||
}
|
|
||||||
}.toMap()
|
|
||||||
|
|
||||||
val artists = galleryBlock.artists
|
if (imageList == null) {
|
||||||
val series = galleryBlock.series
|
max = 0
|
||||||
|
return@with
|
||||||
|
}
|
||||||
|
|
||||||
if (isThin)
|
progress = imageList.count { it != null }
|
||||||
galleryblock_thumbnail.layoutParams.width = context.resources.getDimensionPixelSize(
|
max = imageList.size
|
||||||
R.dimen.galleryblock_thumbnail_thin
|
|
||||||
|
this@GalleryViewHolder.binding.galleryblockId.setOnClickListener {
|
||||||
|
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
|
||||||
|
ClipData.newPlainText("gallery_id", galleryID.toString())
|
||||||
)
|
)
|
||||||
galleryblock_thumbnail.setImageDrawable(CircularProgressDrawable(context).also {
|
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||||
it.start()
|
|
||||||
})
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
val thumbnail = Cache(context).getThumbnail(galleryBlock.id).let {
|
|
||||||
if (it != null)
|
|
||||||
Base64.decode(it, Base64.DEFAULT)
|
|
||||||
else
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryblock_thumbnail.post {
|
|
||||||
glide
|
|
||||||
.load(thumbnail)
|
|
||||||
.skipMemoryCache(true)
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
||||||
.error(R.drawable.image_broken_variant)
|
|
||||||
.apply {
|
|
||||||
if (BuildConfig.CENSOR)
|
|
||||||
override(5, 8)
|
|
||||||
}
|
|
||||||
.into(galleryblock_thumbnail)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//Check cache
|
type = if (!imageList.contains(null)) {
|
||||||
val cache = Cache(context).getCachedGallery(galleryBlock.id)
|
val downloadManager = DownloadManager.getInstance(context)
|
||||||
val reader = Cache(context).getReaderOrNull(galleryBlock.id)
|
|
||||||
|
|
||||||
if (reader != null) {
|
if (downloadManager.getDownloadFolder(galleryID) == null)
|
||||||
val count = cache.listFiles()?.count {
|
ProgressCard.Type.CACHE
|
||||||
Regex("^[0-9]+.+\$").matches(it.name)
|
else
|
||||||
} ?: 0
|
ProgressCard.Type.DOWNLOAD
|
||||||
|
|
||||||
with(galleryblock_progressbar) {
|
|
||||||
max = reader.galleryInfo.files.size
|
|
||||||
progress = count
|
|
||||||
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
} else
|
} else
|
||||||
galleryblock_progressbar.visibility = View.GONE
|
ProgressCard.Type.LOADING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (timerTask == null)
|
fun bind(galleryID: Int) {
|
||||||
timerTask = timer.schedule(0, 1000) {
|
this.galleryID = galleryID
|
||||||
updateProgress(context, galleryBlock.id)
|
updateProgress(itemView.context)
|
||||||
}
|
|
||||||
|
|
||||||
galleryblock_title.text = galleryBlock.title
|
val cache = Cache.getInstance(itemView.context, galleryID)
|
||||||
with(galleryblock_artist) {
|
|
||||||
text = artists.joinToString(", ") { it.wordCapitalize() }
|
|
||||||
visibility = when {
|
|
||||||
artists.isNotEmpty() -> View.VISIBLE
|
|
||||||
else -> View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with(galleryblock_series) {
|
|
||||||
text =
|
|
||||||
resources.getString(
|
|
||||||
R.string.galleryblock_series,
|
|
||||||
series.joinToString(", ") { it.wordCapitalize() })
|
|
||||||
visibility = when {
|
|
||||||
series.isNotEmpty() -> View.VISIBLE
|
|
||||||
else -> View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
galleryblock_type.text = resources.getString(R.string.galleryblock_type, galleryBlock.type).wordCapitalize()
|
|
||||||
with(galleryblock_language) {
|
|
||||||
text =
|
|
||||||
resources.getString(R.string.galleryblock_language, languages[galleryBlock.language])
|
|
||||||
visibility = when {
|
|
||||||
galleryBlock.language.isNotEmpty() -> View.VISIBLE
|
|
||||||
else -> View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryblock_tag_group.removeAllViews()
|
val galleryBlock = runBlocking {
|
||||||
galleryBlock.relatedTags.forEach {
|
cache.getGalleryBlock()
|
||||||
galleryblock_tag_group.addView(Chip(context).apply {
|
} ?: return
|
||||||
val tag = Tag.parse(it).let { tag ->
|
|
||||||
when {
|
val resources = itemView.context.resources
|
||||||
tag.area != null -> tag
|
val languages = resources.getStringArray(R.array.languages).map {
|
||||||
else -> Tag("tag", it)
|
it.split("|").let { split ->
|
||||||
}
|
Pair(split[0], split[1])
|
||||||
|
}
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
val artists = galleryBlock.artists
|
||||||
|
val series = galleryBlock.series
|
||||||
|
|
||||||
|
binding.galleryblockThumbnail.apply {
|
||||||
|
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.getInstance(context, galleryID).let { cache ->
|
||||||
|
cache.cacheFolder.getChild(".thumbnail").let { if (it.exists()) it.delete() }
|
||||||
|
cache.downloadFolder?.getChild(".thumbnail")?.let { if (it.exists()) it.delete() }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
chipIcon = when(tag.area) {
|
override fun onCacheHit(imageType: Int, image: File?) {}
|
||||||
"male" -> {
|
override fun onCacheMiss(imageType: Int, image: File?) {}
|
||||||
setChipBackgroundColorResource(R.color.material_blue_700)
|
override fun onFinish() {}
|
||||||
setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
override fun onProgress(progress: Int) {}
|
||||||
ContextCompat.getDrawable(context, R.drawable.gender_male)
|
override fun onStart() {}
|
||||||
}
|
override fun onSuccess(image: File?) {}
|
||||||
"female" -> {
|
})
|
||||||
setChipBackgroundColorResource(R.color.material_pink_600)
|
ssiv?.recycle()
|
||||||
setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
|
||||||
ContextCompat.getDrawable(context, R.drawable.gender_female)
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
text = tag.tag.wordCapitalize()
|
|
||||||
setEnsureMinTouchTargetSize(false)
|
|
||||||
setOnClickListener {
|
|
||||||
for (callback in onChipClickedHandler)
|
|
||||||
callback.invoke(tag)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryblock_id.text = galleryBlock.id.toString()
|
|
||||||
galleryblock_pagecount.text = "-"
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
val pageCount = kotlin.runCatching {
|
cache.getThumbnail().let { launch(Dispatchers.Main) {
|
||||||
getReader(galleryBlock.id).galleryInfo.files.size
|
showImage(it)
|
||||||
}.getOrNull() ?: return@launch
|
} }
|
||||||
withContext(Dispatchers.Main) {
|
}
|
||||||
galleryblock_pagecount.text = context.getString(R.string.galleryblock_pagecount, pageCount)
|
}
|
||||||
|
|
||||||
|
binding.galleryblockTitle.text = galleryBlock.title
|
||||||
|
with(binding.galleryblockArtist) {
|
||||||
|
text = artists.joinToString { it.wordCapitalize() }
|
||||||
|
visibility = when {
|
||||||
|
artists.isNotEmpty() -> View.VISIBLE
|
||||||
|
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(binding.galleryblockSeries) {
|
||||||
|
text =
|
||||||
|
resources.getString(
|
||||||
|
R.string.galleryblock_series,
|
||||||
|
series.joinToString(", ") { it.wordCapitalize() })
|
||||||
|
visibility = when {
|
||||||
|
series.isNotEmpty() -> View.VISIBLE
|
||||||
|
else -> View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.galleryblockType.text = resources.getString(R.string.galleryblock_type, galleryBlock.type).wordCapitalize()
|
||||||
|
with(binding.galleryblockLanguage) {
|
||||||
|
text =
|
||||||
|
resources.getString(R.string.galleryblock_language, languages[galleryBlock.language])
|
||||||
|
visibility = when {
|
||||||
|
galleryBlock.language.isNotEmpty() -> View.VISIBLE
|
||||||
|
else -> View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with(binding.galleryblockTagGroup) {
|
||||||
|
onClickListener = {
|
||||||
|
onChipClickedHandler.forEach { callback ->
|
||||||
|
callback.invoke(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!::favorites.isInitialized)
|
tags.clear()
|
||||||
favorites = (context.applicationContext as Pupil).favorites
|
|
||||||
|
|
||||||
with(galleryblock_favorite) {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
setImageResource(if (favorites.contains(galleryBlock.id)) R.drawable.ic_star_filled else R.drawable.ic_star_empty)
|
tags.addAll(
|
||||||
setOnClickListener {
|
galleryBlock.relatedTags.sortedBy {
|
||||||
when {
|
val tag = Tag.parse(it)
|
||||||
favorites.contains(galleryBlock.id) -> {
|
|
||||||
favorites.remove(galleryBlock.id)
|
|
||||||
|
|
||||||
setImageResource(R.drawable.ic_star_empty)
|
if (favoriteTags.contains(tag))
|
||||||
}
|
-1
|
||||||
else -> {
|
else
|
||||||
favorites.add(galleryBlock.id)
|
when(Tag.parse(it).area) {
|
||||||
|
"female" -> 0
|
||||||
|
"male" -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
}.map {
|
||||||
|
Tag.parse(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
setImageDrawable(AnimatedVectorDrawableCompat.create(context, R.drawable.avd_star).apply {
|
launch(Dispatchers.Main) {
|
||||||
this ?: return@apply
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
registerAnimationCallback(object: Animatable2Compat.AnimationCallback() {
|
binding.galleryblockId.text = galleryBlock.id.toString()
|
||||||
override fun onAnimationEnd(drawable: Drawable?) {
|
binding.galleryblockPagecount.text = "-"
|
||||||
setImageResource(R.drawable.ic_star_filled)
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
}
|
val pageCount = kotlin.runCatching {
|
||||||
})
|
getGalleryInfo(galleryBlock.id).files.size
|
||||||
start()
|
}.getOrNull() ?: return@launch
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
binding.galleryblockPagecount.text = itemView.context.getString(R.string.galleryblock_pagecount, pageCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with(binding.galleryblockFavorite) {
|
||||||
|
setImageResource(if (favorites.contains(galleryBlock.id)) R.drawable.ic_star_filled else R.drawable.ic_star_empty)
|
||||||
|
setOnClickListener {
|
||||||
|
when {
|
||||||
|
favorites.contains(galleryBlock.id) -> {
|
||||||
|
favorites.remove(galleryBlock.id)
|
||||||
|
|
||||||
|
setImageResource(R.drawable.ic_star_empty)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
favorites.add(galleryBlock.id)
|
||||||
|
|
||||||
|
setImageDrawable(AnimatedVectorDrawableCompat.create(context, R.drawable.avd_star).apply {
|
||||||
|
this ?: return@apply
|
||||||
|
|
||||||
|
registerAnimationCallback(object: Animatable2Compat.AnimationCallback() {
|
||||||
|
override fun onAnimationEnd(drawable: Drawable?) {
|
||||||
|
setImageResource(R.drawable.ic_star_filled)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
start()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Make some views invisible to make it thinner
|
// Make some views invisible to make it thinner
|
||||||
if (isThin) {
|
if (thin) {
|
||||||
galleryblock_language.visibility = View.GONE
|
binding.galleryblockTagGroup.visibility = View.GONE
|
||||||
galleryblock_type.visibility = View.GONE
|
|
||||||
galleryblock_tag_group.visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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()
|
|
||||||
|
|
||||||
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 (Cache(holder.view.context).isDownloading(gallery.id))
|
if (DownloadManager.getInstance(holder.binding.root.context).isDownloading(galleryID))
|
||||||
holder.view.context.getString(android.R.string.cancel)
|
holder.binding.root.context.getString(android.R.string.cancel)
|
||||||
else
|
else
|
||||||
holder.view.context.getString(R.string.main_download)
|
holder.binding.root.context.getString(R.string.main_download)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClose(layout: SwipeLayout?) {}
|
override fun onClose(layout: SwipeLayout?) {}
|
||||||
@@ -379,27 +317,7 @@ class GalleryBlockAdapter(private val glide: RequestManager, private val galleri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,87 +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.annotation.SuppressLint
|
|
||||||
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
|
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
|
||||||
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
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -18,174 +18,233 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.adapters
|
package xyz.quaver.pupil.adapters
|
||||||
|
|
||||||
|
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.preference.PreferenceManager
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.bumptech.glide.RequestManager
|
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.facebook.drawee.backends.pipeline.Fresco
|
||||||
import com.bumptech.glide.load.model.GlideUrl
|
import com.facebook.drawee.controller.BaseControllerListener
|
||||||
import com.bumptech.glide.load.model.LazyHeaders
|
import com.facebook.drawee.drawable.ScalingUtils
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.facebook.drawee.interfaces.DraweeController
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
import com.facebook.drawee.view.SimpleDraweeView
|
||||||
import kotlinx.android.synthetic.main.activity_reader.view.*
|
import com.facebook.imagepipeline.image.ImageInfo
|
||||||
import kotlinx.android.synthetic.main.item_reader.view.*
|
import com.github.piasy.biv.view.BigImageView
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import com.github.piasy.biv.view.ImageShownCallback
|
||||||
import kotlinx.coroutines.Dispatchers
|
import com.github.piasy.biv.view.ImageViewFactory
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.*
|
||||||
import xyz.quaver.Code
|
|
||||||
import xyz.quaver.hitomi.Reader
|
|
||||||
import xyz.quaver.hitomi.getReferer
|
|
||||||
import xyz.quaver.hitomi.imageUrlFromImage
|
|
||||||
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.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 glide: RequestManager,
|
class ReaderAdapter(
|
||||||
private val galleryID: Int) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() {
|
private val activity: ReaderActivity,
|
||||||
|
private val galleryID: Int
|
||||||
var reader: Reader? = null
|
) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() {
|
||||||
val timer = Timer()
|
var galleryInfo: GalleryInfo? = null
|
||||||
|
|
||||||
var isFullScreen = false
|
var isFullScreen = false
|
||||||
|
|
||||||
var onItemClickListener : ((Int) -> (Unit))? = null
|
var onItemClickListener : (() -> (Unit))? = null
|
||||||
|
|
||||||
class ViewHolder(val view: View) : RecyclerView.ViewHolder(view)
|
inner class ViewHolder(private val binding: ReaderItemBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
init {
|
||||||
|
with (binding.image) {
|
||||||
|
setImageViewFactory(FrescoImageViewFactory().apply {
|
||||||
|
updateView = { imageInfo ->
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var downloadWorker: DownloadWorker? = null
|
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) {
|
|
||||||
holder.view as ConstraintLayout
|
|
||||||
|
|
||||||
if (downloadWorker == null)
|
|
||||||
downloadWorker = DownloadWorker.getInstance(holder.view.context)
|
|
||||||
|
|
||||||
if (isFullScreen) {
|
|
||||||
holder.view.layoutParams.height = RecyclerView.LayoutParams.MATCH_PARENT
|
|
||||||
holder.view.container.layoutParams.height = ConstraintLayout.LayoutParams.MATCH_PARENT
|
|
||||||
} else {
|
|
||||||
holder.view.layoutParams.height = RecyclerView.LayoutParams.WRAP_CONTENT
|
|
||||||
holder.view.container.layoutParams.height = 0
|
|
||||||
|
|
||||||
(holder.view.container.layoutParams as ConstraintLayout.LayoutParams)
|
|
||||||
.dimensionRatio = "W,${reader!!.galleryInfo.files[position].width}:${reader!!.galleryInfo.files[position].height}"
|
|
||||||
}
|
|
||||||
|
|
||||||
holder.view.image.setOnPhotoTapListener { _, _, _ ->
|
|
||||||
onItemClickListener?.invoke(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
holder.view.container.setOnClickListener {
|
|
||||||
onItemClickListener?.invoke(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
holder.view.reader_index.text = (position+1).toString()
|
|
||||||
|
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(holder.view.context)
|
|
||||||
if (preferences.getBoolean("cache_disable", false)) {
|
|
||||||
val lowQuality = preferences.getBoolean("low_quality", false)
|
|
||||||
|
|
||||||
val url = when (reader!!.code) {
|
|
||||||
Code.HITOMI ->
|
|
||||||
GlideUrl(
|
|
||||||
imageUrlFromImage(
|
|
||||||
galleryID,
|
|
||||||
reader!!.galleryInfo.files[position],
|
|
||||||
!lowQuality
|
|
||||||
)
|
|
||||||
, LazyHeaders.Builder().addHeader("Referer", getReferer(galleryID)).build())
|
|
||||||
Code.HIYOBI ->
|
|
||||||
GlideUrl(createImgList(galleryID, reader!!, lowQuality)[position].path, LazyHeaders.Builder()
|
|
||||||
.addHeader("User-Agent", user_agent)
|
|
||||||
.addHeader("Cookie", cookie)
|
|
||||||
.build())
|
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
holder.view.image.post {
|
|
||||||
glide
|
binding.root.setOnClickListener {
|
||||||
.load(url!!)
|
onItemClickListener?.invoke()
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
||||||
.skipMemoryCache(false)
|
|
||||||
.fitCenter()
|
|
||||||
.error(R.drawable.image_broken_variant)
|
|
||||||
.into(holder.view.image)
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
val image = Cache(holder.view.context).getImage(galleryID, position)
|
|
||||||
val progress = downloadWorker!!.progress[galleryID]?.get(position)
|
fun bind(position: Int) {
|
||||||
|
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}"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||||
|
height = ConstraintLayout.LayoutParams.MATCH_PARENT
|
||||||
|
dimensionRatio = null
|
||||||
|
}
|
||||||
|
binding.root.background = null
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if (progress?.isInfinite() == true && image != null) {
|
||||||
holder.view.reader_item_progressbar.visibility = View.INVISIBLE
|
binding.progressGroup.visibility = View.INVISIBLE
|
||||||
|
binding.image.showImage(image.uri)
|
||||||
holder.view.image.post {
|
|
||||||
glide
|
|
||||||
.load(image)
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
||||||
.skipMemoryCache(true)
|
|
||||||
.fitCenter()
|
|
||||||
.error(R.drawable.image_broken_variant)
|
|
||||||
.into(holder.view.image)
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
holder.view.reader_item_progressbar.visibility = View.VISIBLE
|
binding.progressGroup.visibility = View.VISIBLE
|
||||||
|
binding.readerItemProgressbar.progress =
|
||||||
|
if (progress?.isInfinite() == true)
|
||||||
|
100
|
||||||
|
else
|
||||||
|
progress?.roundToInt() ?: 0
|
||||||
|
|
||||||
glide.clear(holder.view.image)
|
clear()
|
||||||
|
|
||||||
if (progress?.isNaN() == true) {
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
FirebaseCrashlytics.getInstance().recordException(
|
delay(1000)
|
||||||
DownloadWorker.getInstance(holder.view.context).exception[galleryID]?.get(position)!!
|
notifyItemChanged(position)
|
||||||
)
|
|
||||||
|
|
||||||
glide
|
|
||||||
.load(R.drawable.image_broken_variant)
|
|
||||||
.into(holder.view.image)
|
|
||||||
|
|
||||||
Snackbar.make(holder.view.reader_layout, R.string.reader_error_retry, Snackbar.LENGTH_SHORT).apply {
|
|
||||||
setAction(android.R.string.no) { }
|
|
||||||
setAction(android.R.string.yes) {
|
|
||||||
downloadWorker!!.cancel(galleryID)
|
|
||||||
downloadWorker!!.queue.add(galleryID)
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
holder.view.reader_item_progressbar.progress =
|
|
||||||
if (progress?.isInfinite() == true)
|
|
||||||
100
|
|
||||||
else
|
|
||||||
progress?.roundToInt() ?: 0
|
|
||||||
|
|
||||||
holder.view.image.setImageDrawable(null)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
timer.schedule(1000) {
|
fun clear() {
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
binding.image.mainView.let {
|
||||||
notifyItemChanged(position)
|
when (it) {
|
||||||
}
|
is SubsamplingScaleImageView ->
|
||||||
|
it.recycle()
|
||||||
|
is SimpleDraweeView ->
|
||||||
|
it.controller = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount() = reader?.galleryInfo?.files?.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
|
||||||
|
|
||||||
|
}
|
||||||
65
app/src/main/java/xyz/quaver/pupil/hitomi/Utils.kt
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlinx.serialization.modules.plus
|
||||||
|
import kotlinx.serialization.modules.polymorphic
|
||||||
|
import kotlinx.serialization.modules.subclass
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import xyz.quaver.pupil.client
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* kotlinx.serialization.json.Json object for global use
|
||||||
|
* properties should not be changed
|
||||||
|
*
|
||||||
|
* @see [https://kotlin.github.io/kotlinx.serialization/kotlinx-serialization-core/kotlinx-serialization-core/kotlinx.serialization.json/-json/index.html]
|
||||||
|
*/
|
||||||
|
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() }.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() }.body()?.use { it.bytes() } ?: throw IOException()
|
||||||
|
}
|
||||||
148
app/src/main/java/xyz/quaver/pupil/hitomi/common.kt
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* 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 android.util.Log
|
||||||
|
import com.hippo.quickjs.android.QuickJS
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import xyz.quaver.json
|
||||||
|
import xyz.quaver.readText
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
const val protocol = "https:"
|
||||||
|
|
||||||
|
@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"
|
||||||
|
|
||||||
|
interface gg {
|
||||||
|
fun m(g: Int): Int
|
||||||
|
val b: String
|
||||||
|
fun s(h: String): String
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile private var instance: gg? = null
|
||||||
|
|
||||||
|
fun getInstance(): gg =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: object: gg {
|
||||||
|
private val ggjs by lazy { URL("https://ltn.hitomi.la/gg.js").readText() }
|
||||||
|
private val quickJS = QuickJS.Builder().build()
|
||||||
|
|
||||||
|
override fun m(g: Int): Int =
|
||||||
|
quickJS.createJSRuntime().use { runtime ->
|
||||||
|
runtime.createJSContext().use { context ->
|
||||||
|
context.evaluate(ggjs, "gg.js")
|
||||||
|
context.evaluate("gg.m($g)", "gg.js", Int::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val b: String
|
||||||
|
get() =
|
||||||
|
quickJS.createJSRuntime().use { runtime ->
|
||||||
|
runtime.createJSContext().use { context ->
|
||||||
|
context.evaluate(ggjs, "gg.js")
|
||||||
|
context.evaluate("gg.b", "gg.js", String::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun s(h: String): String =
|
||||||
|
quickJS.createJSRuntime().use { runtime ->
|
||||||
|
runtime.createJSContext().use { context ->
|
||||||
|
context.evaluate(ggjs, "gg.js")
|
||||||
|
context.evaluate("gg.s('$h')", "gg.js", String::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.getInstance().m(g)).toChar().toString() + retval
|
||||||
|
}
|
||||||
|
|
||||||
|
return retval
|
||||||
|
}
|
||||||
|
|
||||||
|
fun urlFromUrl(url: String, base: String? = null) : String {
|
||||||
|
return url.replace(Regex("""//..?\.hitomi\.la/"""), "//${subdomainFromURL(url, base)}.hitomi.la/")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun fullPathFromHash(hash: String) : String =
|
||||||
|
"${gg.getInstance().b}${gg.getInstance().s(hash)}/$hash"
|
||||||
|
|
||||||
|
fun realFullPathFromHash(hash: String): String =
|
||||||
|
hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1/$hash")
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
fun rewriteTnPaths(html: String) =
|
||||||
|
html.replace(Regex("""//tn\.hitomi\.la/[^/]+/[0-9a-f]/[0-9a-f]{2}/[0-9a-f]{64}""")) { url ->
|
||||||
|
urlFromUrl(url.value, "tn")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean) : String {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,11 +14,12 @@
|
|||||||
* 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 org.jsoup.Jsoup
|
||||||
import xyz.quaver.proxy
|
import xyz.quaver.readText
|
||||||
|
import java.net.URL
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -37,13 +38,13 @@ data class Gallery(
|
|||||||
val thumbnails: List<String>
|
val thumbnails: List<String>
|
||||||
)
|
)
|
||||||
fun getGallery(galleryID: Int) : Gallery {
|
fun getGallery(galleryID: Int) : Gallery {
|
||||||
val url = Jsoup.connect("https://hitomi.la/galleries/$galleryID.html").proxy(proxy).get()
|
val url = Jsoup.parse(URL("https://hitomi.la/galleries/$galleryID.html").readText())
|
||||||
.select("a").attr("href")
|
.select("link").attr("href")
|
||||||
|
|
||||||
val doc = Jsoup.connect(url).get()
|
val doc = Jsoup.parse(URL(url).readText())
|
||||||
|
|
||||||
val related = Regex("\\d+")
|
val related = Regex("\\d+")
|
||||||
.findAll(doc.select("script").first().html())
|
.findAll(doc.select("script").first()!!.html())
|
||||||
.map {
|
.map {
|
||||||
it.value.toInt()
|
it.value.toInt()
|
||||||
}.toList()
|
}.toList()
|
||||||
@@ -52,16 +53,16 @@ fun getGallery(galleryID: Int) : Gallery {
|
|||||||
Pair(it.text(), "$protocol//hitomi.la${it.attr("href")}")
|
Pair(it.text(), "$protocol//hitomi.la${it.attr("href")}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val cover = protocol + doc.selectFirst(".cover img").attr("src")
|
val cover = protocol + doc.selectFirst(".cover img")!!.attr("src")
|
||||||
val title = doc.selectFirst(".gallery h1 a").text()
|
val title = doc.selectFirst(".gallery h1 a")!!.text()
|
||||||
val artists = doc.select(".gallery h2 a").map { it.text() }
|
val artists = doc.select(".gallery h2 a").map { it.text() }
|
||||||
val groups = doc.select(".gallery-info a[href~=^/group/]").map { it.text() }
|
val groups = doc.select(".gallery-info a[href~=^/group/]").map { it.text() }
|
||||||
val type = doc.selectFirst(".gallery-info a[href~=^/type/]").text()
|
val type = doc.selectFirst(".gallery-info a[href~=^/type/]")!!.text()
|
||||||
|
|
||||||
val language = {
|
val language = run {
|
||||||
val href = doc.select(".gallery-info a[href~=^/index-.+-1.html]").attr("href")
|
val href = doc.select(".gallery-info a[href~=^/index.+\\.html\$]").attr("href")
|
||||||
href.slice(7 until href.indexOf("-1"))
|
Regex("""index-([^-]+)(-.+)?\.html""").find(href)?.groupValues?.getOrNull(1) ?: ""
|
||||||
}.invoke()
|
}
|
||||||
|
|
||||||
val series = doc.select(".gallery-info a[href~=^/series/]").map { it.text() }
|
val series = doc.select(".gallery-info a[href~=^/series/]").map { it.text() }
|
||||||
val characters = doc.select(".gallery-info a[href~=^/character/]").map { it.text() }
|
val characters = doc.select(".gallery-info a[href~=^/character/]").map { it.text() }
|
||||||
@@ -14,12 +14,11 @@
|
|||||||
* 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 org.jsoup.Jsoup
|
||||||
import xyz.quaver.Code
|
import xyz.quaver.readText
|
||||||
import xyz.quaver.proxy
|
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
@@ -65,7 +64,6 @@ fun fetchNozomi(area: String? = null, tag: String = "index", language: String =
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GalleryBlock(
|
data class GalleryBlock(
|
||||||
val code: Code,
|
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val galleryUrl: String,
|
val galleryUrl: String,
|
||||||
val thumbnails: List<String>,
|
val thumbnails: List<String>,
|
||||||
@@ -76,29 +74,32 @@ data class GalleryBlock(
|
|||||||
val language: String,
|
val language: String,
|
||||||
val relatedTags: List<String>
|
val relatedTags: List<String>
|
||||||
)
|
)
|
||||||
fun getGalleryBlock(galleryID: Int) : GalleryBlock? {
|
|
||||||
|
fun getGalleryBlock(galleryID: Int) : GalleryBlock {
|
||||||
val url = "$protocol//$domain/$galleryblockdir/$galleryID$extension"
|
val url = "$protocol//$domain/$galleryblockdir/$galleryID$extension"
|
||||||
|
|
||||||
val doc = Jsoup.connect(url).proxy(proxy).get()
|
val doc = Jsoup.parse(rewriteTnPaths(URL(url).readText()))
|
||||||
|
|
||||||
val galleryUrl = doc.selectFirst(".lillie").attr("href")
|
val galleryUrl = doc.selectFirst("h1 > a")!!.attr("href")
|
||||||
|
|
||||||
val thumbnails = doc.select("img").map { protocol + it.attr("data-src") }
|
val thumbnails = doc.select(".dj-img-cont img").map { protocol + it.attr("src") }
|
||||||
|
|
||||||
val title = doc.selectFirst("h1.lillie > a").text()
|
val title = doc.selectFirst("h1 > a")!!.text()
|
||||||
val artists = doc.select("div.artist-list a").map{ it.text() }
|
val artists = doc.select(".artist-list a").map{ it.text() }
|
||||||
val series = doc.select("a[href~=^/series/]").map { it.text() }
|
val series = doc.select(".dj-content a[href~=^/series/]").map { it.text() }
|
||||||
val type = doc.selectFirst("a[href~=^/type/]").text()
|
val type = doc.selectFirst("a[href~=^/type/]")!!.text()
|
||||||
|
|
||||||
val language = {
|
val language = run {
|
||||||
val href = doc.select("a[href~=^/index-.+-1.html]").attr("href")
|
val href = doc.select("a[href~=^/index.+\\.html\$]").attr("href")
|
||||||
href.slice(7 until href.indexOf("-1"))
|
Regex("""index-([^-]+)(-.+)?\.html""").find(href)?.groupValues?.getOrNull(1) ?: ""
|
||||||
}.invoke()
|
}
|
||||||
|
|
||||||
val relatedTags = doc.select(".relatedtags a").map {
|
val relatedTags = doc.select(".relatedtags a").map {
|
||||||
val href = URLDecoder.decode(it.attr("href"), "UTF-8")
|
val href = URLDecoder.decode(it.attr("href"), "UTF-8")
|
||||||
href.slice(5 until href.indexOf("-all"))
|
href.slice(5 until href.indexOf("-all"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return GalleryBlock(Code.HITOMI, galleryID, galleryUrl, thumbnails, title, artists, series, type, language, relatedTags)
|
return GalleryBlock(galleryID, galleryUrl, thumbnails, title, artists, series, type, language, relatedTags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getGalleryBlockOrNull(galleryID: Int) = runCatching { getGalleryBlock(galleryID) }.getOrNull()
|
||||||
@@ -14,10 +14,9 @@
|
|||||||
* 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 xyz.quaver.Code
|
|
||||||
|
|
||||||
fun getReferer(galleryID: Int) = "https://hitomi.la/reader/$galleryID.html"
|
fun getReferer(galleryID: Int) = "https://hitomi.la/reader/$galleryID.html"
|
||||||
|
|
||||||
@@ -35,17 +34,16 @@ data class GalleryInfo(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class GalleryFiles(
|
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 hasavif: Int = 0,
|
||||||
|
val hasavifsmalltn: Int? = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Reader(val code: Code, val galleryInfo: 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"))
|
||||||
return Reader(Code.HITOMI, getGalleryInfo(galleryID))
|
fun getReader(galleryID: Int) : GalleryInfo {
|
||||||
|
return getGalleryInfo(galleryID)
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package xyz.quaver.hitomi
|
package xyz.quaver.pupil.hitomi
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -22,11 +22,11 @@ import kotlinx.coroutines.async
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
fun doSearch(query: String, sortByPopularity: Boolean = false) : List<Int> {
|
fun doSearch(query: String, sortByPopularity: Boolean = false) : Set<Int> {
|
||||||
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 +38,42 @@ 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 {
|
CoroutineScope(Dispatchers.IO).async {
|
||||||
getGalleryIDsForQuery(it)
|
kotlin.runCatching {
|
||||||
|
getGalleryIDsForQuery(it)
|
||||||
|
}.getOrElse { emptySet() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val negativeResults = negativeTerms.map {
|
val negativeResults = negativeTerms.map {
|
||||||
CoroutineScope(Dispatchers.IO).async {
|
CoroutineScope(Dispatchers.IO).async {
|
||||||
getGalleryIDsForQuery(it)
|
kotlin.runCatching {
|
||||||
|
getGalleryIDsForQuery(it)
|
||||||
|
}.getOrElse { emptySet() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var results = when {
|
var 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@Synchronized fun filterPositive(newResults: List<Int>) {
|
@Synchronized fun filterPositive(newResults: Set<Int>) {
|
||||||
results = when {
|
results = when {
|
||||||
results.isEmpty() -> newResults
|
results.isEmpty() -> newResults
|
||||||
else -> newResults.sorted().let { sorted ->
|
else -> results intersect newResults
|
||||||
results.filter { sorted.binarySearch(it) >= 0 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized fun filterNegative(newResults: List<Int>) {
|
@Synchronized fun filterNegative(newResults: Set<Int>) {
|
||||||
results = newResults.sorted().let { sorted ->
|
results = results subtract newResults
|
||||||
results.filter { sorted.binarySearch(it) < 0 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//positive results
|
//positive results
|
||||||
@@ -14,14 +14,17 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package xyz.quaver.hitomi
|
package xyz.quaver.pupil.hitomi
|
||||||
|
|
||||||
import xyz.quaver.proxy
|
import okhttp3.Request
|
||||||
|
import xyz.quaver.pupil.client
|
||||||
|
import xyz.quaver.readBytes
|
||||||
|
import xyz.quaver.readText
|
||||||
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 = "-"
|
||||||
@@ -32,8 +35,8 @@ 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") }
|
||||||
|
|
||||||
fun sha256(data: ByteArray) : ByteArray {
|
fun sha256(data: ByteArray) : ByteArray {
|
||||||
return MessageDigest.getInstance("SHA-256").digest(data)
|
return MessageDigest.getInstance("SHA-256").digest(data)
|
||||||
@@ -48,18 +51,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()}").openConnection(proxy).getInputStream().use {
|
|
||||||
it.reader().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(":")
|
||||||
@@ -86,14 +82,14 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,15 +117,12 @@ fun getSuggestionsForQuery(query: String) : List<Suggestion> {
|
|||||||
|
|
||||||
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>()
|
||||||
|
|
||||||
@@ -168,7 +161,7 @@ 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"
|
||||||
@@ -176,14 +169,12 @@ fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : List
|
|||||||
}
|
}
|
||||||
|
|
||||||
val bytes = try {
|
val bytes = try {
|
||||||
URL(nozomiAddress).openConnection(proxy).getInputStream().use {
|
URL(nozomiAddress).readBytes()
|
||||||
it.readBytes()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return emptyList()
|
return emptySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
val nozomi = ArrayList<Int>()
|
val nozomi = mutableSetOf<Int>()
|
||||||
|
|
||||||
val arrayBuffer = ByteBuffer
|
val arrayBuffer = ByteBuffer
|
||||||
.wrap(bytes)
|
.wrap(bytes)
|
||||||
@@ -195,18 +186,15 @@ fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : List
|
|||||||
return nozomi
|
return nozomi
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -228,34 +216,26 @@ 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(proxy) 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalUnsignedTypes::class)
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
@@ -305,7 +285,7 @@ fun decodeNode(data: ByteArray) : Node {
|
|||||||
@OptIn(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])
|
||||||
@@ -318,7 +298,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)
|
||||||
@@ -16,7 +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
|
package xyz.quaver.pupil.receiver
|
||||||
|
|
||||||
import android.app.DownloadManager
|
import android.app.DownloadManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
@@ -28,18 +28,11 @@ import android.webkit.MimeTypeMap
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.preference.PreferenceManager
|
import xyz.quaver.pupil.R
|
||||||
import xyz.quaver.pupil.util.NOTIFICATION_ID_UPDATE
|
import xyz.quaver.pupil.util.Preferences
|
||||||
import xyz.quaver.pupil.util.cancelImport
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class BroadcastReciever : BroadcastReceiver() {
|
class UpdateBroadcastReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val ACTION_CANCEL_IMPORT = "ACTION_CANCEL_IMPORT"
|
|
||||||
|
|
||||||
const val EXTRA_IMPORT_NOTIFICATION_ID = "EXTRA_IMPORT_NOTIFICATION_ID"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
context ?: return
|
context ?: return
|
||||||
@@ -48,12 +41,10 @@ class BroadcastReciever : BroadcastReceiver() {
|
|||||||
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
|
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
|
||||||
|
|
||||||
// Validate download
|
// Validate download
|
||||||
|
val downloadID: Long = Preferences["update_download_id"]
|
||||||
val preference = PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
val downloadID = preference.getLong("update_download_id", -1)
|
|
||||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
|
|
||||||
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != downloadID)
|
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2) != downloadID)
|
||||||
return
|
return
|
||||||
|
|
||||||
// Get target uri
|
// Get target uri
|
||||||
@@ -62,19 +53,21 @@ class BroadcastReciever : BroadcastReceiver() {
|
|||||||
.setFilterById(downloadID)
|
.setFilterById(downloadID)
|
||||||
|
|
||||||
val uri = downloadManager.query(query).use { cursor ->
|
val uri = downloadManager.query(query).use { cursor ->
|
||||||
cursor.moveToFirst()
|
if (cursor.moveToFirst()) {
|
||||||
|
cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))?.let {
|
||||||
|
val uri = Uri.parse(it)
|
||||||
|
|
||||||
cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)).let {
|
when (uri.scheme) {
|
||||||
val uri = Uri.parse(it)
|
"file" ->
|
||||||
|
FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!)
|
||||||
when (uri.scheme) {
|
)
|
||||||
"file" ->
|
"content" -> uri
|
||||||
FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!))
|
else -> null
|
||||||
"content" -> uri
|
}
|
||||||
else -> return
|
|
||||||
}
|
}
|
||||||
}
|
} else
|
||||||
}
|
null
|
||||||
|
} ?: return
|
||||||
|
|
||||||
// Build Notification
|
// Build Notification
|
||||||
|
|
||||||
@@ -92,10 +85,7 @@ class BroadcastReciever : BroadcastReceiver() {
|
|||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
notificationManager.notify(NOTIFICATION_ID_UPDATE, notification)
|
notificationManager.notify(R.id.notification_id_update, notification)
|
||||||
}
|
|
||||||
ACTION_CANCEL_IMPORT -> {
|
|
||||||
cancelImport = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
436
app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.common.util.concurrent.RateLimiter
|
||||||
|
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.cleanCache
|
||||||
|
import xyz.quaver.pupil.util.downloader.Cache
|
||||||
|
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||||
|
import xyz.quaver.pupil.util.ellipsize
|
||||||
|
import xyz.quaver.pupil.util.normalizeID
|
||||||
|
import xyz.quaver.pupil.util.requestBuilders
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
).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")
|
||||||
|
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 (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 rateLimiter = RateLimiter.create(2.0)
|
||||||
|
private val rateLimitHost = Regex("..?\\.hitomi.la")
|
||||||
|
|
||||||
|
private val interceptor: PupilInterceptor = { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
|
||||||
|
if (rateLimitHost.matches(request.url().host()))
|
||||||
|
rateLimiter.acquire()
|
||||||
|
|
||||||
|
var response = chain.proceed(request)
|
||||||
|
var limit = 5
|
||||||
|
|
||||||
|
if (!response.isSuccessful && limit > 0) {
|
||||||
|
Thread.sleep(10000)
|
||||||
|
if (rateLimitHost.matches(request.url().host()))
|
||||||
|
rateLimiter.acquire()
|
||||||
|
response = chain.proceed(request)
|
||||||
|
limit -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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()
|
||||||
|
|
||||||
|
kotlin.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()
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
kotlin.runCatching {
|
||||||
|
Cache.getInstance(this@DownloadService, galleryID).putImage(index, "${index.toString().padStart(padding, '0')}.$ext", image)
|
||||||
|
}.onSuccess {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.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(30))
|
||||||
|
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.requestBuilders.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 {
|
||||||
|
startForeground(R.id.downloader_notification_id, serviceNotification.build())
|
||||||
|
|
||||||
|
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() {
|
||||||
|
startForeground(R.id.downloader_notification_id, serviceNotification.build())
|
||||||
|
interceptors[Tag::class] = interceptor
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
interceptors.remove(Tag::class)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -27,23 +27,24 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
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 kotlinx.android.synthetic.main.fragment_pin_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.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 lateinit var lockManager: LockManager
|
||||||
private var mode: String? = null
|
private var mode: String? = null
|
||||||
|
|
||||||
|
private lateinit var binding: LockActivityBinding
|
||||||
|
|
||||||
private val patternLockFragment = PatternLockFragment().apply {
|
private val patternLockFragment = PatternLockFragment().apply {
|
||||||
var lastPass = ""
|
var lastPass = ""
|
||||||
onPatternDrawn = {
|
onPatternDrawn = {
|
||||||
@@ -52,10 +53,11 @@ class LockActivity : AppCompatActivity() {
|
|||||||
val result = lockManager.check(it)
|
val result = lockManager.check(it)
|
||||||
|
|
||||||
if (result == true) {
|
if (result == true) {
|
||||||
|
lastUnlocked = System.currentTimeMillis()
|
||||||
setResult(Activity.RESULT_OK)
|
setResult(Activity.RESULT_OK)
|
||||||
finish()
|
finish()
|
||||||
} else
|
} else
|
||||||
lock_pattern_view.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
binding.patternLockView.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
||||||
}
|
}
|
||||||
"add_lock" -> {
|
"add_lock" -> {
|
||||||
if (lastPass.isEmpty()) {
|
if (lastPass.isEmpty()) {
|
||||||
@@ -67,7 +69,7 @@ class LockActivity : AppCompatActivity() {
|
|||||||
LockManager(context!!).add(Lock.generate(Lock.Type.PATTERN, it))
|
LockManager(context!!).add(Lock.generate(Lock.Type.PATTERN, it))
|
||||||
finish()
|
finish()
|
||||||
} else {
|
} else {
|
||||||
lock_pattern_view.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
binding.patternLockView.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
||||||
lastPass = ""
|
lastPass = ""
|
||||||
|
|
||||||
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
|
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
|
||||||
@@ -86,18 +88,19 @@ class LockActivity : AppCompatActivity() {
|
|||||||
val result = lockManager.check(it)
|
val result = lockManager.check(it)
|
||||||
|
|
||||||
if (result == true) {
|
if (result == true) {
|
||||||
|
lastUnlocked = System.currentTimeMillis()
|
||||||
setResult(Activity.RESULT_OK)
|
setResult(Activity.RESULT_OK)
|
||||||
finish()
|
finish()
|
||||||
} else {
|
} else {
|
||||||
indicator_dots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
|
binding.indicatorDots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
|
||||||
setAnimationListener(object: Animation.AnimationListener {
|
setAnimationListener(object: Animation.AnimationListener {
|
||||||
override fun onAnimationEnd(animation: Animation?) {
|
override fun onAnimationEnd(animation: Animation?) {
|
||||||
pin_lock_view.resetPinLockView()
|
binding.pinLockView.resetPinLockView()
|
||||||
pin_lock_view.isEnabled = true
|
binding.pinLockView.isEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAnimationStart(animation: Animation?) {
|
override fun onAnimationStart(animation: Animation?) {
|
||||||
pin_lock_view.isEnabled = false
|
binding.pinLockView.isEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAnimationRepeat(animation: Animation?) {
|
override fun onAnimationRepeat(animation: Animation?) {
|
||||||
@@ -111,22 +114,22 @@ class LockActivity : AppCompatActivity() {
|
|||||||
if (lastPass.isEmpty()) {
|
if (lastPass.isEmpty()) {
|
||||||
lastPass = it
|
lastPass = it
|
||||||
|
|
||||||
pin_lock_view.resetPinLockView()
|
binding.pinLockView.resetPinLockView()
|
||||||
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
|
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
|
||||||
} else {
|
} else {
|
||||||
if (lastPass == it) {
|
if (lastPass == it) {
|
||||||
LockManager(context!!).add(Lock.generate(Lock.Type.PIN, it))
|
LockManager(context!!).add(Lock.generate(Lock.Type.PIN, it))
|
||||||
finish()
|
finish()
|
||||||
} else {
|
} else {
|
||||||
indicator_dots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
|
binding.indicatorDots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
|
||||||
setAnimationListener(object: Animation.AnimationListener {
|
setAnimationListener(object: Animation.AnimationListener {
|
||||||
override fun onAnimationEnd(animation: Animation?) {
|
override fun onAnimationEnd(animation: Animation?) {
|
||||||
pin_lock_view.resetPinLockView()
|
binding.pinLockView.resetPinLockView()
|
||||||
pin_lock_view.isEnabled = true
|
binding.pinLockView.isEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAnimationStart(animation: Animation?) {
|
override fun onAnimationStart(animation: Animation?) {
|
||||||
pin_lock_view.isEnabled = false
|
binding.pinLockView.isEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAnimationRepeat(animation: Animation?) {
|
override fun onAnimationRepeat(animation: Animation?) {
|
||||||
@@ -157,6 +160,7 @@ class LockActivity : AppCompatActivity() {
|
|||||||
override fun onAuthenticationSucceeded(
|
override fun onAuthenticationSucceeded(
|
||||||
result: BiometricPrompt.AuthenticationResult) {
|
result: BiometricPrompt.AuthenticationResult) {
|
||||||
super.onAuthenticationSucceeded(result)
|
super.onAuthenticationSucceeded(result)
|
||||||
|
lastUnlocked = System.currentTimeMillis()
|
||||||
setResult(RESULT_OK)
|
setResult(RESULT_OK)
|
||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
@@ -169,7 +173,8 @@ class LockActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
lockManager = try {
|
lockManager = try {
|
||||||
LockManager(this)
|
LockManager(this)
|
||||||
@@ -185,6 +190,7 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mode = intent.getStringExtra("mode")
|
mode = intent.getStringExtra("mode")
|
||||||
|
val force = intent.getBooleanExtra("force", false)
|
||||||
|
|
||||||
when(mode) {
|
when(mode) {
|
||||||
null -> {
|
null -> {
|
||||||
@@ -194,11 +200,18 @@ class LockActivity : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (System.currentTimeMillis() - lastUnlocked < 5*60*1000 && !force) {
|
||||||
|
lastUnlocked = System.currentTimeMillis()
|
||||||
|
setResult(RESULT_OK)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
PreferenceManager.getDefaultSharedPreferences(this).getBoolean("lock_fingerprint", false)
|
Preferences["lock_fingerprint"]
|
||||||
&& BiometricManager.from(this).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS
|
&& BiometricManager.from(this).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
) {
|
) {
|
||||||
lock_fingerprint.apply {
|
binding.fingerprintBtn.apply {
|
||||||
isEnabled = true
|
isEnabled = true
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
showBiometricPrompt()
|
showBiometricPrompt()
|
||||||
@@ -207,7 +220,7 @@ class LockActivity : AppCompatActivity() {
|
|||||||
showBiometricPrompt()
|
showBiometricPrompt()
|
||||||
}
|
}
|
||||||
|
|
||||||
lock_pattern.apply {
|
binding.patternBtn.apply {
|
||||||
isEnabled = lockManager.contains(Lock.Type.PATTERN)
|
isEnabled = lockManager.contains(Lock.Type.PATTERN)
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
supportFragmentManager.beginTransaction().replace(
|
supportFragmentManager.beginTransaction().replace(
|
||||||
@@ -215,7 +228,7 @@ class LockActivity : AppCompatActivity() {
|
|||||||
).commit()
|
).commit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lock_pin.apply {
|
binding.pinBtn.apply {
|
||||||
isEnabled = lockManager.contains(Lock.Type.PIN)
|
isEnabled = lockManager.contains(Lock.Type.PIN)
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
supportFragmentManager.beginTransaction().replace(
|
supportFragmentManager.beginTransaction().replace(
|
||||||
@@ -223,7 +236,7 @@ class LockActivity : AppCompatActivity() {
|
|||||||
).commit()
|
).commit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lock_password.isEnabled = false
|
binding.passwordBtn.isEnabled = false
|
||||||
|
|
||||||
when (lockManager.locks!!.first().type) {
|
when (lockManager.locks!!.first().type) {
|
||||||
Lock.Type.PIN -> {
|
Lock.Type.PIN -> {
|
||||||
@@ -241,20 +254,20 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"add_lock" -> {
|
"add_lock" -> {
|
||||||
lock_pattern.isEnabled = false
|
binding.patternBtn.isEnabled = false
|
||||||
lock_pin.isEnabled = false
|
binding.pinBtn.isEnabled = false
|
||||||
lock_fingerprint.isEnabled = false
|
binding.fingerprintBtn.isEnabled = false
|
||||||
lock_password.isEnabled = false
|
binding.passwordBtn.isEnabled = false
|
||||||
|
|
||||||
when(intent.getStringExtra("type")!!) {
|
when(intent.getStringExtra("type")!!) {
|
||||||
"pattern" -> {
|
"pattern" -> {
|
||||||
lock_pattern.isEnabled = true
|
binding.patternBtn.isEnabled = true
|
||||||
supportFragmentManager.beginTransaction().add(
|
supportFragmentManager.beginTransaction().add(
|
||||||
R.id.lock_content, patternLockFragment
|
R.id.lock_content, patternLockFragment
|
||||||
).commit()
|
).commit()
|
||||||
}
|
}
|
||||||
"pin" -> {
|
"pin" -> {
|
||||||
lock_pin.isEnabled = true
|
binding.pinBtn.isEnabled = true
|
||||||
supportFragmentManager.beginTransaction().add(
|
supportFragmentManager.beginTransaction().add(
|
||||||
R.id.lock_content, pinLockFragment
|
R.id.lock_content, pinLockFragment
|
||||||
).commit()
|
).commit()
|
||||||
|
|||||||
@@ -18,41 +18,51 @@
|
|||||||
|
|
||||||
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.widget.Toast
|
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.bumptech.glide.Glide
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
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.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import xyz.quaver.Code
|
|
||||||
import xyz.quaver.pupil.Pupil
|
|
||||||
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.closeCamera
|
||||||
|
import xyz.quaver.pupil.util.downloader.Cache
|
||||||
|
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||||
|
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
|
||||||
@@ -62,38 +72,61 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
downloader = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val timer = Timer()
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
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)
|
||||||
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", galleryID)
|
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", galleryID)
|
||||||
|
|
||||||
if (galleryID == 0) {
|
if (galleryID == 0) {
|
||||||
@@ -101,37 +134,8 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initDownloadListener()
|
||||||
initView()
|
initView()
|
||||||
if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean("cache_disable", false)) {
|
|
||||||
reader_download_progressbar.visibility = View.GONE
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val reader = Cache(this@ReaderActivity).getReader(galleryID)
|
|
||||||
|
|
||||||
launch(Dispatchers.Main) initDownloader@{
|
|
||||||
if (reader == null) {
|
|
||||||
Snackbar
|
|
||||||
.make(reader_layout, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE)
|
|
||||||
.show()
|
|
||||||
return@initDownloader
|
|
||||||
}
|
|
||||||
|
|
||||||
(reader_recyclerview.adapter as ReaderAdapter).apply {
|
|
||||||
this.reader = reader
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
title = reader.galleryInfo.title ?: ""
|
|
||||||
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${reader.galleryInfo.files.size}"
|
|
||||||
|
|
||||||
menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity,
|
|
||||||
when (reader.code) {
|
|
||||||
Code.HITOMI -> R.drawable.hitomi
|
|
||||||
Code.HIYOBI -> R.drawable.ic_hiyobi
|
|
||||||
else -> android.R.color.transparent
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
initDownloader()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
@@ -157,19 +161,6 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||||
menuInflater.inflate(R.menu.reader, menu)
|
menuInflater.inflate(R.menu.reader, menu)
|
||||||
|
|
||||||
@@ -184,20 +175,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, reader_layout, false)
|
// TODO: Switch to DialogFragment
|
||||||
with(view.dialog_number_picker) {
|
val binding = NumberpickerDialogBinding.inflate(layoutInflater, binding.root, false)
|
||||||
minValue=1
|
|
||||||
maxValue=Cache(context).getReaderOrNull(galleryID)?.galleryInfo?.files?.size ?: 0
|
with(binding.numberPicker) {
|
||||||
value=currentPage
|
minValue = 1
|
||||||
|
maxValue = cache.metadata.galleryInfo?.files?.size ?: 0
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,14 +213,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() {
|
||||||
@@ -249,12 +260,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
|
||||||
}
|
}
|
||||||
@@ -262,51 +273,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(1000, 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
|
||||||
Snackbar
|
|
||||||
.make(reader_layout, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
runOnUiThread {
|
if (downloader.progress[galleryID]?.isEmpty() == true) { //Gallery not found
|
||||||
reader_download_progressbar.max = reader_recyclerview.adapter?.itemCount ?: 0
|
update = false
|
||||||
reader_download_progressbar.progress = worker.progress[galleryID]?.count { !it.isFinite() } ?: 0
|
Snackbar
|
||||||
reader_progressbar.max = reader_recyclerview.adapter?.itemCount ?: 0
|
.make(binding.root, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE)
|
||||||
|
.show()
|
||||||
|
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.downloadProgressbar.max = binding.recyclerview.adapter?.itemCount ?: 0
|
||||||
|
binding.downloadProgressbar.progress =
|
||||||
|
downloader.progress[galleryID]?.count { it.isInfinite() } ?: 0
|
||||||
|
|
||||||
if (title == getString(R.string.reader_loading)) {
|
if (title == getString(R.string.reader_loading)) {
|
||||||
val reader = Cache(this@ReaderActivity).getReaderOrNull(galleryID)
|
val galleryInfo = cache.metadata.galleryInfo
|
||||||
|
|
||||||
if (reader != null) {
|
if (galleryInfo != null) {
|
||||||
|
with(binding.recyclerview.adapter as ReaderAdapter) {
|
||||||
with (reader_recyclerview.adapter as ReaderAdapter) {
|
this.galleryInfo = galleryInfo
|
||||||
this.reader = reader
|
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
title = reader.galleryInfo.title
|
title = galleryInfo.title
|
||||||
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${reader.galleryInfo.files.size}"
|
menu?.findItem(R.id.reader_menu_page_indicator)?.title =
|
||||||
|
"$currentPage/${galleryInfo.files.size}"
|
||||||
|
|
||||||
menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity,
|
menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.hitomi)
|
||||||
when (reader.code) {
|
|
||||||
Code.HITOMI -> R.drawable.hitomi
|
|
||||||
Code.HIYOBI -> R.drawable.ic_hiyobi
|
|
||||||
else -> android.R.color.transparent
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -315,8 +324,8 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun initView() {
|
private fun initView() {
|
||||||
with(reader_recyclerview) {
|
with(binding.recyclerview) {
|
||||||
adapter = ReaderAdapter(Glide.with(this@ReaderActivity), galleryID).apply {
|
adapter = ReaderAdapter(this@ReaderActivity, galleryID).apply {
|
||||||
onItemClickListener = {
|
onItemClickListener = {
|
||||||
if (isScroll) {
|
if (isScroll) {
|
||||||
isScroll = false
|
isScroll = false
|
||||||
@@ -325,7 +334,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,9 +344,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
|
||||||
|
|
||||||
@@ -345,47 +354,61 @@ 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 (PreferenceManager.getDefaultSharedPreferences(context).getBoolean("cache_disable", false))
|
val downloadManager = DownloadManager.getInstance(this@ReaderActivity)
|
||||||
Toast.makeText(context, R.string.settings_download_when_cache_disable_warning, Toast.LENGTH_SHORT).show()
|
|
||||||
else {
|
|
||||||
if (Cache(context).isDownloading(galleryID)) {
|
|
||||||
Cache(context).setDownloading(galleryID, false)
|
|
||||||
|
|
||||||
animateDownloadFAB(false)
|
if (downloadManager.isDownloading(galleryID)) {
|
||||||
} else {
|
downloadManager.deleteDownloadFolder(galleryID)
|
||||||
Cache(context).setDownloading(galleryID, true)
|
animateDownloadFAB(false)
|
||||||
animateDownloadFAB(true)
|
} else {
|
||||||
}
|
downloadManager.addDownloadFolder(galleryID)
|
||||||
|
DownloadService.download(context, galleryID, true)
|
||||||
|
animateDownloadFAB(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with(reader_fab_retry) {
|
with(binding.retryFab) {
|
||||||
setImageResource(R.drawable.refresh)
|
setImageResource(R.drawable.refresh)
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
DownloadWorker.getInstance(context).let {
|
DownloadService.download(context, galleryID)
|
||||||
it.cancel(galleryID)
|
}
|
||||||
it.queue.add(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(reader_fab_fullscreen) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,40 +418,55 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
reader_recyclerview.adapter = reader_recyclerview.adapter // Force to redraw
|
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_cancel)
|
labelText = getString(R.string.reader_fab_download_cancel)
|
||||||
@@ -449,4 +487,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,38 +18,15 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.ui
|
package xyz.quaver.pupil.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
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.preference.PreferenceManager
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import kotlinx.android.synthetic.main.settings_activity.*
|
|
||||||
import kotlinx.serialization.builtins.list
|
|
||||||
import kotlinx.serialization.builtins.serializer
|
|
||||||
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.LockSettingsFragment
|
|
||||||
import xyz.quaver.pupil.ui.fragment.SettingsFragment
|
import xyz.quaver.pupil.ui.fragment.SettingsFragment
|
||||||
import xyz.quaver.pupil.util.*
|
|
||||||
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()
|
||||||
@@ -58,157 +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
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
|
||||||
when(requestCode) {
|
|
||||||
REQUEST_LOCK -> {
|
|
||||||
if (resultCode == Activity.RESULT_OK) {
|
|
||||||
supportFragmentManager
|
|
||||||
.beginTransaction()
|
|
||||||
.replace(R.id.settings, LockSettingsFragment())
|
|
||||||
.addToBackStack("Lock")
|
|
||||||
.commitAllowingStateLoss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
REQUEST_RESTORE -> {
|
|
||||||
if (resultCode == Activity.RESULT_OK) {
|
|
||||||
val uri = data?.data ?: return
|
|
||||||
|
|
||||||
try {
|
|
||||||
val str = contentResolver.openInputStream(uri).use { inputStream ->
|
|
||||||
inputStream!!
|
|
||||||
|
|
||||||
inputStream.readBytes().toString(Charset.defaultCharset())
|
|
||||||
}
|
|
||||||
|
|
||||||
(application as Pupil).favorites.addAll(json.parse(Int.serializer().list, str).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)
|
|
||||||
|
|
||||||
val file = uri.toFile(this)
|
|
||||||
|
|
||||||
if (file?.canWrite() != true)
|
|
||||||
Snackbar.make(
|
|
||||||
settings,
|
|
||||||
R.string.settings_dl_location_not_writable,
|
|
||||||
Snackbar.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
else
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(this).edit()
|
|
||||||
.putString("dl_location", file.canonicalPath)
|
|
||||||
.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", File(directory).canonicalPath)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
REQUEST_IMPORT_OLD_GALLERIES -> {
|
|
||||||
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)
|
|
||||||
|
|
||||||
val file = uri.toFile(this)
|
|
||||||
|
|
||||||
if (file?.canRead() != true)
|
|
||||||
Snackbar.make(
|
|
||||||
settings,
|
|
||||||
resources.getText(R.string.import_old_galleries_folder_not_readable),
|
|
||||||
Snackbar.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
else
|
|
||||||
importOldGalleries(this, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
REQUEST_IMPORT_OLD_GALLERIES_OLD -> {
|
|
||||||
if (resultCode == DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED) {
|
|
||||||
val directory = data?.getStringExtra(DirectoryChooserActivity.RESULT_SELECTED_DIR)!!
|
|
||||||
|
|
||||||
if (!File(directory).canRead())
|
|
||||||
Snackbar.make(
|
|
||||||
settings,
|
|
||||||
resources.getText(R.string.import_old_galleries_folder_not_readable),
|
|
||||||
Snackbar.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
else {
|
|
||||||
importOldGalleries(this, File(directory))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> super.onActivityResult(requestCode, resultCode, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
|
||||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
|
||||||
when (requestCode) {
|
|
||||||
REQUEST_WRITE_PERMISSION_AND_SAF -> {
|
|
||||||
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
|
||||||
putExtra("android.content.extra.SHOW_ADVANCED", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
startActivityForResult(intent, REQUEST_DOWNLOAD_FOLDER)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -18,30 +18,30 @@
|
|||||||
|
|
||||||
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 {
|
||||||
it.split("|").let { split ->
|
requireContext().resources.getStringArray(R.array.languages).map {
|
||||||
Pair(split[0], split[1])
|
it.split("|").let { split ->
|
||||||
}
|
Pair(split[0], split[1])
|
||||||
}.toMap()
|
}
|
||||||
private val reverseLanguages = languages.entries.associate { (k, v) -> v to k }
|
}.toMap()
|
||||||
|
}
|
||||||
|
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")
|
||||||
@@ -49,47 +49,15 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
|
|||||||
|
|
||||||
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!!
|
||||||
setTitle(R.string.default_query_dialog_title)
|
|
||||||
setView(build())
|
|
||||||
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) {
|
private fun initView() {
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (default_query_dialog_loli_checkbox.isChecked)
|
|
||||||
excludeLoli.forEach { tag ->
|
|
||||||
newTags.add(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
onPositiveButtonClickListener?.invoke(newTags)
|
|
||||||
}
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
private fun build() : View {
|
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
val tags = Tags.parse(
|
val tags = Tags.parse(
|
||||||
preferences.getString("default_query", "") ?: ""
|
Preferences["default_query"]
|
||||||
)
|
)
|
||||||
|
|
||||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_default_query, null)
|
with(binding.languageSelector) {
|
||||||
|
|
||||||
with(view.default_query_dialog_language_selector) {
|
|
||||||
adapter =
|
adapter =
|
||||||
ArrayAdapter(
|
ArrayAdapter(
|
||||||
context,
|
context,
|
||||||
@@ -112,13 +80,13 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with(view.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(view.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 {
|
||||||
@@ -126,7 +94,7 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with(view.default_query_dialog_loli_checkbox) {
|
with(binding.loliCheckbox) {
|
||||||
isChecked = excludeLoli.all { tags.contains(it) }
|
isChecked = excludeLoli.all { tags.contains(it) }
|
||||||
if (excludeLoli.all { tags.contains(it) })
|
if (excludeLoli.all { tags.contains(it) })
|
||||||
excludeLoli.forEach {
|
excludeLoli.forEach {
|
||||||
@@ -134,7 +102,7 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with(view.default_query_dialog_edittext) {
|
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(
|
||||||
@@ -159,8 +127,45 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return view
|
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,82 @@
|
|||||||
|
/*
|
||||||
|
* 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.runBlocking
|
||||||
|
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()) }
|
||||||
|
val galleryBlock = runBlocking {
|
||||||
|
Cache.getInstance(requireContext(), galleryID).getGalleryBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.message.text = getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolder() ?: "")
|
||||||
|
binding.edittext.setText(Preferences["download_folder_name", "[-id-] -title-"])
|
||||||
|
binding.edittext.addTextChangedListener {
|
||||||
|
binding.message.text = requireContext().getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolderTest(it.toString()) ?: "")
|
||||||
|
}
|
||||||
|
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,138 +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.Manifest
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Activity
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.RadioButton
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
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.*
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
class DownloadLocationDialog(val activity: Activity) : AlertDialog(activity) {
|
|
||||||
|
|
||||||
private val preference = PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
private val buttons = mutableListOf<Pair<RadioButton, File?>>()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
setTitle(R.string.settings_dl_location)
|
|
||||||
|
|
||||||
setView(build())
|
|
||||||
|
|
||||||
setButton(Dialog.BUTTON_POSITIVE, context.getText(android.R.string.ok)) { _, _ -> }
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun build() : View {
|
|
||||||
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", dir.canonicalPath).apply()
|
|
||||||
}
|
|
||||||
buttons.add(button to 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) {
|
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)
|
|
||||||
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_WRITE_PERMISSION_AND_SAF)
|
|
||||||
else {
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
externalFilesDirs.indexOfFirst {
|
|
||||||
it.canonicalPath == getDownloadDirectory(context).canonicalPath
|
|
||||||
}.let { index ->
|
|
||||||
if (index < 0)
|
|
||||||
buttons.first().first.isChecked = true
|
|
||||||
else
|
|
||||||
buttons[index].first.isChecked = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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,54 +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.RequestManager
|
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 glide: RequestManager, 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()
|
|
||||||
|
|
||||||
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
|
||||||
@@ -74,13 +69,12 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
|
|||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +82,12 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
|
|||||||
try {
|
try {
|
||||||
val gallery = getGallery(galleryID)
|
val gallery = getGallery(galleryID)
|
||||||
|
|
||||||
gallery_cover.post {
|
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 {
|
||||||
@@ -110,28 +104,26 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
glide
|
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,
|
||||||
@@ -147,7 +139,18 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
|
|||||||
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
|
||||||
@@ -157,35 +160,14 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
).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.gender_male)
|
|
||||||
}
|
|
||||||
"female" -> {
|
|
||||||
setChipBackgroundColorResource(R.color.material_pink_600)
|
|
||||||
setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
|
||||||
ContextCompat.getDrawable(context, R.drawable.gender_female)
|
|
||||||
}
|
|
||||||
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)
|
||||||
@@ -194,37 +176,36 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.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(glide, 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)
|
||||||
@@ -232,39 +213,21 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
glide,
|
|
||||||
galleries[position].id
|
|
||||||
).apply {
|
|
||||||
onChipClickedHandler.add { tag ->
|
onChipClickedHandler.add { tag ->
|
||||||
this@GalleryDialog.onChipClickedHandler.forEach { it.invoke(tag) }
|
this@GalleryDialog.onChipClickedHandler.forEach { it.invoke(tag) }
|
||||||
}
|
}
|
||||||
@@ -272,11 +235,20 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
|
|||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}.let {
|
}
|
||||||
gallery_details_contents.addView(it, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
})
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
gallery.related.forEach { galleryID ->
|
||||||
|
Cache.getInstance(context, galleryID).getGalleryBlock()?.let {
|
||||||
|
galleries.add(galleryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.let {
|
|
||||||
gallery_contents.addView(it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +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 android.view.View
|
|
||||||
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) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
setTitle(R.string.settings_mirror_title)
|
|
||||||
setView(build())
|
|
||||||
setButton(Dialog.BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ -> }
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun build() : View {
|
|
||||||
return 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -18,58 +18,60 @@
|
|||||||
|
|
||||||
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.content.Context
|
||||||
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.AdapterView
|
import android.widget.AdapterView
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.appcompat.app.AlertDialog
|
||||||
import kotlinx.android.synthetic.main.dialog_proxy.view.*
|
import androidx.fragment.app.DialogFragment
|
||||||
import xyz.quaver.proxy
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
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.databinding.ProxyDialogBinding
|
||||||
|
import xyz.quaver.pupil.util.Preferences
|
||||||
import xyz.quaver.pupil.util.ProxyInfo
|
import xyz.quaver.pupil.util.ProxyInfo
|
||||||
import xyz.quaver.pupil.util.getProxyInfo
|
import xyz.quaver.pupil.util.getProxyInfo
|
||||||
import xyz.quaver.pupil.util.json
|
import xyz.quaver.pupil.util.proxyInfo
|
||||||
import java.net.Proxy
|
import java.net.Proxy
|
||||||
|
|
||||||
class ProxyDialog(context: Context) : Dialog(context) {
|
class ProxyDialogFragment : DialogFragment() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
private var _binding: ProxyDialogBinding? = null
|
||||||
val view = build()
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
setTitle(R.string.settings_proxy_title)
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
setContentView(view)
|
_binding = ProxyDialogBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
window?.attributes?.width = ViewGroup.LayoutParams.MATCH_PARENT
|
initView()
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
return AlertDialog.Builder(requireContext()).apply {
|
||||||
|
setView(binding.root)
|
||||||
|
}.create()
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
private fun initView() {
|
||||||
private fun build() : View {
|
val proxyInfo = getProxyInfo()
|
||||||
val proxyInfo = getProxyInfo(context)
|
|
||||||
|
|
||||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_proxy, null)
|
|
||||||
|
|
||||||
val enabler = { enable: Boolean ->
|
val enabler = { enable: Boolean ->
|
||||||
view?.proxy_addr?.isEnabled = enable
|
binding.addr.isEnabled = enable
|
||||||
view?.proxy_port?.isEnabled = enable
|
binding.port.isEnabled = enable
|
||||||
view?.proxy_username?.isEnabled = enable
|
binding.username.isEnabled = enable
|
||||||
view?.proxy_password?.isEnabled = enable
|
binding.password.isEnabled = enable
|
||||||
|
|
||||||
if (!enable) {
|
if (!enable) {
|
||||||
view?.proxy_addr?.text = null
|
binding.addr.text = null
|
||||||
view?.proxy_port?.text = null
|
binding.port.text = null
|
||||||
view?.proxy_username?.text = null
|
binding.username.text = null
|
||||||
view?.proxy_password?.text = null
|
binding.password.text = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with(view.proxy_type_selector) {
|
with(binding.typeSelector) {
|
||||||
adapter = ArrayAdapter(
|
adapter = ArrayAdapter(
|
||||||
context,
|
context,
|
||||||
android.R.layout.simple_spinner_dropdown_item,
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
@@ -87,47 +89,45 @@ class ProxyDialog(context: Context) : Dialog(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
view.proxy_addr.setText(proxyInfo.host)
|
binding.addr.setText(proxyInfo.host)
|
||||||
view.proxy_port.setText(proxyInfo.port?.toString())
|
binding.port.setText(proxyInfo.port?.toString())
|
||||||
view.proxy_username.setText(proxyInfo.username)
|
binding.username.setText(proxyInfo.username)
|
||||||
view.proxy_password.setText(proxyInfo.password)
|
binding.password.setText(proxyInfo.password)
|
||||||
|
|
||||||
enabler.invoke(proxyInfo.type != Proxy.Type.DIRECT)
|
enabler.invoke(proxyInfo.type != Proxy.Type.DIRECT)
|
||||||
|
|
||||||
view.proxy_cancel.setOnClickListener {
|
binding.cancelButton.setOnClickListener {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
view.proxy_ok.setOnClickListener {
|
binding.okButton.setOnClickListener {
|
||||||
val type = Proxy.Type.values()[view.proxy_type_selector.selectedItemPosition]
|
val type = Proxy.Type.values()[binding.typeSelector.selectedItemPosition]
|
||||||
val addr = view.proxy_addr.text?.toString()
|
val addr = binding.addr.text?.toString()
|
||||||
val port = view.proxy_port.text?.toString()?.toIntOrNull()
|
val port = binding.port.text?.toString()?.toIntOrNull()
|
||||||
val username = view.proxy_username.text?.toString()
|
val username = binding.username.text?.toString()
|
||||||
val password = view.proxy_password.text?.toString()
|
val password = binding.password.text?.toString()
|
||||||
|
|
||||||
if (type != Proxy.Type.DIRECT) {
|
if (type != Proxy.Type.DIRECT) {
|
||||||
if (addr == null || addr.isEmpty())
|
if (addr == null || addr.isEmpty())
|
||||||
view.proxy_addr.error = context.getText(R.string.proxy_dialog_error)
|
binding.addr.error = requireContext().getText(R.string.proxy_dialog_error)
|
||||||
if (port == null)
|
if (port == null)
|
||||||
view.proxy_port.error = context.getText(R.string.proxy_dialog_error)
|
binding.port.error = requireContext().getText(R.string.proxy_dialog_error)
|
||||||
|
|
||||||
if (addr == null || addr.isEmpty() || port == null)
|
if (addr == null || addr.isEmpty() || port == null)
|
||||||
return@setOnClickListener
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
|
|
||||||
ProxyInfo(type, addr, port, username, password).let {
|
ProxyInfo(type, addr, port, username, password).let {
|
||||||
|
Preferences["proxy"] = Json.encodeToString(it)
|
||||||
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(context).edit().putString("proxy",
|
clientBuilder
|
||||||
json.stringify(ProxyInfo.serializer(), it)
|
.proxyInfo(it)
|
||||||
).apply()
|
clientHolder = null
|
||||||
|
client
|
||||||
proxy = it.proxy()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
return view
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -24,15 +24,14 @@ import android.widget.Toast
|
|||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import androidx.preference.SwitchPreferenceCompat
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
import xyz.quaver.pupil.R
|
import xyz.quaver.pupil.R
|
||||||
import xyz.quaver.pupil.ui.LockActivity
|
import xyz.quaver.pupil.ui.LockActivity
|
||||||
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
|
||||||
|
|
||||||
class LockSettingsFragment :
|
class LockSettingsFragment : PreferenceFragmentCompat() {
|
||||||
PreferenceFragmentCompat() {
|
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
@@ -54,7 +53,7 @@ class LockSettingsFragment :
|
|||||||
if (lockManager.isEmpty()) {
|
if (lockManager.isEmpty()) {
|
||||||
(findPreference<Preference>("lock_fingerprint") as SwitchPreferenceCompat).isChecked = false
|
(findPreference<Preference>("lock_fingerprint") as SwitchPreferenceCompat).isChecked = false
|
||||||
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean("lock_fingerprint", false).apply()
|
Preferences["lock_fingerprint"] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +74,11 @@ class LockSettingsFragment :
|
|||||||
setTitle(R.string.warning)
|
setTitle(R.string.warning)
|
||||||
setMessage(R.string.settings_lock_remove_message)
|
setMessage(R.string.settings_lock_remove_message)
|
||||||
|
|
||||||
setPositiveButton(android.R.string.yes) { _, _ ->
|
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
lockManager.remove(Lock.Type.PATTERN)
|
lockManager.remove(Lock.Type.PATTERN)
|
||||||
onResume()
|
onResume()
|
||||||
}
|
}
|
||||||
setNegativeButton(android.R.string.no) { _, _ -> }
|
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||||
}.show()
|
}.show()
|
||||||
} else {
|
} else {
|
||||||
val intent = Intent(requireContext(), LockActivity::class.java).apply {
|
val intent = Intent(requireContext(), LockActivity::class.java).apply {
|
||||||
@@ -108,11 +107,11 @@ class LockSettingsFragment :
|
|||||||
setTitle(R.string.warning)
|
setTitle(R.string.warning)
|
||||||
setMessage(R.string.settings_lock_remove_message)
|
setMessage(R.string.settings_lock_remove_message)
|
||||||
|
|
||||||
setPositiveButton(android.R.string.yes) { _, _ ->
|
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
lockManager.remove(Lock.Type.PIN)
|
lockManager.remove(Lock.Type.PIN)
|
||||||
onResume()
|
onResume()
|
||||||
}
|
}
|
||||||
setNegativeButton(android.R.string.no) { _, _ -> }
|
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||||
}.show()
|
}.show()
|
||||||
} else {
|
} else {
|
||||||
val intent = Intent(requireContext(), LockActivity::class.java).apply {
|
val intent = Intent(requireContext(), LockActivity::class.java).apply {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* 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.EditText
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import okhttp3.*
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.client
|
||||||
|
import xyz.quaver.pupil.favorites
|
||||||
|
import xyz.quaver.pupil.util.restore
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class ManageFavoritesFragment : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
|
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 request = Request.Builder()
|
||||||
|
.url(context.getString(R.string.backup_url))
|
||||||
|
.post(
|
||||||
|
FormBody.Builder()
|
||||||
|
.add("f:1", File(ContextCompat.getDataDir(context), "favorites.json").readText())
|
||||||
|
.build()
|
||||||
|
).build()
|
||||||
|
|
||||||
|
client.newCall(request).enqueue(object: Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
val view = view ?: return
|
||||||
|
Snackbar.make(view, R.string.settings_backup_failed, Snackbar.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
if (response.code() != 200) {
|
||||||
|
response.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/plain"
|
||||||
|
putExtra(Intent.EXTRA_TEXT, response.body()?.use { it.string() }?.replace("\n", ""))
|
||||||
|
}.let {
|
||||||
|
getContext()?.startActivity(Intent.createChooser(it, getString(R.string.settings_backup_share)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
findPreference<Preference>("restore")?.setOnPreferenceClickListener {
|
||||||
|
val editText = EditText(context).apply {
|
||||||
|
setText(context.getString(R.string.backup_url), TextView.BufferType.EDITABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog.Builder(context)
|
||||||
|
.setTitle(R.string.settings_restore_title)
|
||||||
|
.setView(editText)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
restore(editText.text.toString(),
|
||||||
|
onFailure = onFailure@{
|
||||||
|
val view = view ?: return@onFailure
|
||||||
|
Snackbar.make(view, R.string.settings_restore_failed, Snackbar.LENGTH_LONG).show()
|
||||||
|
}, onSuccess = onSuccess@{
|
||||||
|
val view = view ?: return@onSuccess
|
||||||
|
Snackbar.make(view, context.getString(R.string.settings_restore_success, it.size), Snackbar.LENGTH_LONG).show()
|
||||||
|
})
|
||||||
|
}.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||||
|
// Do Nothing
|
||||||
|
}.show()
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/*
|
||||||
|
* 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 androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import xyz.quaver.io.FileX
|
||||||
|
import xyz.quaver.io.util.deleteRecursively
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.histories
|
||||||
|
import xyz.quaver.pupil.util.byteToString
|
||||||
|
import xyz.quaver.pupil.util.downloader.Cache
|
||||||
|
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
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) {
|
||||||
|
this ?: return false
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
job?.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -24,30 +24,34 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.andrognito.pinlockview.PinLockListener
|
import com.andrognito.pinlockview.PinLockListener
|
||||||
import kotlinx.android.synthetic.main.fragment_pin_lock.view.*
|
import xyz.quaver.pupil.databinding.PinLockFragmentBinding
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
|
|
||||||
class PINLockFragment : Fragment(), PinLockListener {
|
class PINLockFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: PinLockFragmentBinding? = null
|
||||||
|
val binding get() = _binding!!
|
||||||
|
|
||||||
var onPINEntered: ((String) -> Unit)? = null
|
var onPINEntered: ((String) -> Unit)? = null
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
return inflater.inflate(R.layout.fragment_pin_lock, container, false).apply {
|
_binding = PinLockFragmentBinding.inflate(inflater, container, false)
|
||||||
pin_lock_view.attachIndicatorDots(indicator_dots)
|
|
||||||
pin_lock_view.setPinLockListener(this@PINLockFragment)
|
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 onComplete(pin: String?) {
|
override fun onDestroy() {
|
||||||
onPINEntered?.invoke(pin!!)
|
super.onDestroy()
|
||||||
}
|
_binding = null
|
||||||
|
|
||||||
override fun onEmpty() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPinChange(pinLength: Int, intermediatePin: String?) {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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 onComplete(pattern: MutableList<PatternLockView.Dot>?) {
|
||||||
|
val password = PatternLockUtils.patternToMD5(binding.patternLockView, pattern)
|
||||||
|
onPatternDrawn?.invoke(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {}
|
||||||
|
override fun onProgress(progressPattern: MutableList<PatternLockView.Dot>?) {}
|
||||||
|
override fun onStarted() {}
|
||||||
|
})
|
||||||
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
}
|
_binding = null
|
||||||
|
|
||||||
override fun onComplete(pattern: MutableList<PatternLockView.Dot>?) {
|
|
||||||
val password = PatternLockUtils.patternToMD5(lock_pattern_view, pattern)
|
|
||||||
onPatternDrawn?.invoke(password)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onProgress(progressPattern: MutableList<PatternLockView.Dot>?) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStarted() {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,34 +18,31 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.ui.fragment
|
package xyz.quaver.pupil.ui.fragment
|
||||||
|
|
||||||
import android.Manifest
|
import android.app.Activity
|
||||||
import android.content.*
|
import android.content.*
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.preference.*
|
||||||
import androidx.core.content.ContextCompat
|
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
|
||||||
import androidx.preference.Preference
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import androidx.preference.PreferenceCategory
|
import kotlinx.coroutines.Dispatchers
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import kotlinx.coroutines.launch
|
||||||
import androidx.preference.PreferenceManager
|
import okhttp3.Dispatcher
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import xyz.quaver.io.FileX
|
||||||
import net.rdrei.android.dirchooser.DirectoryChooserActivity
|
import xyz.quaver.io.util.getChild
|
||||||
import net.rdrei.android.dirchooser.DirectoryChooserConfig
|
|
||||||
import xyz.quaver.pupil.Pupil
|
|
||||||
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.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.dialog.*
|
||||||
import xyz.quaver.pupil.ui.dialog.DownloadLocationDialog
|
|
||||||
import xyz.quaver.pupil.ui.dialog.MirrorDialog
|
|
||||||
import xyz.quaver.pupil.ui.dialog.ProxyDialog
|
|
||||||
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(),
|
||||||
@@ -53,7 +50,15 @@ class SettingsFragment :
|
|||||||
Preference.OnPreferenceChangeListener,
|
Preference.OnPreferenceChangeListener,
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
|
||||||
lateinit var sharedPreference: SharedPreferences
|
private val lockLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
|
if (it.resultCode == Activity.RESULT_OK) {
|
||||||
|
parentFragmentManager
|
||||||
|
.beginTransaction()
|
||||||
|
.replace(R.id.settings, LockSettingsFragment())
|
||||||
|
.addToBackStack("Lock")
|
||||||
|
.commitAllowingStateLoss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
@@ -73,12 +78,6 @@ class SettingsFragment :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDirSize(dir: File) : String {
|
|
||||||
val size = dir.walk().map { it.length() }.sum()
|
|
||||||
|
|
||||||
return getString(R.string.settings_clear_summary, byteToString(size))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPreferenceClick(preference: Preference?): Boolean {
|
override fun onPreferenceClick(preference: Preference?): Boolean {
|
||||||
with (preference) {
|
with (preference) {
|
||||||
this ?: return false
|
this ?: return false
|
||||||
@@ -87,122 +86,31 @@ class SettingsFragment :
|
|||||||
"app_version" -> {
|
"app_version" -> {
|
||||||
checkUpdate(activity as SettingsActivity, true)
|
checkUpdate(activity as SettingsActivity, true)
|
||||||
}
|
}
|
||||||
"delete_cache" -> {
|
"download_folder" -> {
|
||||||
val dir = File(requireContext().cacheDir, "imageCache")
|
DownloadLocationDialogFragment().show(parentFragmentManager, "Download Location Dialog")
|
||||||
|
|
||||||
AlertDialog.Builder(requireContext()).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(requireContext())
|
|
||||||
|
|
||||||
AlertDialog.Builder(requireContext()).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 = (requireContext().applicationContext as Pupil).histories
|
|
||||||
|
|
||||||
AlertDialog.Builder(requireContext()).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(requireActivity()).show()
|
|
||||||
}
|
}
|
||||||
"default_query" -> {
|
"default_query" -> {
|
||||||
DefaultQueryDialog(requireContext()).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(requireContext(), LockActivity::class.java)
|
val intent = Intent(requireContext(), LockActivity::class.java).apply {
|
||||||
activity?.startActivityForResult(intent, REQUEST_LOCK)
|
putExtra("force", true)
|
||||||
}
|
}
|
||||||
"mirrors" -> {
|
lockLauncher.launch(intent)
|
||||||
MirrorDialog(requireContext())
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
"proxy" -> {
|
"proxy" -> {
|
||||||
ProxyDialog(requireContext())
|
ProxyDialogFragment().show(parentFragmentManager, "Proxy Dialog")
|
||||||
.show()
|
|
||||||
}
|
|
||||||
"nomedia" -> {
|
|
||||||
File(getDownloadDirectory(context), ".nomedia").createNewFile()
|
|
||||||
}
|
|
||||||
"backup" -> {
|
|
||||||
File(ContextCompat.getDataDir(requireContext()), "favorites.json").copyTo(
|
|
||||||
File(getDownloadDirectory(requireContext()), "favorites.json"),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
|
|
||||||
Snackbar.make(this@SettingsFragment.listView, R.string.settings_backup_snackbar, Snackbar.LENGTH_LONG)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
"restore" -> {
|
|
||||||
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
type = "*/*"
|
|
||||||
}
|
|
||||||
|
|
||||||
activity?.startActivityForResult(intent, REQUEST_RESTORE)
|
|
||||||
}
|
|
||||||
"old_import_galleries" -> {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)
|
|
||||||
ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_WRITE_PERMISSION_AND_SAF)
|
|
||||||
else {
|
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
|
||||||
putExtra("android.content.extra.SHOW_ADVANCED", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
activity?.startActivityForResult(intent, REQUEST_IMPORT_OLD_GALLERIES)
|
|
||||||
}
|
|
||||||
} else { // Can't use SAF on old Androids!
|
|
||||||
val config = DirectoryChooserConfig.builder()
|
|
||||||
.newDirectoryName("Pupil")
|
|
||||||
.allowNewDirectoryNameModification(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val intent = Intent(requireContext(), DirectoryChooserActivity::class.java).apply {
|
|
||||||
putExtra(DirectoryChooserActivity.EXTRA_CONFIG, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
activity?.startActivityForResult(intent, REQUEST_IMPORT_OLD_GALLERIES_OLD)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"user_id" -> {
|
"user_id" -> {
|
||||||
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
|
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
|
||||||
ClipData.newPlainText("user_id", sharedPreference.getString("user_id", ""))
|
ClipData.newPlainText("user_id", Preferences.get<String>("user_id"))
|
||||||
)
|
)
|
||||||
Toast.makeText(context, R.string.settings_user_id_toast, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
else -> return false
|
else -> return false
|
||||||
}
|
}
|
||||||
@@ -216,6 +124,21 @@ class SettingsFragment :
|
|||||||
this ?: return false
|
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
|
||||||
@@ -237,11 +160,26 @@ class SettingsFragment :
|
|||||||
|
|
||||||
when (key) {
|
when (key) {
|
||||||
"proxy" -> {
|
"proxy" -> {
|
||||||
summary = getProxyInfo(requireContext()).type.name
|
summary = context?.let { getProxyInfo().type.name }
|
||||||
}
|
}
|
||||||
"dl_location" -> {
|
"download_folder" -> {
|
||||||
summary = getDownloadDirectory(requireContext()).canonicalPath
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,12 +187,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)
|
||||||
|
|
||||||
sharedPreference = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
Preferences.registerOnSharedPreferenceChangeListener(this)
|
||||||
sharedPreference.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) {
|
||||||
|
|
||||||
@@ -264,7 +206,7 @@ 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" -> {
|
||||||
@@ -274,31 +216,29 @@ class SettingsFragment :
|
|||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
"delete_cache" -> {
|
"download_folder_name" -> {
|
||||||
val dir = File(requireContext().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(requireContext())
|
(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 = (requireActivity().application as Pupil).histories
|
|
||||||
summary = getString(R.string.settings_clear_history_summary, histories.size)
|
|
||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
|
||||||
}
|
|
||||||
"dl_location" -> {
|
|
||||||
summary = getDownloadDirectory(requireContext()).canonicalPath
|
|
||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
|
||||||
}
|
}
|
||||||
"default_query" -> {
|
"default_query" -> {
|
||||||
summary = sharedPreference.getString("default_query", "") ?: ""
|
summary = Preferences.get<String>("default_query")
|
||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
@@ -319,33 +259,48 @@ class SettingsFragment :
|
|||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
"mirrors" -> {
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
|
||||||
}
|
|
||||||
"proxy" -> {
|
"proxy" -> {
|
||||||
summary = getProxyInfo(requireContext()).type.name
|
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
|
||||||
}
|
}
|
||||||
"nomedia" -> {
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
|
||||||
}
|
|
||||||
"backup" -> {
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
|
||||||
}
|
|
||||||
"restore" -> {
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
|
||||||
}
|
|
||||||
"old_import_galleries" -> {
|
"old_import_galleries" -> {
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
"user_id" -> {
|
"user_id" -> {
|
||||||
summary = sharedPreference.getString("user_id", "")
|
summary = Preferences.get<String>("user_id")
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
onPreferenceClickListener = this@SettingsFragment
|
||||||
}
|
}
|
||||||
|
"oss" -> {
|
||||||
|
setOnPreferenceClickListener {
|
||||||
|
context?.startActivity(Intent(context, OssLicensesMenuActivity::class.java))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -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
@@ -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
@@ -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
@@ -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,39 +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
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonConfiguration
|
|
||||||
import okhttp3.Dispatcher
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import xyz.quaver.proxy
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
const val REQUEST_LOCK = 38238
|
|
||||||
const val REQUEST_RESTORE = 16546
|
|
||||||
const val REQUEST_IMPORT_OLD_GALLERIES = 6458
|
|
||||||
const val REQUEST_IMPORT_OLD_GALLERIES_OLD = 5946
|
|
||||||
const val REQUEST_DOWNLOAD_FOLDER = 3874
|
|
||||||
const val REQUEST_DOWNLOAD_FOLDER_OLD = 3425
|
|
||||||
const val REQUEST_WRITE_PERMISSION_AND_SAF = 13900
|
|
||||||
|
|
||||||
const val NOTIFICATION_ID_UPDATE = 2345
|
|
||||||
|
|
||||||
val json = Json(JsonConfiguration.Stable)
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,312 +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 android.util.Log
|
|
||||||
import android.util.SparseArray
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import xyz.quaver.Code
|
|
||||||
import xyz.quaver.hitomi.GalleryBlock
|
|
||||||
import xyz.quaver.hitomi.Reader
|
|
||||||
import xyz.quaver.proxy
|
|
||||||
import xyz.quaver.pupil.util.getCachedGallery
|
|
||||||
import xyz.quaver.pupil.util.getDownloadDirectory
|
|
||||||
import xyz.quaver.pupil.util.isParentOf
|
|
||||||
import xyz.quaver.pupil.util.json
|
|
||||||
import java.io.BufferedInputStream
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.net.URL
|
|
||||||
import java.util.*
|
|
||||||
import java.util.concurrent.locks.Lock
|
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
|
||||||
|
|
||||||
class Cache(context: Context) : ContextWrapper(context) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val moving = mutableListOf<Int>()
|
|
||||||
private val readers = SparseArray<Reader?>()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val locks = SparseArray<Lock>()
|
|
||||||
private fun lock(galleryID: Int) {
|
|
||||||
synchronized(locks) {
|
|
||||||
if (locks.indexOfKey(galleryID) < 0)
|
|
||||||
locks.put(galleryID, ReentrantLock())
|
|
||||||
}
|
|
||||||
|
|
||||||
locks[galleryID].lock()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unlock(galleryID: Int) {
|
|
||||||
locks[galleryID]?.unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val preference = PreferenceManager.getDefaultSharedPreferences(this)
|
|
||||||
|
|
||||||
// Search in this order
|
|
||||||
// Download -> Cache
|
|
||||||
fun getCachedGallery(galleryID: Int) = getCachedGallery(this, galleryID).also {
|
|
||||||
if (!it.exists() && !preference.getBoolean("cache_disable", false))
|
|
||||||
it.mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCachedMetadata(galleryID: Int) : Metadata? {
|
|
||||||
val file = File(getCachedGallery(galleryID), ".metadata")
|
|
||||||
|
|
||||||
if (!file.exists())
|
|
||||||
return null
|
|
||||||
|
|
||||||
return try {
|
|
||||||
json.parse(Metadata.serializer(), file.readText())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
//File corrupted
|
|
||||||
file.delete()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCachedMetadata(galleryID: Int, metadata: Metadata) {
|
|
||||||
if (preference.getBoolean("cache_disable", false))
|
|
||||||
return
|
|
||||||
|
|
||||||
val file = File(getCachedGallery(galleryID), ".metadata").also {
|
|
||||||
if (!it.exists())
|
|
||||||
it.createNewFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
file.writeText(json.stringify(Metadata.serializer(), metadata))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getThumbnail(galleryID: Int): String? {
|
|
||||||
val metadata = Cache(this).getCachedMetadata(galleryID)
|
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
val thumbnail = if (metadata?.thumbnail == null)
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val thumbnails = getGalleryBlock(galleryID)?.thumbnails
|
|
||||||
try {
|
|
||||||
Base64.encodeToString(URL(thumbnails?.firstOrNull()).openConnection(proxy).getInputStream().use {
|
|
||||||
it.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 sources = listOf(
|
|
||||||
{ xyz.quaver.hitomi.getGalleryBlock(galleryID) },
|
|
||||||
{ xyz.quaver.hiyobi.getGalleryBlock(galleryID) }
|
|
||||||
)
|
|
||||||
|
|
||||||
val galleryBlock = if (metadata?.galleryBlock == null) {
|
|
||||||
CoroutineScope(Dispatchers.IO).async {
|
|
||||||
var galleryBlock: GalleryBlock? = null
|
|
||||||
|
|
||||||
for (source in sources) {
|
|
||||||
galleryBlock = try {
|
|
||||||
source.invoke()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (galleryBlock != null)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryBlock
|
|
||||||
}.await() ?: return null
|
|
||||||
}
|
|
||||||
else
|
|
||||||
metadata.galleryBlock
|
|
||||||
|
|
||||||
setCachedMetadata(
|
|
||||||
galleryID,
|
|
||||||
Metadata(Cache(this).getCachedMetadata(galleryID), galleryBlock = galleryBlock)
|
|
||||||
)
|
|
||||||
|
|
||||||
return galleryBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getReaderOrNull(galleryID: Int): Reader? {
|
|
||||||
return readers[galleryID] ?: 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 (readers[galleryID] != null)
|
|
||||||
return readers[galleryID]
|
|
||||||
else if (metadata?.reader == null) {
|
|
||||||
var retval: Reader? = null
|
|
||||||
|
|
||||||
for (source in sources) {
|
|
||||||
retval = try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
withTimeoutOrNull(1000) {
|
|
||||||
source.value.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
FirebaseCrashlytics.getInstance().recordException(e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (retval != null)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
retval
|
|
||||||
} else
|
|
||||||
metadata.reader
|
|
||||||
|
|
||||||
readers.put(galleryID, reader)
|
|
||||||
|
|
||||||
setCachedMetadata(
|
|
||||||
galleryID,
|
|
||||||
Metadata(Cache(this).getCachedMetadata(galleryID), readers = reader)
|
|
||||||
)
|
|
||||||
|
|
||||||
return reader
|
|
||||||
}
|
|
||||||
|
|
||||||
val imageNameRegex = Regex("""^\d+\..+$""")
|
|
||||||
fun getImages(galleryID: Int): List<File?>? {
|
|
||||||
val gallery = getCachedGallery(galleryID)
|
|
||||||
|
|
||||||
return gallery.list { _, name ->
|
|
||||||
imageNameRegex.matches(name)
|
|
||||||
}?.map {
|
|
||||||
File(gallery, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val imageExtensions = listOf(
|
|
||||||
"png",
|
|
||||||
"jpg",
|
|
||||||
"webp",
|
|
||||||
"gif"
|
|
||||||
)
|
|
||||||
fun getImage(galleryID: Int, index: Int): File? {
|
|
||||||
val gallery = getCachedGallery(galleryID)
|
|
||||||
|
|
||||||
for (ext in imageExtensions) {
|
|
||||||
File(gallery, "%05d.$ext".format(index)).let {
|
|
||||||
if (it.exists())
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun putImage(galleryID: Int, index: Int, ext: String, data: InputStream) {
|
|
||||||
if (preference.getBoolean("cache_disable", false))
|
|
||||||
return
|
|
||||||
|
|
||||||
val cache = File(getCachedGallery(galleryID), "%05d.$ext".format(index)).also {
|
|
||||||
if (!it.exists())
|
|
||||||
it.createNewFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
BufferedInputStream(data).use { inputStream ->
|
|
||||||
FileOutputStream(cache).use { outputStream ->
|
|
||||||
inputStream.copyTo(outputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
cache.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun moveToDownload(galleryID: Int) {
|
|
||||||
if (preference.getBoolean("cache_disable", false))
|
|
||||||
return
|
|
||||||
|
|
||||||
if (moving.contains(galleryID))
|
|
||||||
return
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val cache = getCachedGallery(galleryID).also {
|
|
||||||
if (!it.exists())
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
val download = File(getDownloadDirectory(this@Cache), galleryID.toString())
|
|
||||||
|
|
||||||
if (download.isParentOf(cache))
|
|
||||||
return@launch
|
|
||||||
|
|
||||||
Log.i("PUPILD", "MOVING ${cache.canonicalPath} --> ${download.canonicalPath}")
|
|
||||||
|
|
||||||
cache.copyRecursively(download, true) { file, err ->
|
|
||||||
Log.i("PUPILD", "MOVING ERROR ${file.canonicalPath} ${err.message}")
|
|
||||||
OnErrorAction.SKIP
|
|
||||||
}
|
|
||||||
Log.i("PUPILD", "MOVED ${cache.canonicalPath}")
|
|
||||||
|
|
||||||
Log.i("PUPILD", "DELETING ${cache.canonicalPath}")
|
|
||||||
cache.deleteRecursively()
|
|
||||||
Log.i("PUPILD", "DELETED ${cache.canonicalPath}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isDownloading(galleryID: Int) = getCachedMetadata(galleryID)?.isDownloading == true
|
|
||||||
|
|
||||||
fun setDownloading(galleryID: Int, isDownloading: Boolean) {
|
|
||||||
setCachedMetadata(galleryID, Metadata(getCachedMetadata(galleryID), isDownloading = isDownloading))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,444 +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.Log
|
|
||||||
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.google.firebase.crashlytics.FirebaseCrashlytics
|
|
||||||
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.imageUrlFromImage
|
|
||||||
import xyz.quaver.hiyobi.cookie
|
|
||||||
import xyz.quaver.hiyobi.createImgList
|
|
||||||
import xyz.quaver.hiyobi.user_agent
|
|
||||||
import xyz.quaver.proxy
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.ui.ReaderActivity
|
|
||||||
import java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
@OptIn(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()
|
|
||||||
|
|
||||||
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?>()
|
|
||||||
|
|
||||||
val interceptor = Interceptor { chain ->
|
|
||||||
val request = chain.request()
|
|
||||||
var response = chain.proceed(request)
|
|
||||||
|
|
||||||
var retry = 5
|
|
||||||
while (!response.isSuccessful && retry > 0) {
|
|
||||||
response = chain.proceed(request)
|
|
||||||
retry--
|
|
||||||
}
|
|
||||||
|
|
||||||
response.newBuilder()
|
|
||||||
.body(response.body()?.let {
|
|
||||||
ProgressResponseBody(request.tag(), it, progressListener)
|
|
||||||
}).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
val client =
|
|
||||||
OkHttpClient.Builder()
|
|
||||||
.connectTimeout(0, TimeUnit.SECONDS)
|
|
||||||
.addInterceptor(interceptor)
|
|
||||||
.readTimeout(0, TimeUnit.SECONDS)
|
|
||||||
.dispatcher(Dispatcher().apply {
|
|
||||||
maxRequests = 4
|
|
||||||
maxRequestsPerHost = 4
|
|
||||||
})
|
|
||||||
.proxy(proxy)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
queue.clear()
|
|
||||||
|
|
||||||
loop.cancel()
|
|
||||||
for (i in 0 until worker.size()) {
|
|
||||||
val galleryID = worker.keyAt(i)
|
|
||||||
|
|
||||||
Cache(this@DownloadWorker).setDownloading(galleryID, false)
|
|
||||||
worker[galleryID]?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
client.dispatcher().queuedCalls().filter {
|
|
||||||
it.request().tag() is Pair<*, *>
|
|
||||||
}.forEach {
|
|
||||||
it.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.clear()
|
|
||||||
exception.clear()
|
|
||||||
notification.clear()
|
|
||||||
notificationManager.cancelAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel(galleryID: Int) {
|
|
||||||
queue.remove(galleryID)
|
|
||||||
worker[galleryID]?.cancel()
|
|
||||||
|
|
||||||
client.dispatcher().queuedCalls().filter {
|
|
||||||
((it.request().tag() as Pair<*, *>).first as Int) == 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isCompleted(galleryID: Int) = progress[galleryID]?.all { !it.isFinite() } == true
|
|
||||||
|
|
||||||
private fun queueDownload(galleryID: Int, reader: Reader, index: Int, callback: Callback) {
|
|
||||||
val lowQuality = preferences.getBoolean("low_quality", false)
|
|
||||||
|
|
||||||
val request = Request.Builder().apply {
|
|
||||||
when (reader.code) {
|
|
||||||
Code.HITOMI -> {
|
|
||||||
url(
|
|
||||||
imageUrlFromImage(
|
|
||||||
galleryID,
|
|
||||||
reader.galleryInfo.files[index],
|
|
||||||
!lowQuality
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
|
|
||||||
Log.i("PUPILD", "DOWNLOADING ($galleryID, $index) from ${request.url()}")
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val cache = Cache(this@DownloadWorker).getImages(galleryID)
|
|
||||||
|
|
||||||
progress.put(galleryID, reader.galleryInfo.files.indices.map { index ->
|
|
||||||
if (cache?.firstOrNull { it?.nameWithoutExtension?.toIntOrNull() == index } != null)
|
|
||||||
Float.POSITIVE_INFINITY
|
|
||||||
else
|
|
||||||
0F
|
|
||||||
}.toMutableList())
|
|
||||||
exception.put(galleryID, reader.galleryInfo.files.map { null }.toMutableList())
|
|
||||||
|
|
||||||
if (notification[galleryID] == null)
|
|
||||||
initNotification(galleryID)
|
|
||||||
|
|
||||||
notification[galleryID].setContentTitle(reader.galleryInfo.title)
|
|
||||||
notify(galleryID)
|
|
||||||
|
|
||||||
if (isCompleted(galleryID)) {
|
|
||||||
with(Cache(this@DownloadWorker)) {
|
|
||||||
if (isDownloading(galleryID)) {
|
|
||||||
moveToDownload(galleryID)
|
|
||||||
setDownloading(galleryID, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i in reader.galleryInfo.files.indices) {
|
|
||||||
val callback = object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
Log.i("PUPILD", "FAIL ${call.request().tag()} (${e.message})")
|
|
||||||
if (e.message?.contains("cancel", true) != true)
|
|
||||||
FirebaseCrashlytics.getInstance().recordException(e)
|
|
||||||
|
|
||||||
progress[galleryID]?.set(i, Float.NaN)
|
|
||||||
exception[galleryID]?.set(i, e)
|
|
||||||
|
|
||||||
notify(galleryID)
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
if (isCompleted(galleryID)) {
|
|
||||||
with(Cache(this@DownloadWorker)) {
|
|
||||||
if (isDownloading(galleryID)) {
|
|
||||||
moveToDownload(galleryID)
|
|
||||||
setDownloading(galleryID, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
Log.i("PUPILD", "OK ${call.request().tag()}")
|
|
||||||
|
|
||||||
val ext = call.request().url().encodedPath().split('.').last()
|
|
||||||
|
|
||||||
try {
|
|
||||||
response.body().use {
|
|
||||||
Cache(this@DownloadWorker).putImage(galleryID, i, ext, it!!.byteStream())
|
|
||||||
}
|
|
||||||
progress[galleryID]?.set(i, Float.POSITIVE_INFINITY)
|
|
||||||
|
|
||||||
notify(galleryID)
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
if (isCompleted(galleryID)) {
|
|
||||||
with(Cache(this@DownloadWorker)) {
|
|
||||||
if (isDownloading(galleryID)) {
|
|
||||||
moveToDownload(galleryID)
|
|
||||||
setDownloading(galleryID, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i("PUPILD", "SUCCESS ${call.request().tag()}")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
|
|
||||||
progress[galleryID]?.set(i, Float.NaN)
|
|
||||||
exception[galleryID]?.set(i, e)
|
|
||||||
|
|
||||||
notify(galleryID)
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
if (isCompleted(galleryID)) {
|
|
||||||
with(Cache(this@DownloadWorker)) {
|
|
||||||
if (isDownloading(galleryID)) {
|
|
||||||
moveToDownload(galleryID)
|
|
||||||
setDownloading(galleryID, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
File(Cache(this@DownloadWorker).getCachedGallery(galleryID), "%05d.$ext".format(i)).delete()
|
|
||||||
|
|
||||||
Log.i("PUPILD", "FAIL ON OK ${call.request().tag()} (${e.message})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progress[galleryID]?.get(i)?.isFinite() == true) {
|
|
||||||
queueDownload(galleryID, reader, i, callback)
|
|
||||||
Log.i("PUPILD", "$galleryID QUEUED $i")
|
|
||||||
} else {
|
|
||||||
Log.i("PUPILD", "$galleryID SKIPPED $i (${progress[galleryID]?.get(i)})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun notify(galleryID: Int) {
|
|
||||||
val max = progress[galleryID]?.size ?: 0
|
|
||||||
val progress = progress[galleryID]?.count { !it.isFinite() } ?: 0
|
|
||||||
|
|
||||||
Log.i("PUPILD", "NOTIFY $galleryID ${isCompleted(galleryID)} $progress/$max")
|
|
||||||
|
|
||||||
if (isCompleted(galleryID)) {
|
|
||||||
notification[galleryID]
|
|
||||||
?.setContentText(getString(R.string.reader_notification_complete))
|
|
||||||
?.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
|
||||||
?.setProgress(0, 0, false)
|
|
||||||
?.setOngoing(false)
|
|
||||||
|
|
||||||
notificationManager.cancel(galleryID)
|
|
||||||
} 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(galleryID, 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)
|
|
||||||
setOngoing(true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loop() = CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
while (true) {
|
|
||||||
if (queue.isEmpty())
|
|
||||||
continue
|
|
||||||
|
|
||||||
val galleryID = queue.peek() ?: continue
|
|
||||||
|
|
||||||
if (progress.indexOfKey(galleryID) >= 0) // Gallery already downloading!
|
|
||||||
cancel(galleryID)
|
|
||||||
|
|
||||||
if (notification[galleryID] == null)
|
|
||||||
initNotification(galleryID)
|
|
||||||
|
|
||||||
if (Cache(this@DownloadWorker).isDownloading(galleryID))
|
|
||||||
notificationManager.notify(galleryID, notification[galleryID].build())
|
|
||||||
|
|
||||||
Log.i("PUPILD", "QUEUED $galleryID")
|
|
||||||
|
|
||||||
worker.put(galleryID, download(galleryID))
|
|
||||||
queue.poll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
279
app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/*
|
||||||
|
* 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.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
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.GalleryBlock
|
||||||
|
import xyz.quaver.pupil.hitomi.GalleryInfo
|
||||||
|
import xyz.quaver.pupil.hitomi.getGalleryBlock
|
||||||
|
import xyz.quaver.pupil.hitomi.getGalleryInfo
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OldGalleryBlock(
|
||||||
|
val code: String,
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OldReader(val code: String, val galleryInfo: GalleryInfo)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OldMetadata(
|
||||||
|
var galleryBlock: OldGalleryBlock? = 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?.let { galleryBlock -> GalleryBlock(
|
||||||
|
galleryBlock.id,
|
||||||
|
galleryBlock.galleryUrl,
|
||||||
|
galleryBlock.thumbnails,
|
||||||
|
galleryBlock.title,
|
||||||
|
galleryBlock.artists,
|
||||||
|
galleryBlock.series,
|
||||||
|
galleryBlock.type,
|
||||||
|
galleryBlock.language,
|
||||||
|
galleryBlock.relatedTags) },
|
||||||
|
old.reader?.galleryInfo,
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.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)
|
||||||
|
.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")
|
||||||
|
fun putImage(index: Int, fileName: String, data: ByteArray) {
|
||||||
|
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,130 @@
|
|||||||
|
/*
|
||||||
|
* 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.runBlocking
|
||||||
|
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.*
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}.invoke()
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
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 ?: {
|
||||||
|
file.createNewFile()
|
||||||
|
mutableMapOf<Int, String>()
|
||||||
|
}.invoke()
|
||||||
|
}.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadFolderMapInstance ?: mutableMapOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun isDownloading(galleryID: Int): Boolean {
|
||||||
|
val isThisGallery: (Call) -> Boolean = { (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) }
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun addDownloadFolder(galleryID: Int) {
|
||||||
|
val name = runBlocking {
|
||||||
|
Cache.getInstance(this@DownloadManager, galleryID).getGalleryBlock()
|
||||||
|
}?.formatDownloadFolder() ?: return
|
||||||
|
|
||||||
|
val folder = downloadFolder.getChild(name)
|
||||||
|
|
||||||
|
if (folder.exists())
|
||||||
|
return
|
||||||
|
|
||||||
|
folder.mkdir()
|
||||||
|
|
||||||
|
downloadFolderMap[galleryID] = folder.name
|
||||||
|
|
||||||
|
downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() }
|
||||||
|
downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap))
|
||||||
|
}
|
||||||
|
|
||||||
|
@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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,200 +18,50 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.util
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
import android.annotation.TargetApi
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import android.os.Build
|
import kotlinx.coroutines.Dispatchers
|
||||||
import android.os.storage.StorageManager
|
import kotlinx.coroutines.launch
|
||||||
import android.provider.DocumentsContract
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import androidx.core.content.ContextCompat
|
import kotlinx.coroutines.sync.withLock
|
||||||
import androidx.preference.PreferenceManager
|
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.io.FileOutputStream
|
|
||||||
import java.lang.reflect.Array
|
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
|
val mutex = Mutex()
|
||||||
|
fun cleanCache(context: Context) = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
if (mutex.isLocked) return@launch
|
||||||
|
|
||||||
fun getCachedGallery(context: Context, galleryID: Int) =
|
mutex.withLock {
|
||||||
File(getDownloadDirectory(context), galleryID.toString()).let {
|
val cacheFolder = File(context.cacheDir, "imageCache")
|
||||||
if (it.exists())
|
val downloadManager = DownloadManager.getInstance(context)
|
||||||
it
|
|
||||||
else
|
|
||||||
File(context.cacheDir, "imageCache/$galleryID")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDownloadDirectory(context: Context) =
|
val limit = (Preferences.get<String>("cache_limit").toLongOrNull() ?: 0L)*1024*1024*1024
|
||||||
PreferenceManager.getDefaultSharedPreferences(context).getString("dl_location", null).let {
|
|
||||||
if (it != null && !it.startsWith("content"))
|
|
||||||
File(it)
|
|
||||||
else
|
|
||||||
context.getExternalFilesDir(null)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
fun URL.download(to: File, onDownloadProgress: ((Long, Long) -> Unit)? = null) {
|
if (limit == 0L) return@withLock
|
||||||
|
|
||||||
if (to.parentFile?.exists() == false)
|
val cacheSize = {
|
||||||
to.parentFile!!.mkdirs()
|
var size = 0L
|
||||||
|
|
||||||
if (!to.exists())
|
cacheFolder.walk().forEach {
|
||||||
to.createNewFile()
|
size += it.length()
|
||||||
|
}
|
||||||
|
|
||||||
FileOutputStream(to).use { out ->
|
size
|
||||||
|
}
|
||||||
|
|
||||||
with(openConnection()) {
|
if (cacheSize.invoke() > limit)
|
||||||
val fileSize = contentLength.toLong()
|
while (cacheSize.invoke() > limit/2) {
|
||||||
|
val caches = cacheFolder.list() ?: return@withLock
|
||||||
|
|
||||||
getInputStream().use {
|
synchronized(histories) {
|
||||||
|
(histories.firstOrNull {
|
||||||
var bytesCopied: Long = 0
|
caches.contains(it.toString()) && !downloadManager.isDownloading(it)
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
} ?: return@withLock).let {
|
||||||
|
Cache.delete(context, it)
|
||||||
var bytes = it.read(buffer)
|
}
|
||||||
while (bytes >= 0) {
|
|
||||||
out.write(buffer, 0, bytes)
|
|
||||||
bytesCopied += bytes
|
|
||||||
onDownloadProgress?.invoke(bytesCopied, fileSize)
|
|
||||||
bytes = it.read(buffer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getExtSdCardPaths(context: Context) =
|
|
||||||
ContextCompat.getExternalFilesDirs(context, null).drop(1).map {
|
|
||||||
it.absolutePath.substringBeforeLast("/Android/data").let { path ->
|
|
||||||
runCatching {
|
|
||||||
File(path).canonicalPath
|
|
||||||
}.getOrElse {
|
|
||||||
path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const val PRIMARY_VOLUME_NAME = "primary"
|
|
||||||
fun getVolumePath(context: Context, volumeID: String?): String? {
|
|
||||||
return runCatching {
|
|
||||||
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
|
||||||
val storageVolumeClass = Class.forName("android.os.storage.StorageVolume")
|
|
||||||
|
|
||||||
val getVolumeList = storageVolumeClass.javaClass.getMethod("getVolumeList")
|
|
||||||
val getUUID = storageVolumeClass.getMethod("getUuid")
|
|
||||||
val getPath = storageVolumeClass.getMethod("getPath")
|
|
||||||
val isPrimary = storageVolumeClass.getMethod("isPrimary")
|
|
||||||
|
|
||||||
val result = getVolumeList.invoke(storageManager)!!
|
|
||||||
|
|
||||||
val length = Array.getLength(result)
|
|
||||||
|
|
||||||
for (i in 0 until length) {
|
|
||||||
val storageVolumeElement = Array.get(result, i)
|
|
||||||
val uuid = getUUID.invoke(storageVolumeElement) as? String
|
|
||||||
val primary = isPrimary.invoke(storageVolumeElement) as? Boolean
|
|
||||||
|
|
||||||
// primary volume?
|
|
||||||
if (primary == true && volumeID == PRIMARY_VOLUME_NAME)
|
|
||||||
return@runCatching getPath.invoke(storageVolumeElement) as? String
|
|
||||||
|
|
||||||
// other volumes?
|
|
||||||
if (volumeID == uuid) {
|
|
||||||
return@runCatching getPath.invoke(storageVolumeElement) as? String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return@runCatching null
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Credits go to https://stackoverflow.com/questions/34927748/android-5-0-documentfile-from-tree-uri/36162691#36162691
|
|
||||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
|
||||||
fun getVolumeIdFromTreeUri(uri: Uri) =
|
|
||||||
DocumentsContract.getTreeDocumentId(uri).split(':').let {
|
|
||||||
if (it.isNotEmpty())
|
|
||||||
it[0]
|
|
||||||
else
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
|
||||||
fun getDocumentPathFromTreeUri(uri: Uri) =
|
|
||||||
DocumentsContract.getTreeDocumentId(uri).split(':').let {
|
|
||||||
if (it.size >= 2)
|
|
||||||
it[1]
|
|
||||||
else
|
|
||||||
File.separator
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFullPathFromTreeUri(context: Context, uri: Uri) : String? {
|
|
||||||
val volumePath = getVolumePath(context, getVolumeIdFromTreeUri(uri) ?: return null).let {
|
|
||||||
it ?: return File.separator
|
|
||||||
|
|
||||||
if (it.endsWith(File.separator))
|
|
||||||
it.dropLast(1)
|
|
||||||
else
|
|
||||||
it
|
|
||||||
}
|
|
||||||
|
|
||||||
val documentPath = getDocumentPathFromTreeUri(uri).let {
|
|
||||||
if (it.endsWith(File.separator))
|
|
||||||
it.dropLast(1)
|
|
||||||
else
|
|
||||||
it
|
|
||||||
}
|
|
||||||
|
|
||||||
return if (documentPath.isNotEmpty()) {
|
|
||||||
if (documentPath.startsWith(File.separator))
|
|
||||||
volumePath + documentPath
|
|
||||||
else
|
|
||||||
volumePath + File.separator + documentPath
|
|
||||||
} else
|
|
||||||
volumePath
|
|
||||||
}
|
|
||||||
|
|
||||||
// Huge thanks to avluis(https://github.com/avluis)
|
|
||||||
// This code is originated from Hentoid(https://github.com/avluis/Hentoid) under Apache-2.0 license.
|
|
||||||
fun Uri.toFile(context: Context): File? {
|
|
||||||
val path = this.path ?: return null
|
|
||||||
|
|
||||||
val pathSeparator = path.indexOf(':')
|
|
||||||
val folderName = path.substring(pathSeparator+1)
|
|
||||||
|
|
||||||
// Determine whether the designated file is
|
|
||||||
// - on a removable media (e.g. SD card, OTG)
|
|
||||||
// or
|
|
||||||
// - on the internal phone memory
|
|
||||||
val removableMediaFolderRoots = getExtSdCardPaths(context)
|
|
||||||
|
|
||||||
/* First test is to compare root names with known roots of removable media
|
|
||||||
* In many cases, the SD card root name is shared between pre-SAF (File) and SAF (DocumentFile) frameworks
|
|
||||||
* (e.g. /storage/3437-3934 vs. /tree/3437-3934)
|
|
||||||
* This is what the following block is trying to do
|
|
||||||
*/
|
|
||||||
for (s in removableMediaFolderRoots) {
|
|
||||||
val sRoot = s.substring(s.lastIndexOf(File.separatorChar))
|
|
||||||
val root = path.substring(0, pathSeparator).let {
|
|
||||||
it.substring(it.lastIndexOf(File.separatorChar))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sRoot.equals(root, true)) {
|
|
||||||
return File(s + File.separatorChar + folderName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* In some other cases, there is no common name (e.g. /storage/sdcard1 vs. /tree/3437-3934)
|
|
||||||
* We can use a slower method to translate the Uri obtained with SAF into a pre-SAF path
|
|
||||||
* and compare it to the known removable media volume names
|
|
||||||
*/
|
|
||||||
val root = getFullPathFromTreeUri(context, this)
|
|
||||||
|
|
||||||
for (s in removableMediaFolderRoots) {
|
|
||||||
if (root?.startsWith(s) == true) {
|
|
||||||
return File(root)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return File(context.getExternalFilesDir(null)?.canonicalPath?.substringBeforeLast("/Android/data") ?: return null, folderName)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun File.isParentOf(another: File) =
|
|
||||||
another.absolutePath.startsWith(this.absolutePath)
|
|
||||||
@@ -1,82 +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.KSerializer
|
|
||||||
import kotlinx.serialization.builtins.list
|
|
||||||
import kotlinx.serialization.builtins.serializer
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class Histories(private val file: File) : ArrayList<Int>() {
|
|
||||||
|
|
||||||
val serializer: KSerializer<List<Int>> = Int.serializer().list
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (!file.exists())
|
|
||||||
file.parentFile?.mkdirs()
|
|
||||||
|
|
||||||
try {
|
|
||||||
load()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun load() : Histories {
|
|
||||||
return apply {
|
|
||||||
super.clear()
|
|
||||||
addAll(
|
|
||||||
json.parse(
|
|
||||||
serializer,
|
|
||||||
file.bufferedReader().use { it.readText() }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun save() {
|
|
||||||
file.writeText(json.stringify(serializer, 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,9 @@ 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.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.builtins.list
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
@@ -41,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) {
|
||||||
@@ -80,7 +82,7 @@ class LockManager(base: Context): ContextWrapper(base) {
|
|||||||
lock.writeText("[]")
|
lock.writeText("[]")
|
||||||
}
|
}
|
||||||
|
|
||||||
locks = ArrayList(json.parse(Lock.serializer().list, lock.readText()))
|
locks = Json.decodeFromString(lock.readText())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun save() {
|
private fun save() {
|
||||||
@@ -89,7 +91,7 @@ class LockManager(base: Context): ContextWrapper(base) {
|
|||||||
if (!lock.exists())
|
if (!lock.exists())
|
||||||
lock.createNewFile()
|
lock.createNewFile()
|
||||||
|
|
||||||
lock.writeText(json.stringify(Lock.serializer().list, locks?.toList() ?: listOf()))
|
lock.writeText(Json.encodeToString(locks?.toList() ?: listOf()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun add(lock: Lock) {
|
fun add(lock: Lock) {
|
||||||
|
|||||||
@@ -19,6 +19,13 @@
|
|||||||
package xyz.quaver.pupil.util
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import xyz.quaver.pupil.hitomi.GalleryBlock
|
||||||
|
import xyz.quaver.pupil.hitomi.GalleryInfo
|
||||||
|
import xyz.quaver.pupil.hitomi.getReferer
|
||||||
|
import xyz.quaver.pupil.hitomi.imageUrlFromImage
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.collections.ArrayList
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
@@ -28,20 +35,20 @@ fun String.wordCapitalize() : 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(
|
||||||
|
"B",
|
||||||
|
"kB",
|
||||||
|
"MB",
|
||||||
|
"GB",
|
||||||
|
"TB" //really?
|
||||||
|
)
|
||||||
|
|
||||||
val suffix = listOf(
|
fun byteToString(byte: Long, precision : Int = 1) : String {
|
||||||
"B",
|
|
||||||
"kB",
|
|
||||||
"MB",
|
|
||||||
"GB",
|
|
||||||
"TB" //really?
|
|
||||||
)
|
|
||||||
var size = byte.toDouble(); var suffixIndex = 0
|
var size = byte.toDouble(); var suffixIndex = 0
|
||||||
|
|
||||||
while (size >= 1024) {
|
while (size >= 1024) {
|
||||||
@@ -50,5 +57,74 @@ 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 { artists.joinToString() }
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
val GalleryInfo.requestBuilders: List<Request.Builder>
|
||||||
|
get() {
|
||||||
|
val galleryID = this.id ?: 0
|
||||||
|
val lowQuality = Preferences["low_quality", true]
|
||||||
|
|
||||||
|
return this.files.map {
|
||||||
|
Request.Builder()
|
||||||
|
.url(imageUrlFromImage(galleryID, it, !lowQuality))
|
||||||
|
.header("Referer", "https://hitomi.la/")
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.ellipsize(n: Int): String =
|
||||||
|
if (this.length > n)
|
||||||
|
this.slice(0 until n) + "…"
|
||||||
|
else
|
||||||
|
this
|
||||||
|
|
||||||
|
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
|
||||||
@@ -19,8 +19,10 @@
|
|||||||
package xyz.quaver.pupil.util
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Authenticator
|
import okhttp3.Authenticator
|
||||||
import okhttp3.Credentials
|
import okhttp3.Credentials
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
@@ -35,29 +37,22 @@ data class ProxyInfo(
|
|||||||
val password: String? = null
|
val password: String? = null
|
||||||
) {
|
) {
|
||||||
fun proxy() : Proxy {
|
fun proxy() : Proxy {
|
||||||
return if (host == null || port == null)
|
return if (host.isNullOrBlank() || port == null)
|
||||||
return Proxy.NO_PROXY
|
return Proxy.NO_PROXY
|
||||||
else
|
else
|
||||||
Proxy(type, InetSocketAddress.createUnresolved(host, port))
|
Proxy(type, InetSocketAddress.createUnresolved(host, port))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun authenticator() = Authenticator { _, response ->
|
fun authenticator(): Authenticator? = if (username.isNullOrBlank() || password.isNullOrBlank()) null else
|
||||||
val credential = Credentials.basic(username ?: "", password ?: "")
|
Authenticator { _, response ->
|
||||||
|
val credential = Credentials.basic(username, password)
|
||||||
|
|
||||||
response.request().newBuilder()
|
response.request().newBuilder()
|
||||||
.header("Proxy-Authorization", credential)
|
.header("Proxy-Authorization", credential)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getProxy(context: Context) =
|
fun getProxyInfo(): ProxyInfo =
|
||||||
getProxyInfo(context).proxy()
|
Json.decodeFromString(Preferences["proxy", Json.encodeToString(ProxyInfo(Proxy.Type.DIRECT))])
|
||||||
|
|
||||||
fun getProxyInfo(context: Context) =
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(context).getString("proxy", null).let {
|
|
||||||
if (it == null)
|
|
||||||
ProxyInfo(Proxy.Type.DIRECT)
|
|
||||||
else
|
|
||||||
json.parse(ProxyInfo.serializer(), it)
|
|
||||||
}
|
|
||||||
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,63 +18,51 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.util
|
package xyz.quaver.pupil.util
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.DownloadManager
|
import android.app.DownloadManager
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Base64
|
import android.webkit.URLUtil
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.boolean
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.content
|
import kotlinx.serialization.json.*
|
||||||
import okhttp3.*
|
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.hitomi.GalleryBlock
|
|
||||||
import xyz.quaver.hitomi.Reader
|
|
||||||
import xyz.quaver.hitomi.getGalleryBlock
|
|
||||||
import xyz.quaver.hitomi.getReader
|
|
||||||
import xyz.quaver.proxy
|
|
||||||
import xyz.quaver.pupil.BroadcastReciever
|
|
||||||
import xyz.quaver.pupil.BuildConfig
|
import xyz.quaver.pupil.BuildConfig
|
||||||
import xyz.quaver.pupil.R
|
import xyz.quaver.pupil.R
|
||||||
import xyz.quaver.pupil.util.download.Cache
|
import xyz.quaver.pupil.client
|
||||||
import xyz.quaver.pupil.util.download.Metadata
|
import xyz.quaver.pupil.favorites
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
fun getReleases(url: String) : JsonArray {
|
fun getReleases(url: String) : JsonArray {
|
||||||
return try {
|
return try {
|
||||||
URL(url).readText().let {
|
URL(url).readText().let {
|
||||||
json.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
|
||||||
@@ -83,13 +71,12 @@ 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: Context, force: Boolean = false) {
|
||||||
|
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
@@ -99,7 +86,7 @@ fun checkUpdate(context: Context, 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" -> "한국어"
|
||||||
@@ -137,12 +124,12 @@ fun checkUpdate(context: Context, 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
|
||||||
|
|
||||||
@@ -150,15 +137,12 @@ fun checkUpdate(context: Context, 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) { _, _ ->
|
||||||
|
|
||||||
val preference = PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
|
|
||||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
|
|
||||||
//Cancel any download queued before
|
//Cancel any download queued before
|
||||||
|
|
||||||
val id = preference.getLong("update_download_id", -1)
|
val id: Long = Preferences["update_download_id"]
|
||||||
|
|
||||||
if (id != -1L)
|
if (id != -1L)
|
||||||
downloadManager.remove(id)
|
downloadManager.remove(id)
|
||||||
@@ -172,10 +156,10 @@ fun checkUpdate(context: Context, force: Boolean = false) {
|
|||||||
.setDestinationUri(Uri.fromFile(target))
|
.setDestinationUri(Uri.fromFile(target))
|
||||||
|
|
||||||
downloadManager.enqueue(request).also {
|
downloadManager.enqueue(request).also {
|
||||||
preference.edit().putLong("update_download_id", it).apply()
|
Preferences["update_download_id"] = it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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() + 604800000)
|
||||||
@@ -189,147 +173,29 @@ fun checkUpdate(context: Context, force: Boolean = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cancelImport = false
|
fun restore(url: String, onFailure: ((Throwable) -> Unit)? = null, onSuccess: ((List<Int>) -> Unit)? = null) {
|
||||||
@SuppressLint("RestrictedApi")
|
if (!URLUtil.isValidUrl(url)) {
|
||||||
fun importOldGalleries(context: Context, folder: File) = CoroutineScope(Dispatchers.IO).launch {
|
onFailure?.invoke(IllegalArgumentException())
|
||||||
val client = OkHttpClient.Builder()
|
return
|
||||||
.connectTimeout(0, TimeUnit.SECONDS)
|
}
|
||||||
.readTimeout(0, TimeUnit.SECONDS)
|
|
||||||
.proxy(proxy)
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val cancelIntent = Intent(context, BroadcastReciever::class.java).apply {
|
client.newCall(request).enqueue(object: Callback {
|
||||||
action = BroadcastReciever.ACTION_CANCEL_IMPORT
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
putExtra(BroadcastReciever.EXTRA_IMPORT_NOTIFICATION_ID, 0)
|
onFailure?.invoke(e)
|
||||||
}
|
|
||||||
val pendingIntent = PendingIntent.getBroadcast(context, 0, cancelIntent, 0)
|
|
||||||
|
|
||||||
val notificationManager = NotificationManagerCompat.from(context)
|
|
||||||
val notificationBuilder = NotificationCompat.Builder(context, "import").apply {
|
|
||||||
setContentTitle(context.getText(R.string.import_old_galleries_notification))
|
|
||||||
setProgress(0, 0, true)
|
|
||||||
setSmallIcon(R.drawable.ic_notification)
|
|
||||||
addAction(0, context.getText(android.R.string.cancel), pendingIntent)
|
|
||||||
setOngoing(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationManager.notify(0, notificationBuilder.build())
|
|
||||||
|
|
||||||
if (!folder.isDirectory)
|
|
||||||
return@launch
|
|
||||||
|
|
||||||
val galleryRegex = Regex("""[0-9]+$""")
|
|
||||||
val imageRegex = Regex("""^[0-9]+\..+$""")
|
|
||||||
var size = 0
|
|
||||||
fun setProgress(progress: Int) {
|
|
||||||
notificationBuilder.apply {
|
|
||||||
setContentText(
|
|
||||||
context.getString(
|
|
||||||
R.string.import_old_galleries_notification_text,
|
|
||||||
progress,
|
|
||||||
size
|
|
||||||
)
|
|
||||||
)
|
|
||||||
setProgress(size, progress, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notificationManager.notify(0, notificationBuilder.build())
|
override fun onResponse(call: Call, response: Response) {
|
||||||
}
|
|
||||||
|
|
||||||
folder.listFiles { _, name ->
|
|
||||||
galleryRegex.matches(name)
|
|
||||||
}?.also {
|
|
||||||
size = it.size
|
|
||||||
setProgress(0)
|
|
||||||
}?.forEachIndexed { index, gallery ->
|
|
||||||
if (cancelImport)
|
|
||||||
return@forEachIndexed
|
|
||||||
|
|
||||||
setProgress(index)
|
|
||||||
|
|
||||||
val galleryID = gallery.name.toIntOrNull() ?: return@forEachIndexed
|
|
||||||
|
|
||||||
File(getDownloadDirectory(context), galleryID.toString()).mkdirs()
|
|
||||||
|
|
||||||
val reader = async {
|
|
||||||
kotlin.runCatching {
|
kotlin.runCatching {
|
||||||
json.parse(Reader.serializer(), File(gallery, "reader.json").readText())
|
Json.decodeFromString<List<Int>>(response.also { if (it.code() != 200) throw IOException() }.body().use { it?.string() } ?: "[]").let {
|
||||||
}.getOrElse {
|
favorites.addAll(it)
|
||||||
getReader(galleryID)
|
onSuccess?.invoke(it)
|
||||||
}
|
|
||||||
}
|
|
||||||
val galleryBlock = async {
|
|
||||||
kotlin.runCatching {
|
|
||||||
json.parse(GalleryBlock.serializer(), File(gallery, "galleryBlock.json").readText())
|
|
||||||
}.getOrElse {
|
|
||||||
getGalleryBlock(galleryID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@Suppress("NAME_SHADOWING")
|
|
||||||
val thumbnail = async thumbnail@{
|
|
||||||
val galleryBlock = galleryBlock.await()
|
|
||||||
|
|
||||||
Base64.encodeToString(try {
|
|
||||||
File(gallery, "thumbnail.jpg").readBytes()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
val url = galleryBlock?.thumbnails?.firstOrNull()
|
|
||||||
|
|
||||||
if (url == null)
|
|
||||||
null
|
|
||||||
else {
|
|
||||||
val request = Request.Builder().url(url).build()
|
|
||||||
|
|
||||||
var done = false
|
|
||||||
var result: ByteArray? = null
|
|
||||||
client.newCall(request).enqueue(object : Callback {
|
|
||||||
override fun onFailure(call: Call?, e: IOException?) {
|
|
||||||
done = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call?, response: Response?) {
|
|
||||||
result = response?.body()?.use {
|
|
||||||
it.bytes()
|
|
||||||
}
|
|
||||||
done = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!done)
|
|
||||||
yield()
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
} ?: return@thumbnail null, Base64.DEFAULT)
|
}.onFailure { onFailure?.invoke(it) }
|
||||||
}
|
}
|
||||||
|
})
|
||||||
Cache(context).setCachedMetadata(galleryID,
|
|
||||||
Metadata(
|
|
||||||
thumbnail.await(),
|
|
||||||
galleryBlock.await(),
|
|
||||||
reader.await()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
File(gallery, "images").listFiles { _, name ->
|
|
||||||
imageRegex.matches(name)
|
|
||||||
}?.forEach {
|
|
||||||
if (cancelImport)
|
|
||||||
return@forEach
|
|
||||||
|
|
||||||
@Suppress("NAME_SHADOWING")
|
|
||||||
val index = it.nameWithoutExtension.toIntOrNull() ?: return@forEach
|
|
||||||
|
|
||||||
Cache(context).putImage(galleryID, index, it.extension, it.inputStream())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationBuilder.apply {
|
|
||||||
setContentText(context.getText(R.string.import_old_galleries_notification_done))
|
|
||||||
setProgress(0, 0, false)
|
|
||||||
setOngoing(false)
|
|
||||||
mActions.clear()
|
|
||||||
}
|
|
||||||
notificationManager.notify(0, notificationBuilder.build())
|
|
||||||
|
|
||||||
cancelImport = false
|
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 197 B |
|
Before Width: | Height: | Size: 585 B |
|
Before Width: | Height: | Size: 361 B |
|
Before Width: | Height: | Size: 470 B |
|
Before Width: | Height: | Size: 145 B |
|
Before Width: | Height: | Size: 366 B |
|
Before Width: | Height: | Size: 224 B |
|
Before Width: | Height: | Size: 236 B |
|
Before Width: | Height: | Size: 226 B |
|
Before Width: | Height: | Size: 700 B |
|
Before Width: | Height: | Size: 370 B |
|
Before Width: | Height: | Size: 416 B |
|
Before Width: | Height: | Size: 294 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 602 B |
|
Before Width: | Height: | Size: 687 B |
@@ -1,8 +1,4 @@
|
|||||||
<!-- drawable/account_group.xml -->
|
<!--drawable/account_group.xml-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
android:height="24dp"
|
<path android:fillColor="?attr/colorControlNormal" android:pathData="M12 5.5A3.5 3.5 0 0 1 15.5 9a3.5 3.5 0 0 1-3.5 3.5A3.5 3.5 0 0 1 8.5 9 3.5 3.5 0 0 1 12 5.5M5 8c0.56 0 1.08 0.15 1.53 0.42-0.15 1.43 0.27 2.85 1.13 3.96C7.16 13.34 6.16 14 5 14a3 3 0 0 1-3-3 3 3 0 0 1 3-3m14 0a3 3 0 0 1 3 3 3 3 0 0 1-3 3c-1.16 0-2.16-0.66-2.66-1.62 0.86-1.11 1.28-2.53 1.13-3.96C17.92 8.15 18.44 8 19 8M5.5 18.25c0-2.07 2.91-3.75 6.5-3.75s6.5 1.68 6.5 3.75V20h-13v-1.75M0 20v-1.5c0-1.39 1.89-2.56 4.45-2.9-0.59 0.68-0.95 1.62-0.95 2.65V20H0m24 0h-3.5v-1.75c0-1.03-0.36-1.97-0.95-2.65 2.56 0.34 4.45 1.51 4.45 2.9V20z"/>
|
||||||
android:width="24dp"
|
</vector>
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path android:fillColor="#fff" android:pathData="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z" />
|
|
||||||
</vector>
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
<!-- drawable/account_star.xml -->
|
<!--drawable/account_star.xml-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
android:height="24dp"
|
<path android:fillColor="?attr/colorControlNormal" android:pathData="M15 14c-2.67 0-8 1.33-8 4v2h16v-2c0-2.67-5.33-4-8-4m0-2a4 4 0 0 0 4-4 4 4 0 0 0-4-4 4 4 0 0 0-4 4 4 4 0 0 0 4 4M5 13.28l2.45 1.49-0.65-2.81L9 10.08 6.11 9.83 5 7.19 3.87 9.83 1 10.08l2.18 1.88-0.68 2.81L5 13.28z"/>
|
||||||
android:width="24dp"
|
</vector>
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path android:fillColor="#fff" android:pathData="M15,14C12.33,14 7,15.33 7,18V20H23V18C23,15.33 17.67,14 15,14M15,12A4,4 0 0,0 19,8A4,4 0 0,0 15,4A4,4 0 0,0 11,8A4,4 0 0,0 15,12M5,13.28L7.45,14.77L6.8,11.96L9,10.08L6.11,9.83L5,7.19L3.87,9.83L1,10.08L3.18,11.96L2.5,14.77L5,13.28Z" />
|
|
||||||
</vector>
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
<!-- drawable/arrow_right.xml -->
|
<!--drawable/arrow_right.xml-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
android:height="24dp"
|
<path android:fillColor="#fff" android:pathData="M4 11v2h12l-5.5 5.5 1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5 16 11H4z"/>
|
||||||
android:width="24dp"
|
</vector>
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path android:fillColor="#fff" android:pathData="M4,11V13H16L10.5,18.5L11.92,19.92L19.84,12L11.92,4.08L10.5,5.5L16,11H4Z" />
|
|
||||||
</vector>
|
|
||||||
|
|||||||
@@ -1,37 +1,14 @@
|
|||||||
<animated-vector
|
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" xmlns:tools="http://schemas.android.com/tools" tools:ignore="newApi">
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
tools:ignore="newApi">
|
|
||||||
<aapt:attr name="android:drawable">
|
<aapt:attr name="android:drawable">
|
||||||
<vector
|
<vector android:name="vector" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
android:name="vector"
|
<path android:name="path" android:pathData="M 12 15.39 L 8.24 17.66 L 9.23 13.38 L 5.91 10.5 L 10.29 10.13 L 12 6.09 L 13.71 10.13 L 18.09 10.5 L 14.77 13.38 L 15.76 17.66 M 22 9.24 L 14.81 8.63 L 12 2 L 9.19 8.63 L 2 9.24 L 7.45 13.97 L 5.82 21 L 12 17.27 L 18.18 21 L 16.54 13.97 L 22 9.24 Z" android:fillColor="@color/material_orange_500"/>
|
||||||
android:width="24dp"
|
<clip-path android:name="clip" android:pathData="M 2 21 L 2 21 L 22 21 L 22 21 Z"/>
|
||||||
android:height="24dp"
|
<path android:name="path_1" android:pathData="M 12 17.27 L 18.18 21 L 16.54 13.97 L 22 9.24 L 14.81 8.62 L 12 2 L 9.19 8.62 L 2 9.24 L 7.45 13.97 L 5.82 21 L 12 17.27 Z" android:fillColor="@color/material_orange_500"/>
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:name="path"
|
|
||||||
android:pathData="M 12 15.39 L 8.24 17.66 L 9.23 13.38 L 5.91 10.5 L 10.29 10.13 L 12 6.09 L 13.71 10.13 L 18.09 10.5 L 14.77 13.38 L 15.76 17.66 M 22 9.24 L 14.81 8.63 L 12 2 L 9.19 8.63 L 2 9.24 L 7.45 13.97 L 5.82 21 L 12 17.27 L 18.18 21 L 16.54 13.97 L 22 9.24 Z"
|
|
||||||
android:fillColor="@color/material_orange_500"/>
|
|
||||||
<clip-path
|
|
||||||
android:name="clip"
|
|
||||||
android:pathData="M 2 21 L 2 21 L 22 21 L 22 21 Z"/>
|
|
||||||
<path
|
|
||||||
android:name="path_1"
|
|
||||||
android:pathData="M 12 17.27 L 18.18 21 L 16.54 13.97 L 22 9.24 L 14.81 8.62 L 12 2 L 9.19 8.62 L 2 9.24 L 7.45 13.97 L 5.82 21 L 12 17.27 Z"
|
|
||||||
android:fillColor="@color/material_orange_500"/>
|
|
||||||
</vector>
|
</vector>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
<target android:name="clip">
|
<target android:name="clip">
|
||||||
<aapt:attr name="android:animation">
|
<aapt:attr name="android:animation">
|
||||||
<objectAnimator
|
<objectAnimator android:propertyName="pathData" android:duration="500" android:valueFrom="M 2 21 L 2 21 L 22 21 L 22 21 Z" android:valueTo="M 2 2 L 2 21 L 22 21 L 22 2 Z" android:valueType="pathType" android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
||||||
android:propertyName="pathData"
|
|
||||||
android:duration="500"
|
|
||||||
android:valueFrom="M 2 21 L 2 21 L 22 21 L 22 21 Z"
|
|
||||||
android:valueTo="M 2 2 L 2 21 L 22 21 L 22 2 Z"
|
|
||||||
android:valueType="pathType"
|
|
||||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</target>
|
</target>
|
||||||
</animated-vector>
|
</animated-vector>
|
||||||
|
|||||||