Compare commits
256 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f79c11303 | ||
|
|
a9cd3db27e | ||
|
|
47d96a6ba9 | ||
|
|
3ee5e683f4 | ||
|
|
71e8cebff4 | ||
|
|
fd3f1454c5 | ||
|
|
4028739e70 | ||
|
|
067a263336 | ||
|
|
62948abf75 | ||
|
|
e8ba5c4881 | ||
|
|
e648b6dfee | ||
|
|
d1381b8700 | ||
|
|
f8df28311e | ||
|
|
59afa04744 | ||
|
|
7a5c3ae2ed | ||
|
|
9e9a5998cd | ||
|
|
f34876ca93 | ||
|
|
48752a323f | ||
|
|
ab3e6466d5 | ||
|
|
419c8fc644 | ||
|
|
69078ac42e | ||
|
|
91b6baaf1c | ||
|
|
3f3774a0cd | ||
|
|
efc40ce458 | ||
|
|
39b8bbc725 | ||
|
|
b0fedd78fb | ||
|
|
72b0fa78bb | ||
|
|
114158cf73 | ||
|
|
6d108dd7ff | ||
|
|
f36b7f1dbe | ||
|
|
0a22ebd8e9 | ||
|
|
3682eeaf94 | ||
|
|
7df2ae4ba7 | ||
|
|
c9519ec681 | ||
|
|
b146ed684d | ||
|
|
d2787c36d7 | ||
|
|
3ff663114a | ||
|
|
573e62f310 | ||
|
|
f9af670b82 | ||
|
|
bf461475c6 | ||
|
|
bdea6e0cc1 | ||
|
|
57f0ec4e5d | ||
|
|
d663092363 | ||
|
|
edf6188e36 | ||
|
|
f3f3395e68 | ||
|
|
ac9dc347e3 | ||
|
|
8721d85946 | ||
|
|
a0bd1a8738 | ||
|
|
35fdf3e3b0 | ||
|
|
aced8293f1 | ||
|
|
3f516faad8 | ||
|
|
824f7b9602 | ||
|
|
95aeeaa16f | ||
|
|
63f08f0230 | ||
|
|
3b241fe857 | ||
|
|
75bc104f43 | ||
|
|
30afd56324 | ||
|
|
5ee1bb11a0 | ||
|
|
c1de45abce | ||
|
|
8805033c8d | ||
|
|
0ed59bb8a9 | ||
|
|
8163f2fd28 | ||
|
|
521a65c9d2 | ||
|
|
eb98424668 | ||
|
|
961c731743 | ||
|
|
5188769fb6 | ||
|
|
8f27d9e30f | ||
|
|
b58566999e | ||
|
|
117d6dcd2b | ||
|
|
2608796929 | ||
|
|
792f5b5a7f | ||
|
|
a77b1db749 | ||
|
|
9d984d92af | ||
|
|
e303f25991 | ||
|
|
85973d2305 | ||
|
|
13f8d7b747 | ||
|
|
e198860edb | ||
|
|
fc8355467b | ||
|
|
67abc15442 | ||
|
|
e94cddb86a | ||
|
|
700f7a33a5 | ||
|
|
41e952144d | ||
|
|
910ed65937 | ||
|
|
e06701a2fb | ||
|
|
62dce26c73 | ||
|
|
ac0cff62d4 | ||
|
|
655c060814 | ||
|
|
36d27895e7 | ||
|
|
803481f74c | ||
|
|
b3ca1686e3 | ||
|
|
8f220eb0cb | ||
|
|
51d5f42e8b | ||
|
|
8d8c5ace61 | ||
|
|
4bb6b8ccc9 | ||
|
|
6bebd36e83 | ||
|
|
edc7053e50 | ||
|
|
55e6ef5f78 | ||
|
|
9781d7a5dc | ||
|
|
b83cf87cd8 | ||
|
|
430864512d | ||
|
|
16eeef1878 | ||
|
|
994d4b589b | ||
|
|
43adba6f13 | ||
|
|
e4fbd21731 | ||
|
|
8be64745fc | ||
|
|
b66f376729 | ||
|
|
cc40416e1e | ||
|
|
5073352366 | ||
|
|
9ae12a2c4c | ||
|
|
843b8412a9 | ||
|
|
4f67578371 | ||
|
|
37f2227093 | ||
|
|
1833c0bde5 | ||
|
|
aa3aeca3f2 | ||
|
|
152d4e248f | ||
|
|
7461c8d201 | ||
|
|
0902fdf981 | ||
|
|
0fd2cf4fd7 | ||
|
|
679558106f | ||
|
|
e498efc493 | ||
|
|
74bbc71741 | ||
|
|
502b4890e3 | ||
|
|
dfb60461e4 | ||
|
|
bd6bc418e6 | ||
|
|
a284143ce1 | ||
|
|
1f1c782772 | ||
|
|
5c0f5fe333 | ||
|
|
748e023fde | ||
|
|
30104bacd2 | ||
|
|
f33d1a1bfa | ||
|
|
3c08331441 | ||
|
|
3eaa38247b | ||
|
|
304ce643f9 | ||
|
|
b4ad994f95 | ||
|
|
03c5cfa791 | ||
|
|
e8056072b8 | ||
|
|
d134639a5f | ||
|
|
b4745d76b8 | ||
|
|
c5fd674020 | ||
|
|
9b821dd7cb | ||
|
|
1b441f6aea | ||
|
|
213902c854 | ||
|
|
2054922586 | ||
|
|
a17b7355f5 | ||
|
|
066a1e1f3a | ||
|
|
b10cbfbd63 | ||
|
|
fcd72bb8bd | ||
|
|
37cd99731c | ||
|
|
ed97773f24 | ||
|
|
0424ba3e87 | ||
|
|
9539c4e7bf | ||
|
|
248b378f01 | ||
|
|
1c40575665 | ||
|
|
ac67c648be | ||
|
|
42cc026acc | ||
|
|
23a74edfad | ||
|
|
5da1804f17 | ||
|
|
75f0c35017 | ||
|
|
0e6b02d260 | ||
|
|
d5a0ce55f0 | ||
|
|
09fc6fe8ef | ||
|
|
ff30be879a | ||
|
|
309fe4d831 | ||
|
|
dff0c817a7 | ||
|
|
04313981d4 | ||
|
|
810cb4d13a | ||
|
|
969e32e744 | ||
|
|
980909df9b | ||
|
|
e6753088a4 | ||
|
|
cbdb6cb63a | ||
|
|
3cdf1a899e | ||
|
|
c796be5de5 | ||
|
|
db301cb0c3 | ||
|
|
f00421ef23 | ||
|
|
b324654967 | ||
|
|
aa10ada3ee | ||
|
|
10c97987fb | ||
|
|
b532615bbd | ||
|
|
3066f41af3 | ||
|
|
0c401c6741 | ||
|
|
1a21d1c937 | ||
|
|
525b49a5c9 | ||
|
|
34c074bf7b | ||
|
|
b4dc961cdc | ||
|
|
93374d2cfe | ||
|
|
4009b10549 | ||
|
|
db1864205f | ||
|
|
bf39ccabbd | ||
|
|
0e8e7767ee | ||
|
|
5b6c86e34f | ||
|
|
6bbaca3686 | ||
|
|
35eae90df1 | ||
|
|
488d43e076 | ||
|
|
7c5e93c171 | ||
|
|
a20ef783e1 | ||
|
|
8ae0dce0ed | ||
|
|
44aea606b7 | ||
|
|
a05dc8c661 | ||
|
|
1f80e36017 | ||
|
|
1efca40744 | ||
|
|
86e3131afa | ||
|
|
4910b4a4b0 | ||
|
|
9c7320c0a0 | ||
|
|
02c17c3b75 | ||
|
|
49a47f4b4f | ||
|
|
68280f4a62 | ||
|
|
0e3669b247 | ||
|
|
4c9aa29d46 | ||
|
|
66fbf10f2d | ||
|
|
15ad806eb8 | ||
|
|
b7f80b9c82 | ||
|
|
9b511d2f8f | ||
|
|
6ebce2deb3 | ||
|
|
95dade13f4 | ||
|
|
ba4449d003 | ||
|
|
7632fe5e86 | ||
|
|
2c56bcacee | ||
|
|
c8202db3c6 | ||
|
|
223d689b0c | ||
|
|
4f0e7d9696 | ||
|
|
f4ce911de9 | ||
|
|
d0ad7effa0 | ||
|
|
a032beecbf | ||
|
|
46ec9e48d9 | ||
|
|
26bcef1cc0 | ||
|
|
bfb2f44f8f | ||
|
|
28b19b6774 | ||
|
|
8d72f4a3aa | ||
|
|
9c62e0399d | ||
|
|
65ea09854e | ||
|
|
9f9a4c81b3 | ||
|
|
d567b30f4b | ||
|
|
6d7c4ce0ab | ||
|
|
e062b8f9e9 | ||
|
|
08403b7a4e | ||
|
|
c6ed5d35e7 | ||
|
|
dba3460b60 | ||
|
|
f07f624fcf | ||
|
|
48ff2f328f | ||
|
|
9ae2423a40 | ||
|
|
2bc3c78c75 | ||
|
|
18e9fe75fb | ||
|
|
880a741a44 | ||
|
|
2c6ddcc64b | ||
|
|
8f2e757b77 | ||
|
|
ff177955b3 | ||
|
|
8bb8066a98 | ||
|
|
2747ddbf65 | ||
|
|
b939e9424d | ||
|
|
fb9dea5d1e | ||
|
|
da4d5d711b | ||
|
|
331cbec5f1 | ||
|
|
7f02284285 | ||
|
|
ac2c3a6d97 | ||
|
|
c3bc80fec6 | ||
|
|
09779a0710 |
47
.gitignore
vendored
@@ -1,20 +1,33 @@
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Log/OS Files
|
||||
*.log
|
||||
|
||||
# Android Studio generated files and folders
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
output.json
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.idea/
|
||||
misc.xml
|
||||
deploymentTargetDropDown.xml
|
||||
render.experimental.xml
|
||||
|
||||
#Github pages
|
||||
/gh-pages
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
#Private files
|
||||
**/google-services.json
|
||||
**/credentials.json
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
139
.idea/codeStyles/Project.xml
generated
@@ -1,139 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="RIGHT_MARGIN" value="120" />
|
||||
<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" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
6
.idea/compiler.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="1.8" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/copyright/GPL.xml
generated
@@ -1,6 +0,0 @@
|
||||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value=" Pupil, Hitomi.la viewer for Android Copyright (C) &#36;today.year tom5079 This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>." />
|
||||
<option name="myName" value="GPL" />
|
||||
</copyright>
|
||||
</component>
|
||||
7
.idea/copyright/profiles_settings.xml
generated
@@ -1,7 +0,0 @@
|
||||
<component name="CopyrightManager">
|
||||
<settings>
|
||||
<module2copyright>
|
||||
<element module="Pupil" copyright="GPL" />
|
||||
</module2copyright>
|
||||
</settings>
|
||||
</component>
|
||||
7
.idea/dictionaries/tom50.xml
generated
@@ -1,7 +0,0 @@
|
||||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="tom50">
|
||||
<words>
|
||||
<w>hitomi</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
||||
4
.idea/encodings.xml
generated
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
|
||||
</project>
|
||||
21
.idea/gradle.xml
generated
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="PLATFORM" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveModulePerSourceSet" value="false" />
|
||||
<option name="useQualifiedModuleNames" value="true" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
75
.idea/jarRepositories.xml
generated
@@ -1,75 +0,0 @@
|
||||
<?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>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/kotlinCodeInsightSettings.xml
generated
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinCodeInsightWorkspaceSettings">
|
||||
<option name="addUnambiguousImportsOnTheFly" value="true" />
|
||||
<option name="optimizeImportsOnTheFly" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/kotlinc.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Kotlin2JvmCompilerArguments">
|
||||
<option name="jvmTarget" value="1.8" />
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/misc.xml
generated
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<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" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
12
.idea/runConfigurations.xml
generated
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
|
||||
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
|
||||
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
3
.idea/scopes/Pupil.xml
generated
@@ -1,3 +0,0 @@
|
||||
<component name="DependencyValidationManager">
|
||||
<scope name="Pupil" pattern="file[app]:*/" />
|
||||
</component>
|
||||
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -2,8 +2,8 @@
|
||||
*Pupil, Hitomi.la viewer for Android*
|
||||
|
||||

|
||||
[](https://github.com/tom5079/Pupil/releases/download/5.1.4-hotfix1/Pupil-v5.1.4-hotfix1.apk)
|
||||
[](https://discord.gg/Stj4b5v)
|
||||
[](https://github.com/tom5079/Pupil/releases/download/5.3.8-hotfix1/Pupil-v5.3.8-hotfix1.apk)
|
||||
[](https://discord.gg/Stj4b5v)
|
||||
|
||||
# Features
|
||||

|
||||
@@ -20,7 +20,7 @@ or Build app yourself
|
||||
|
||||
# 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)
|
||||
|
||||
138
app/build.gradle
@@ -1,138 +0,0 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "kotlin-android"
|
||||
apply plugin: "kotlin-kapt"
|
||||
apply plugin: "kotlin-android-extensions"
|
||||
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()) {
|
||||
logger.lifecycle("Firebase Enabled")
|
||||
apply plugin: "com.google.gms.google-services"
|
||||
apply plugin: "com.google.firebase.crashlytics"
|
||||
apply plugin: "com.google.firebase.firebase-perf"
|
||||
} else {
|
||||
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 {
|
||||
compileSdkVersion 30
|
||||
defaultConfig {
|
||||
applicationId "xyz.quaver.pupil"
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 30
|
||||
versionCode 63
|
||||
versionName "5.1.5"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
|
||||
debuggable true
|
||||
applicationIdSuffix ".debug"
|
||||
versionNameSuffix "-DEBUG"
|
||||
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
|
||||
ext.enableCrashlytics = false
|
||||
ext.alwaysUpdateBuildId = false
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
freeCompilerArgs += "-Xuse-experimental=kotlin.Experimental"
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
buildToolsVersion = "29.0.3"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: "libs", include: ["*.jar", "*.aar"])
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.0-RC2"
|
||||
|
||||
implementation "androidx.appcompat:appcompat:1.2.0"
|
||||
implementation "androidx.activity:activity-ktx:1.2.0-beta01"
|
||||
implementation "androidx.fragment:fragment-ktx:1.3.0-beta01"
|
||||
implementation "androidx.preference:preference-ktx:1.1.1"
|
||||
implementation "androidx.constraintlayout:constraintlayout:2.0.2"
|
||||
implementation "androidx.gridlayout:gridlayout:1.0.0"
|
||||
implementation "androidx.biometric:biometric:1.0.1"
|
||||
implementation "androidx.work:work-runtime-ktx:2.4.0"
|
||||
|
||||
implementation "com.daimajia.swipelayout:library:1.2.0@aar"
|
||||
|
||||
implementation "com.google.android.material:material:1.3.0-alpha03"
|
||||
|
||||
implementation "com.google.firebase:firebase-core:17.5.1"
|
||||
implementation "com.google.firebase:firebase-analytics:17.6.0"
|
||||
implementation "com.google.firebase:firebase-crashlytics:17.2.2"
|
||||
implementation "com.google.firebase:firebase-perf:19.0.9"
|
||||
|
||||
implementation "com.google.android.gms:play-services-oss-licenses:17.0.0"
|
||||
implementation "com.google.android.gms:play-services-mlkit-face-detection:16.1.1"
|
||||
|
||||
implementation "com.github.clans:fab:1.6.4"
|
||||
|
||||
//implementation "com.quiph.ui:recyclerviewfastscroller:0.2.1"
|
||||
|
||||
implementation 'com.github.piasy:BigImageViewer:1.6.7'
|
||||
implementation 'com.github.piasy:FrescoImageLoader:1.6.7'
|
||||
implementation 'com.github.piasy:FrescoImageViewFactory:1.6.7'
|
||||
|
||||
//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 "xyz.quaver:libpupil:1.7.2"
|
||||
implementation "xyz.quaver:documentfilex:0.4-alpha02"
|
||||
implementation "xyz.quaver:floatingsearchview:1.0.7"
|
||||
|
||||
testImplementation "junit:junit:4.13"
|
||||
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"
|
||||
}
|
||||
|
||||
androidExtensions {
|
||||
experimental = true
|
||||
}
|
||||
95
app/build.gradle.kts
Normal file
@@ -0,0 +1,95 @@
|
||||
plugins {
|
||||
alias(libs.plugins.org.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.googleServices)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
alias(libs.plugins.crashlytics)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "xyz.quaver.pupil"
|
||||
defaultConfig {
|
||||
applicationId = "xyz.quaver.pupil"
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 69
|
||||
versionName = "6.0.0"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
isDebuggable = true
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-DEBUG"
|
||||
ext.set("enableCrashlytics", false)
|
||||
ext.set("alwaysUpdateBuildId", false)
|
||||
}
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring(libs.android.desugaring)
|
||||
|
||||
implementation(libs.kotlinx.serialization)
|
||||
implementation(libs.kotlinx.coroutines)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material3.windowSizeClass)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
|
||||
implementation(libs.androidx.room.runtime)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
implementation(libs.accompanist.adaptive)
|
||||
|
||||
implementation(libs.coil)
|
||||
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.analytics)
|
||||
implementation(libs.firebase.crashlytics)
|
||||
implementation(libs.firebase.perf)
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
implementation(libs.ktor.client)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
|
||||
implementation(libs.documentFileX)
|
||||
}
|
||||
6
app/proguard-rules.pro
vendored
@@ -1,6 +1,6 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
# proguardFiles setting in build.gradle.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
@@ -31,5 +31,5 @@
|
||||
-keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
-keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment
|
||||
-keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment
|
||||
-keep class xyz.quaver.pupil.** { *; }
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "xyz.quaver.pupil",
|
||||
"variantName": "processReleaseResources",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"versionCode": 63,
|
||||
"versionName": "5.1.5",
|
||||
"attributes": [],
|
||||
"versionCode": 69,
|
||||
"versionName": "6.0.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
]
|
||||
],
|
||||
"elementType": "File"
|
||||
}
|
||||
@@ -20,10 +20,18 @@
|
||||
|
||||
package xyz.quaver.pupil
|
||||
|
||||
import android.util.Log
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import xyz.quaver.pupil.hitomi.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
@@ -32,10 +40,144 @@ import org.junit.runner.RunWith
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
// @Before
|
||||
// fun init() {
|
||||
// val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
// }
|
||||
|
||||
@Before
|
||||
fun init() {
|
||||
clientBuilder = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.SECONDS)
|
||||
.writeTimeout(0, TimeUnit.SECONDS)
|
||||
.callTimeout(0, TimeUnit.SECONDS)
|
||||
.connectTimeout(0, TimeUnit.SECONDS)
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Referer", "https://hitomi.la/")
|
||||
.build()
|
||||
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
fun test_empty() {
|
||||
print(
|
||||
"".trim()
|
||||
.replace(Regex("""^\?"""), "")
|
||||
.lowercase(Locale.getDefault())
|
||||
.split(Regex("\\s+"))
|
||||
.map {
|
||||
it.replace('_', ' ')
|
||||
})
|
||||
}
|
||||
@Test
|
||||
fun test_nozomi() {
|
||||
val nozomi = getGalleryIDsFromNozomi(null, "index", "all")
|
||||
|
||||
Log.d("PUPILD", nozomi.size.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_search() {
|
||||
val ids = getGalleryIDsForQuery("language:korean").reversed()
|
||||
|
||||
print(ids.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_suggestions() {
|
||||
val suggestions = getSuggestionsForQuery("language:g")
|
||||
|
||||
print(suggestions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_doSearch() {
|
||||
val r = runBlocking {
|
||||
doSearch("language:korean")
|
||||
}
|
||||
|
||||
Log.d("PUPILD", r.take(10).toString())
|
||||
}
|
||||
|
||||
// @Test
|
||||
// fun test_getBlock() {
|
||||
// val galleryBlock = getGalleryBlock(2097576)
|
||||
//
|
||||
// print(galleryBlock)
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// fun test_getGallery() {
|
||||
// val gallery = getGallery(2097751)
|
||||
//
|
||||
// print(gallery)
|
||||
// }
|
||||
|
||||
@Test
|
||||
fun test_getGalleryInfo() {
|
||||
val info = getGalleryInfo(1469394)
|
||||
|
||||
print(info)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getReader() {
|
||||
val reader = getGalleryInfo(2128654)
|
||||
|
||||
Log.d("PUPILD", reader.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getImages() { runBlocking {
|
||||
val galleryID = 2128654
|
||||
|
||||
val images = getGalleryInfo(galleryID).files.map {
|
||||
imageUrlFromImage(galleryID, it,false)
|
||||
}
|
||||
|
||||
Log.d("PUPILD", images.toString())
|
||||
|
||||
// images.forEachIndexed { index, image ->
|
||||
// println("Testing $index/${images.size}: $image")
|
||||
// val response = client.newCall(
|
||||
// Request.Builder()
|
||||
// .url(image)
|
||||
// .header("Referer", "https://hitomi.la/")
|
||||
// .build()
|
||||
// ).execute()
|
||||
//
|
||||
// assertEquals(200, response.code())
|
||||
//
|
||||
// println("$index/${images.size} Passed")
|
||||
// }
|
||||
} }
|
||||
|
||||
// @Test
|
||||
// fun test_urlFromUrlFromHash() {
|
||||
// val url = urlFromUrlFromHash(1531795, GalleryFiles(
|
||||
// 212, "719d46a7556be0d0021c5105878507129b5b3308b02cf67f18901b69dbb3b5ef", 1, "00.jpg", 300
|
||||
// ), "webp")
|
||||
//
|
||||
// print(url)
|
||||
// }
|
||||
|
||||
// @Test
|
||||
// suspend fun test_doSearch_extreme() {
|
||||
// val query = "language:korean -tag:sample -female:humiliation -female:diaper -female:strap-on -female:squirting -female:lizard_girl -female:voyeurism -type:artistcg -female:blood -female:ryona -male:blood -male:ryona -female:crotch_tattoo -male:urethra_insertion -female:living_clothes -male:tentacles -female:slave -female:gag -male:gag -female:wooden_horse -male:exhibitionism -male:miniguy -female:mind_break -male:mind_break -male:unbirth -tag:scanmark -tag:no_penetration -tag:nudity_only -female:enema -female:brain_fuck -female:navel_fuck -tag:novel -tag:mosaic_censorship -tag:webtoon -male:rape -female:rape -female:yuri -male:anal -female:anal -female:futanari -female:huge_breasts -female:big_areolae -male:torture -male:stuck_in_wall -female:stuck_in_wall -female:torture -female:birth -female:pregnant -female:drugs -female:bdsm -female:body_writing -female:cbt -male:dark_skin -male:insect -female:insect -male:vore -female:vore -female:vomit -female:urination -female:urethra_insertion -tag:mmf_threesome -female:sex_toys -female:double_penetration -female:eggs -female:prolapse -male:smell -male:bestiality -female:bestiality -female:big_ass -female:milf -female:mother -male:dilf -male:netorare -female:netorare -female:cosplaying -female:filming -female:armpit_sex -female:armpit_licking -female:tickling -female:lactation -male:skinsuit -female:skinsuit -male:bbm -female:prostitution -female:double_penetration -female:females_only -male:males_only -female:tentacles -female:tentacles -female:stomach_deformation -female:hairy_armpits -female:large_insertions -female:mind_control -male:orc -female:dark_skin -male:yandere -female:yandere -female:scat -female:toddlercon -female:bbw -female:hairy -male:cuntboy -male:lactation -male:drugs -female:body_modification -female:monoeye -female:chikan -female:long_tongue -female:harness -female:fisting -female:glory_hole -female:latex -male:latex -female:unbirth -female:giantess -female:sole_dickgirl -female:robot -female:doll_joints -female:machine -tag:artbook -male:cbt -female:farting -male:farting -male:midget -female:midget -female:exhibitionism -male:monster -female:big_nipples -female:big_clit -female:gyaru -female:piercing -female:necrophilia -female:snuff -female:smell -male:cheating -female:cheating -male:snuff -female:harem -male:harem"
|
||||
// print(doSearch(query).size)
|
||||
// }
|
||||
|
||||
// @Test
|
||||
// suspend fun test_parse() {
|
||||
// print(doSearch("-male:yaoi -female:yaoi -female:loli").size)
|
||||
// }
|
||||
|
||||
// @Test
|
||||
// fun test_subdomainFromUrl() {
|
||||
// val galleryInfo = getGalleryInfo(1929109).files[2]
|
||||
// print(urlFromUrlFromHash(1929109, galleryInfo, "webp", null, "a"))
|
||||
// }
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="xyz.quaver.pupil">
|
||||
xmlns:tools="http://schemas.android.com/tools" >
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="21"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="21" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
@@ -22,10 +24,8 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:replace="android:theme"
|
||||
tools:ignore="UnusedAttribute">
|
||||
tools:ignore="UnusedAttribute" >
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
@@ -43,205 +43,23 @@
|
||||
|
||||
</provider>
|
||||
|
||||
<service android:name=".services.DownloadService"
|
||||
android:exported="false"/>
|
||||
<service android:name=".services.ImageCacheService"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.UpdateBroadcastReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<activity android:name=".ui.LockActivity" />
|
||||
<activity
|
||||
android:name=".ui.ReaderActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||
android:parentActivityName=".ui.MainActivity">
|
||||
<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:host="hitomi.la"
|
||||
android:pathPrefix="/galleries"
|
||||
android:scheme="http" />
|
||||
</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:host="hitomi.la"
|
||||
android:pathPrefix="/manga"
|
||||
android:scheme="http" />
|
||||
</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:host="hitomi.la"
|
||||
android:pathPrefix="/doujinshi"
|
||||
android:scheme="http" />
|
||||
</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:host="hitomi.la"
|
||||
android:pathPrefix="/cg"
|
||||
android:scheme="http" />
|
||||
</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:host="hitomi.la"
|
||||
android:pathPrefix="/reader"
|
||||
android:scheme="http" />
|
||||
</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:host="hitomi.la"
|
||||
android:pathPrefix="/galleries"
|
||||
android:scheme="https" />
|
||||
</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:host="hitomi.la"
|
||||
android:pathPrefix="/manga"
|
||||
android:scheme="https" />
|
||||
</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:host="hitomi.la"
|
||||
android:pathPrefix="/doujinshi"
|
||||
android:scheme="https" />
|
||||
</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:host="hitomi.la"
|
||||
android:pathPrefix="/cg"
|
||||
android:scheme="https" />
|
||||
</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:host="hitomi.la"
|
||||
android:pathPrefix="/reader"
|
||||
android:scheme="https" />
|
||||
</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:host="hiyobi.me"
|
||||
android:scheme="http"
|
||||
android:pathPrefix="/reader" />
|
||||
</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:host="hiyobi.me"
|
||||
android:pathPrefix="/reader"
|
||||
android:scheme="https" />
|
||||
</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:host="e-hentai.org"
|
||||
android:pathPrefix="/g"
|
||||
android:scheme="http" />
|
||||
</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:host="e-hentai.org"
|
||||
android:pathPrefix="/g"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.SettingsActivity"
|
||||
android:label="@string/settings_title">
|
||||
<tools:validation testUrl="http://ix.io/eer" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||
android:theme="@style/NoActionBarAppTheme">
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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 android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -18,122 +18,24 @@
|
||||
|
||||
package xyz.quaver.pupil
|
||||
|
||||
import android.app.*
|
||||
import android.app.Application
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.content.ContextCompat
|
||||
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.GooglePlayServicesRepairableException
|
||||
import com.google.android.gms.security.ProviderInstaller
|
||||
import com.google.firebase.analytics.FirebaseAnalytics
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.pupil.types.Tag
|
||||
import xyz.quaver.pupil.util.*
|
||||
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||
import xyz.quaver.setClient
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
typealias PupilInterceptor = (Interceptor.Chain) -> Response
|
||||
|
||||
lateinit var histories: SavedSet<Int>
|
||||
private set
|
||||
lateinit var favorites: SavedSet<Int>
|
||||
private set
|
||||
lateinit var favoriteTags: SavedSet<Tag>
|
||||
private set
|
||||
lateinit var searchHistory: SavedSet<String>
|
||||
private set
|
||||
|
||||
val interceptors = mutableMapOf<KClass<out Any>, PupilInterceptor>()
|
||||
|
||||
lateinit var clientBuilder: OkHttpClient.Builder
|
||||
|
||||
var clientHolder: OkHttpClient? = null
|
||||
val client: OkHttpClient
|
||||
get() = clientHolder ?: clientBuilder.build().also {
|
||||
clientHolder = it
|
||||
setClient(it)
|
||||
}
|
||||
import java.util.UUID
|
||||
|
||||
@HiltAndroidApp
|
||||
class Pupil : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
||||
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
||||
val userID = Preferences["user_id", ""].let { userID ->
|
||||
if (userID.isEmpty()) UUID.randomUUID().toString().also { Preferences["user_id"] = it }
|
||||
else userID
|
||||
}
|
||||
|
||||
FirebaseCrashlytics.getInstance().setUserId(userID)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
try {
|
||||
Preferences.get<String>("download_folder").also {
|
||||
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()
|
||||
|
||||
DownloadManager.getInstance(this).migrate()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Preferences.remove("download_folder")
|
||||
}
|
||||
|
||||
if (!Preferences["reset_secure", false]) {
|
||||
Preferences["security_mode"] = false
|
||||
Preferences["reset_secure"] = true
|
||||
}
|
||||
|
||||
histories = SavedSet(File(ContextCompat.getDataDir(this), "histories.json"), 0)
|
||||
favorites = SavedSet(File(ContextCompat.getDataDir(this), "favorites.json"), 0)
|
||||
favoriteTags = SavedSet(File(ContextCompat.getDataDir(this), "favorites_tags.json"), Tag.parse(""))
|
||||
searchHistory = SavedSet(File(ContextCompat.getDataDir(this), "search_histories.json"), "")
|
||||
|
||||
if (BuildConfig.DEBUG)
|
||||
FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false)
|
||||
|
||||
try {
|
||||
ProviderInstaller.installIfNeeded(this)
|
||||
} catch (e: GooglePlayServicesRepairableException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: GooglePlayServicesNotAvailableException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
BigImageViewer.initialize(FrescoImageLoader.with(this))
|
||||
FirebaseApp.initializeApp(this)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -167,12 +69,6 @@ class Pupil : Application() {
|
||||
})
|
||||
}
|
||||
|
||||
AppCompatDelegate.setDefaultNightMode(when (Preferences.get<Boolean>("dark_mode")) {
|
||||
true -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
false -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
})
|
||||
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,407 +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.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.Log
|
||||
import android.util.SparseBooleanArray
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||
import com.daimajia.swipe.SwipeLayout
|
||||
import com.daimajia.swipe.adapters.RecyclerSwipeAdapter
|
||||
import com.daimajia.swipe.interfaces.SwipeAdapterInterface
|
||||
import com.github.piasy.biv.loader.ImageLoader
|
||||
import kotlinx.android.synthetic.main.item_galleryblock.view.*
|
||||
import kotlinx.coroutines.*
|
||||
import xyz.quaver.hitomi.getGallery
|
||||
import xyz.quaver.hitomi.getReader
|
||||
import xyz.quaver.io.util.getChild
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.favoriteTags
|
||||
import xyz.quaver.pupil.favorites
|
||||
import xyz.quaver.pupil.types.Tag
|
||||
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 java.io.File
|
||||
|
||||
class GalleryBlockAdapter(private val galleries: List<Int>) : RecyclerSwipeAdapter<RecyclerView.ViewHolder>(), SwipeAdapterInterface {
|
||||
|
||||
enum class ViewType {
|
||||
NEXT,
|
||||
GALLERY,
|
||||
PREV
|
||||
}
|
||||
|
||||
var updateAll = true
|
||||
var thin: Boolean = Preferences["thin"]
|
||||
|
||||
inner class GalleryViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
|
||||
private var galleryID: Int = 0
|
||||
|
||||
init {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
while (updateAll) {
|
||||
updateProgress(view.context)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProgress(context: Context) {
|
||||
val cache = Cache.getInstance(context, galleryID)
|
||||
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
if (cache.metadata.reader == null) {
|
||||
view.galleryblock_progressbar_layout.visibility = View.GONE
|
||||
view.galleryblock_progress_complete.visibility = View.INVISIBLE
|
||||
return@launch
|
||||
}
|
||||
|
||||
with(view.galleryblock_progressbar) {
|
||||
val imageList = cache.metadata.imageList!!
|
||||
|
||||
progress = imageList.count { it != null }
|
||||
max = imageList.size
|
||||
|
||||
with(view.galleryblock_progressbar_layout) {
|
||||
if (visibility == View.GONE)
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
if (!imageList.contains(null)) {
|
||||
val downloadManager = DownloadManager.getInstance(context)
|
||||
|
||||
if (completeFlag.get(galleryID, false)) {
|
||||
with(view.galleryblock_progress_complete) {
|
||||
setImageResource(
|
||||
if (downloadManager.getDownloadFolder(galleryID) != null)
|
||||
R.drawable.ic_progressbar
|
||||
else R.drawable.ic_progressbar_cache
|
||||
)
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
} else {
|
||||
with(view.galleryblock_progress_complete) {
|
||||
setImageDrawable(AnimatedVectorDrawableCompat.create(context,
|
||||
if (downloadManager.getDownloadFolder(galleryID) != null)
|
||||
R.drawable.ic_progressbar_complete
|
||||
else R.drawable.ic_progressbar_complete_cache
|
||||
).apply {
|
||||
this?.start()
|
||||
})
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
completeFlag.put(galleryID, true)
|
||||
}
|
||||
} else
|
||||
view.galleryblock_progress_complete.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(galleryID: Int) {
|
||||
this.galleryID = galleryID
|
||||
updateProgress(view.context)
|
||||
|
||||
val cache = Cache.getInstance(view.context, galleryID)
|
||||
|
||||
val galleryBlock = runBlocking {
|
||||
cache.getGalleryBlock()
|
||||
} ?: return
|
||||
|
||||
with(view) {
|
||||
val resources = context.resources
|
||||
val languages = resources.getStringArray(R.array.languages).map {
|
||||
it.split("|").let { split ->
|
||||
Pair(split[0], split[1])
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
val artists = galleryBlock.artists
|
||||
val series = galleryBlock.series
|
||||
|
||||
galleryblock_thumbnail.apply {
|
||||
setOnClickListener {
|
||||
view.performClick()
|
||||
}
|
||||
setOnLongClickListener {
|
||||
view.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() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCacheHit(imageType: Int, image: File?) {}
|
||||
override fun onCacheMiss(imageType: Int, image: File?) {}
|
||||
override fun onFinish() {}
|
||||
override fun onProgress(progress: Int) {}
|
||||
override fun onStart() {}
|
||||
override fun onSuccess(image: File?) {}
|
||||
})
|
||||
ssiv?.recycle()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
cache.getThumbnail().let { launch(Dispatchers.Main) {
|
||||
showImage(it)
|
||||
} }
|
||||
}
|
||||
}
|
||||
|
||||
galleryblock_title.text = galleryBlock.title
|
||||
with(galleryblock_artist) {
|
||||
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(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
|
||||
}
|
||||
}
|
||||
|
||||
with(galleryblock_tag_group) {
|
||||
onClickListener = {
|
||||
onChipClickedHandler.forEach { callback ->
|
||||
callback.invoke(it)
|
||||
}
|
||||
}
|
||||
|
||||
tags.clear()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
tags.addAll(
|
||||
galleryBlock.relatedTags.sortedBy {
|
||||
val tag = Tag.parse(it)
|
||||
|
||||
if (favoriteTags.contains(tag))
|
||||
-1
|
||||
else
|
||||
when(Tag.parse(it).area) {
|
||||
"female" -> 0
|
||||
"male" -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.map {
|
||||
Tag.parse(it)
|
||||
}
|
||||
)
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
galleryblock_id.text = galleryBlock.id.toString()
|
||||
galleryblock_pagecount.text = "-"
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val pageCount = kotlin.runCatching {
|
||||
getReader(galleryBlock.id).galleryInfo.files.size
|
||||
}.getOrNull() ?: return@launch
|
||||
withContext(Dispatchers.Main) {
|
||||
galleryblock_pagecount.text = context.getString(R.string.galleryblock_pagecount, pageCount)
|
||||
}
|
||||
}
|
||||
|
||||
with(galleryblock_favorite) {
|
||||
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
|
||||
if (thin) {
|
||||
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)>()
|
||||
var onDownloadClickedHandler: ((Int) -> Unit)? = null
|
||||
var onDeleteClickedHandler: ((Int) -> Unit)? = null
|
||||
|
||||
var showNext = false
|
||||
var showPrev = false
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
|
||||
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) {
|
||||
if (holder is GalleryViewHolder) {
|
||||
val galleryID = galleries[position-(if (showPrev) 1 else 0)]
|
||||
|
||||
holder.bind(galleryID)
|
||||
|
||||
with(holder.view.galleryblock_primary) {
|
||||
setOnClickListener {
|
||||
holder.view.performClick()
|
||||
}
|
||||
setOnLongClickListener {
|
||||
holder.view.performLongClick()
|
||||
}
|
||||
}
|
||||
|
||||
holder.view.galleryblock_download.setOnClickListener {
|
||||
onDownloadClickedHandler?.invoke(position)
|
||||
}
|
||||
|
||||
holder.view.galleryblock_delete.setOnClickListener {
|
||||
onDeleteClickedHandler?.invoke(position)
|
||||
}
|
||||
|
||||
mItemManger.bindView(holder.view, position)
|
||||
|
||||
holder.view.galleryblock_swipe_layout.addSwipeListener(object: SwipeLayout.SwipeListener {
|
||||
override fun onStartOpen(layout: SwipeLayout?) {
|
||||
mItemManger.closeAllExcept(layout)
|
||||
|
||||
holder.view.galleryblock_download.text =
|
||||
if (DownloadManager.getInstance(holder.view.context).isDownloading(galleryID))
|
||||
holder.view.context.getString(android.R.string.cancel)
|
||||
else
|
||||
holder.view.context.getString(R.string.main_download)
|
||||
}
|
||||
|
||||
override fun onClose(layout: SwipeLayout?) {}
|
||||
override fun onHandRelease(layout: SwipeLayout?, xvel: Float, yvel: Float) {}
|
||||
override fun onOpen(layout: SwipeLayout?) {}
|
||||
override fun onStartClose(layout: SwipeLayout?) {}
|
||||
override fun onUpdate(layout: SwipeLayout?, leftOffset: Int, topOffset: Int) {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() =
|
||||
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,86 +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.recyclerview.widget.RecyclerView
|
||||
import kotlinx.android.synthetic.main.item_mirrors.view.*
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.util.Preferences
|
||||
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 {
|
||||
Preferences.get<String>("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
|
||||
|
||||
}
|
||||
@@ -1,250 +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.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.DiscretePathEffect
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.net.Uri
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.facebook.drawee.backends.pipeline.Fresco
|
||||
import com.facebook.drawee.controller.BaseControllerListener
|
||||
import com.facebook.drawee.drawable.ScalingUtils
|
||||
import com.facebook.drawee.interfaces.DraweeController
|
||||
import com.facebook.drawee.view.SimpleDraweeView
|
||||
import com.facebook.imagepipeline.image.ImageInfo
|
||||
import com.github.piasy.biv.view.BigImageView
|
||||
import com.github.piasy.biv.view.ImageShownCallback
|
||||
import com.github.piasy.biv.view.ImageViewFactory
|
||||
import kotlinx.android.synthetic.main.item_reader.view.*
|
||||
import kotlinx.coroutines.*
|
||||
import xyz.quaver.hitomi.Reader
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.ui.ReaderActivity
|
||||
import xyz.quaver.pupil.util.downloader.Cache
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ReaderAdapter(
|
||||
private val activity: ReaderActivity,
|
||||
private val galleryID: Int
|
||||
) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() {
|
||||
|
||||
var reader: Reader? = null
|
||||
|
||||
var isFullScreen = false
|
||||
|
||||
var onItemClickListener : (() -> (Unit))? = null
|
||||
|
||||
class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
|
||||
fun clear() {
|
||||
view.image.mainView.let {
|
||||
when (it) {
|
||||
is SubsamplingScaleImageView ->
|
||||
it.recycle()
|
||||
is SimpleDraweeView ->
|
||||
it.controller = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return LayoutInflater.from(parent.context).inflate(
|
||||
R.layout.item_reader, parent, false
|
||||
).let {
|
||||
with(it) {
|
||||
image.setImageViewFactory(FrescoImageViewFactory().apply {
|
||||
updateView = { imageInfo ->
|
||||
it.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||
dimensionRatio = "${imageInfo.width}:${imageInfo.height}"
|
||||
}
|
||||
}
|
||||
})
|
||||
image.setImageShownCallback(object : ImageShownCallback {
|
||||
override fun onMainImageShown() {
|
||||
it.image.mainView.let { v ->
|
||||
when (v) {
|
||||
is SubsamplingScaleImageView ->
|
||||
if (!isFullScreen) it.image.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onThumbnailShown() {}
|
||||
})
|
||||
image.setFailureImage(ContextCompat.getDrawable(context, R.drawable.image_broken_variant))
|
||||
image.setOnClickListener {
|
||||
this.performClick()
|
||||
}
|
||||
setOnClickListener {
|
||||
onItemClickListener?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
ViewHolder(it)
|
||||
}
|
||||
}
|
||||
|
||||
private var cache: Cache? = null
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.view as ConstraintLayout
|
||||
|
||||
if (cache == null)
|
||||
cache = Cache.getInstance(holder.view.context, galleryID)
|
||||
|
||||
if (!isFullScreen) {
|
||||
holder.view.setBackgroundResource(R.drawable.reader_item_boundary)
|
||||
holder.view.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||
height = 0
|
||||
dimensionRatio =
|
||||
"${reader!!.galleryInfo.files[position].width}:${reader!!.galleryInfo.files[position].height}"
|
||||
}
|
||||
} else {
|
||||
holder.view.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
holder.view.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||
height = ConstraintLayout.LayoutParams.MATCH_PARENT
|
||||
dimensionRatio = null
|
||||
}
|
||||
holder.view.background = null
|
||||
}
|
||||
|
||||
holder.view.reader_index.text = (position+1).toString()
|
||||
|
||||
val image = cache!!.getImage(position)
|
||||
val progress = activity.downloader?.progress?.get(galleryID)?.get(position)
|
||||
|
||||
if (progress?.isInfinite() == true && image != null) {
|
||||
holder.view.progress_group.visibility = View.INVISIBLE
|
||||
holder.view.image.showImage(image.uri)
|
||||
} else {
|
||||
holder.view.progress_group.visibility = View.VISIBLE
|
||||
holder.view.reader_item_progressbar.progress =
|
||||
if (progress?.isInfinite() == true)
|
||||
100
|
||||
else
|
||||
progress?.roundToInt() ?: 0
|
||||
|
||||
holder.clear()
|
||||
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
delay(1000)
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = reader?.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +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.adapters
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.piasy.biv.view.BigImageView
|
||||
import xyz.quaver.pupil.R
|
||||
|
||||
class ThumbnailAdapter(var thumbnails: List<String>) : RecyclerView.Adapter<ThumbnailAdapter.ViewHolder>() {
|
||||
|
||||
class ViewHolder(val view: BigImageView) : RecyclerView.ViewHolder(view) {
|
||||
fun clear() {
|
||||
view.ssiv?.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(BigImageView(parent.context).apply {
|
||||
setFailureImage(ContextCompat.getDrawable(context, R.drawable.image_broken_variant))
|
||||
})
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.view.showImage(Uri.parse(thumbnails[position]))
|
||||
}
|
||||
|
||||
override fun getItemCount() = thumbnails.size
|
||||
|
||||
override fun onViewRecycled(holder: ViewHolder) {
|
||||
holder.clear()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,52 +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.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
|
||||
|
||||
}
|
||||
24
app/src/main/java/xyz/quaver/pupil/di/SingletonModule.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
package xyz.quaver.pupil.di
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import xyz.quaver.pupil.networking.FileImageCache
|
||||
import xyz.quaver.pupil.networking.ImageCache
|
||||
import java.io.File
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object SingletonModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideImageCache(
|
||||
@ApplicationContext context: Context
|
||||
): ImageCache {
|
||||
return FileImageCache(File(context.cacheDir, "image_cache"))
|
||||
}
|
||||
}
|
||||
110
app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import android.os.BaseBundle
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
interface TagLike {
|
||||
fun toTag(): SearchQuery.Tag
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Artist(val artist: String): TagLike {
|
||||
override fun toTag() = SearchQuery.Tag("artist", artist)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Group(val group: String): TagLike {
|
||||
override fun toTag() = SearchQuery.Tag("group", group)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Series(@SerialName("parody") val series: String): TagLike {
|
||||
override fun toTag() = SearchQuery.Tag("series", series)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Character(val character: String): TagLike {
|
||||
override fun toTag() = SearchQuery.Tag("character", character)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GalleryTag(
|
||||
val tag: String,
|
||||
val female: String? = null,
|
||||
val male: String? = null
|
||||
): TagLike {
|
||||
override fun toTag() = SearchQuery.Tag(
|
||||
if (female.isNullOrEmpty() && male.isNullOrEmpty()) {
|
||||
"tag"
|
||||
} else if (male.isNullOrEmpty()) {
|
||||
"female"
|
||||
} else {
|
||||
"male"
|
||||
},
|
||||
tag
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Language(
|
||||
@SerialName("galleryid") val galleryID: String,
|
||||
val name: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GalleryFile(
|
||||
@SerialName("haswebp") val hasWebP: Int = 0,
|
||||
@SerialName("hasavif") val hasAVIF: Int = 0,
|
||||
@SerialName("hasjxl") val hasJXL: Int = 0,
|
||||
val height: Int,
|
||||
val width: Int,
|
||||
val hash: String,
|
||||
val name: String,
|
||||
) {
|
||||
fun writeToBundle(bundle: BaseBundle) {
|
||||
bundle.putInt("hasWebP", hasWebP)
|
||||
bundle.putInt("hasAVIF", hasAVIF)
|
||||
bundle.putInt("hasJXL", hasJXL)
|
||||
bundle.putInt("height", height)
|
||||
bundle.putInt("width", width)
|
||||
bundle.putString("hash", hash)
|
||||
bundle.putString("name", name)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromBundle(bundle: BaseBundle) = GalleryFile(
|
||||
bundle.getInt("hasWebP"),
|
||||
bundle.getInt("hasAVIF"),
|
||||
bundle.getInt("hasJXL"),
|
||||
bundle.getInt("height"),
|
||||
bundle.getInt("width"),
|
||||
bundle.getString("hash")!!,
|
||||
bundle.getString("name")!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GalleryInfo(
|
||||
val id: String,
|
||||
val title: String,
|
||||
@SerialName("japanese_title") val japaneseTitle: String? = null,
|
||||
val language: String? = null,
|
||||
val type: String,
|
||||
val date: String,
|
||||
val artists: List<Artist>? = null,
|
||||
val groups: List<Group>? = null,
|
||||
@SerialName("parodys") val series: List<Series>? = null,
|
||||
val tags: List<GalleryTag>? = null,
|
||||
val related: List<Int> = emptyList(),
|
||||
val languages: List<Language> = emptyList(),
|
||||
val characters: List<Character>? = null,
|
||||
@SerialName("scene_indexes") val sceneIndices: List<Int>? = emptyList(),
|
||||
val files: List<GalleryFile> = emptyList()
|
||||
)
|
||||
|
||||
@JvmName("joinToCapitalizedStringArtist")
|
||||
fun List<Artist>.joinToCapitalizedString() = joinToString { it.artist.replaceFirstChar(Char::titlecase) }
|
||||
@JvmName("joinToCapitalizedStringGroup")
|
||||
fun List<Group>.joinToCapitalizedString() = joinToString { it.group.replaceFirstChar(Char::titlecase) }
|
||||
@@ -0,0 +1,36 @@
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
|
||||
class GallerySearchSource(val query: SearchQuery?) {
|
||||
private var searchResult: List<Int>? = null
|
||||
private var job: Job? = null
|
||||
|
||||
suspend fun load(range: IntRange): Result<Pair<List<GalleryInfo>, Int>> = runCatching {
|
||||
val searchResult = searchResult ?: (
|
||||
HitomiHttpClient
|
||||
.search(query)
|
||||
.getOrThrow()
|
||||
.toList()
|
||||
.also { searchResult = it }
|
||||
)
|
||||
|
||||
val galleryResults = coroutineScope {
|
||||
searchResult.slice(range).map { galleryID ->
|
||||
async {
|
||||
HitomiHttpClient.getGalleryInfo(galleryID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val galleries = galleryResults.map { result ->
|
||||
result.await().getOrThrow()
|
||||
}
|
||||
|
||||
Pair(galleries, searchResult.size)
|
||||
}
|
||||
|
||||
fun cancel() = job?.cancel()
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.onDownload
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.Clock.System.now
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.IntBuffer
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
const val domain = "ltn.hitomi.la"
|
||||
const val nozomiExtension = ".nozomi"
|
||||
|
||||
const val compressedNozomiPrefix = "n"
|
||||
|
||||
const val B = 16
|
||||
const val indexDir = "tagindex"
|
||||
const val maxNodeSize = 464
|
||||
const val galleriesIndexDir = "galleriesindex"
|
||||
const val tagIndexDomain = "tagindex.hitomi.la"
|
||||
|
||||
const val separator = "-"
|
||||
const val extension = ".html"
|
||||
|
||||
data class Suggestion(
|
||||
val tag: SearchQuery.Tag,
|
||||
val count: Int,
|
||||
)
|
||||
|
||||
fun IntBuffer.toSet(): Set<Int> {
|
||||
val result = LinkedHashSet<Int>()
|
||||
|
||||
while (this.hasRemaining()) {
|
||||
result.add(this.get())
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private val json = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
class ImagePathResolver(ggjs: String) {
|
||||
private val defaultPrefix: Int = Regex("var o = (\\d)").find(ggjs)!!.groupValues[1].toInt()
|
||||
private val prefixMap: Map<Int, Int> = buildMap {
|
||||
val o = Regex("o = (\\d); break;").find(ggjs)!!.groupValues[1].toInt()
|
||||
|
||||
Regex("case (\\d+):").findAll(ggjs).forEach {
|
||||
val case = it.groupValues[1].toInt()
|
||||
put(case, o)
|
||||
}
|
||||
}
|
||||
|
||||
private val imageBaseDir: String = Regex("b: '(.+)'").find(ggjs)!!.groupValues[1]
|
||||
|
||||
fun decodeSubdomain(hash: String, thumbnail: Boolean): String {
|
||||
val key = (hash.last() + hash.dropLast(1).takeLast(2)).toInt(16)
|
||||
val base = if (thumbnail) "tn" else "a"
|
||||
|
||||
return "${'a' + (prefixMap[key] ?: defaultPrefix)}$base"
|
||||
}
|
||||
|
||||
fun decodeImagePath(hash: String, thumbnail: Boolean): String {
|
||||
val key = hash.last() to hash.dropLast(1).takeLast(2)
|
||||
|
||||
return if (thumbnail) {
|
||||
"${key.first}/${key.second}/$hash"
|
||||
} else {
|
||||
"$imageBaseDir/${(key.first + key.second).toInt(16)}/$hash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExpirableEntry<T>(
|
||||
private val expiryDuration: Duration,
|
||||
private val action: suspend () -> T,
|
||||
) {
|
||||
private var value: T? = null
|
||||
private var expiresAt: Instant = now()
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun getValue(): T = mutex.withLock {
|
||||
value?.let { if (expiresAt > now()) value else null } ?: action().also {
|
||||
expiresAt = now() + expiryDuration
|
||||
value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object HitomiHttpClient {
|
||||
private val httpClient = HttpClient(OkHttp) {
|
||||
engine {
|
||||
config {
|
||||
sslSocketFactory(SSLSettings.sslContext!!.socketFactory, SSLSettings.trustManager!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var imagePathResolver = ExpirableEntry(1.minutes) {
|
||||
ImagePathResolver(httpClient.get("https://ltn.hitomi.la/gg.js").bodyAsText())
|
||||
}
|
||||
|
||||
private val tagIndexVersion = ExpirableEntry(1.minutes) { getIndexVersion("tagindex") }
|
||||
private val galleriesIndexVersion =
|
||||
ExpirableEntry(1.minutes) { getIndexVersion("galleriesindex") }
|
||||
|
||||
private suspend fun getIndexVersion(name: String): String = withContext(Dispatchers.IO) {
|
||||
httpClient.get("https://$domain/$name/version?_=${System.currentTimeMillis()}").bodyAsText()
|
||||
}
|
||||
|
||||
private suspend fun getURLAtRange(url: String, range: LongRange): ByteBuffer {
|
||||
val response: HttpResponse = withContext(Dispatchers.IO) {
|
||||
httpClient.get(url) {
|
||||
header("Range", "bytes=${range.first}-${range.last}")
|
||||
}
|
||||
}
|
||||
|
||||
val result: ByteArray = response.body()
|
||||
|
||||
return ByteBuffer.wrap(result)
|
||||
}
|
||||
|
||||
private suspend fun getNodeAtAddress(field: String, address: Long): Node {
|
||||
val url = when (field) {
|
||||
"galleries" -> "https://$domain/$galleriesIndexDir/galleries.${galleriesIndexVersion.getValue()}.index"
|
||||
"languages" -> "https://$domain/$galleriesIndexDir/languages.${galleriesIndexVersion.getValue()}.index"
|
||||
"nozomiurl" -> "https://$domain/$galleriesIndexDir/nozomiurl.${galleriesIndexVersion.getValue()}.index"
|
||||
else -> "https://$domain/$indexDir/$field.${tagIndexVersion.getValue()}.index"
|
||||
}
|
||||
|
||||
return Node.decodeNode(
|
||||
getURLAtRange(url, address..<address + maxNodeSize)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun bSearch(
|
||||
field: String,
|
||||
key: Node.Key,
|
||||
node: Node,
|
||||
): Node.Data? {
|
||||
if (node.keys.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val (matched, index) = node.locateKey(key)
|
||||
|
||||
if (matched) {
|
||||
return node.datas[index]
|
||||
} else if (node.isLeaf) {
|
||||
return null
|
||||
}
|
||||
|
||||
val nextNode = getNodeAtAddress(field, node.subNodeAddresses[index])
|
||||
return bSearch(field, key, nextNode)
|
||||
}
|
||||
|
||||
private suspend fun getGalleryIDsFromData(offset: Long, length: Int): IntBuffer {
|
||||
val url =
|
||||
"https://$domain/$galleriesIndexDir/galleries.${galleriesIndexVersion.getValue()}.data"
|
||||
if (length > 100000000 || length <= 0) {
|
||||
error("length $length is too long")
|
||||
}
|
||||
|
||||
return getURLAtRange(url, offset until (offset + length)).asIntBuffer()
|
||||
}
|
||||
|
||||
private fun encodeSearchQueryForUrl(s: Char) =
|
||||
when (s) {
|
||||
' ' -> "_"
|
||||
'/' -> "slash"
|
||||
'.' -> "dot"
|
||||
else -> s.toString()
|
||||
}
|
||||
|
||||
private fun sanitize(s: String) = s.replace(Regex("[/#]"), "")
|
||||
|
||||
private suspend fun getGalleryIDsFromNozomi(
|
||||
area: String?,
|
||||
tag: String,
|
||||
language: String,
|
||||
): IntBuffer {
|
||||
val nozomiAddress = if (area == null) {
|
||||
"https://$domain/$compressedNozomiPrefix/$tag-$language$nozomiExtension"
|
||||
} else {
|
||||
"https://$domain/$compressedNozomiPrefix/$area/$tag-$language$nozomiExtension"
|
||||
}
|
||||
|
||||
val response: HttpResponse = withContext(Dispatchers.IO) {
|
||||
httpClient.get(nozomiAddress)
|
||||
}
|
||||
|
||||
val result: ByteArray = response.body()
|
||||
|
||||
return ByteBuffer.wrap(result).asIntBuffer()
|
||||
}
|
||||
|
||||
private suspend fun getGalleryIDsForQuery(
|
||||
query: SearchQuery.Tag,
|
||||
language: String = "all",
|
||||
): IntBuffer = when (query.namespace) {
|
||||
"female", "male" -> getGalleryIDsFromNozomi("tag", query.toString(), language)
|
||||
"language" -> getGalleryIDsFromNozomi(null, "index", query.tag)
|
||||
null -> {
|
||||
val key = Node.Key(query.tag)
|
||||
|
||||
val node = getNodeAtAddress("galleries", 0)
|
||||
val data = bSearch("galleries", key, node)
|
||||
|
||||
if (data != null) getGalleryIDsFromData(
|
||||
data.offset,
|
||||
data.length
|
||||
) else IntBuffer.allocate(0)
|
||||
}
|
||||
|
||||
else -> getGalleryIDsFromNozomi(query.namespace, query.tag, language)
|
||||
}
|
||||
|
||||
suspend fun getSuggestionsForQuery(query: SearchQuery.Tag): Result<List<Suggestion>> =
|
||||
runCatching {
|
||||
val field = query.namespace ?: "global"
|
||||
val chars = query.tag.map(::encodeSearchQueryForUrl)
|
||||
|
||||
val suggestions = json.parseToJsonElement(
|
||||
withContext(Dispatchers.IO) {
|
||||
httpClient.get(
|
||||
"https://$tagIndexDomain/$field${
|
||||
if (chars.isNotEmpty()) "/${
|
||||
chars.joinToString(
|
||||
"/"
|
||||
)
|
||||
}" else ""
|
||||
}.json"
|
||||
).bodyAsText()
|
||||
}
|
||||
)
|
||||
|
||||
buildList {
|
||||
suggestions.jsonArray.forEach { suggestionRaw ->
|
||||
val suggestion = suggestionRaw.jsonArray
|
||||
if (suggestion.size < 3) {
|
||||
return@forEach
|
||||
}
|
||||
val namespace = suggestion[2].jsonPrimitive.contentOrNull ?: ""
|
||||
|
||||
val tag =
|
||||
sanitize(suggestion[0].jsonPrimitive.contentOrNull ?: return@forEach)
|
||||
|
||||
add(
|
||||
Suggestion(
|
||||
SearchQuery.Tag(
|
||||
namespace,
|
||||
tag
|
||||
),
|
||||
suggestion[1].jsonPrimitive.contentOrNull?.toIntOrNull() ?: 0,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getGalleryInfo(galleryID: Int) = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
json.decodeFromString<GalleryInfo>(
|
||||
httpClient.get("https://$domain/galleries/$galleryID.js").bodyAsText()
|
||||
.replace("var galleryinfo = ", "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: SearchQuery?): Result<Set<Int>> = runCatching {
|
||||
when (query) {
|
||||
is SearchQuery.Tag -> getGalleryIDsForQuery(query).toSet()
|
||||
is SearchQuery.Not -> coroutineScope {
|
||||
val allGalleries = async {
|
||||
getGalleryIDsFromNozomi(null, "index", "all")
|
||||
}
|
||||
|
||||
val queriedGalleries = search(query.query).getOrThrow()
|
||||
|
||||
val result = LinkedHashSet<Int>()
|
||||
|
||||
with(allGalleries.await()) {
|
||||
while (this.hasRemaining()) {
|
||||
val gallery = this.get()
|
||||
|
||||
if (gallery in queriedGalleries) {
|
||||
result.add(gallery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
is SearchQuery.And -> coroutineScope {
|
||||
val queries = query.queries.map { query ->
|
||||
async {
|
||||
search(query).getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
val result = queries.first().await().toMutableSet()
|
||||
|
||||
queries.drop(1).forEach {
|
||||
val queryResult = it.await()
|
||||
|
||||
result.retainAll(queryResult)
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
is SearchQuery.Or -> coroutineScope {
|
||||
val queries = query.queries.map { query ->
|
||||
async {
|
||||
search(query).getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
val result = LinkedHashSet<Int>()
|
||||
|
||||
queries.forEach {
|
||||
val queryResult = it.await()
|
||||
result.addAll(queryResult)
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
null -> getGalleryIDsFromNozomi(null, "index", "all").toSet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getImageURL(galleryFile: GalleryFile, thumbnail: Boolean = false): List<String> =
|
||||
buildList {
|
||||
val imagePathResolver = imagePathResolver.getValue()
|
||||
|
||||
listOf("webp", "avif", "jxl").forEach { type ->
|
||||
val available = when {
|
||||
thumbnail && type != "jxl" -> true
|
||||
type == "webp" -> galleryFile.hasWebP != 0
|
||||
type == "avif" -> galleryFile.hasAVIF != 0
|
||||
!thumbnail && type == "jxl" -> galleryFile.hasJXL != 0
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (!available) return@forEach
|
||||
|
||||
val url = buildString {
|
||||
append("https://")
|
||||
append(imagePathResolver.decodeSubdomain(galleryFile.hash, thumbnail))
|
||||
append(".hitomi.la/")
|
||||
append(type)
|
||||
if (thumbnail) append("bigtn")
|
||||
append('/')
|
||||
append(imagePathResolver.decodeImagePath(galleryFile.hash, thumbnail))
|
||||
append('.')
|
||||
append(type)
|
||||
}
|
||||
|
||||
add(url)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadImage(
|
||||
galleryFile: GalleryFile,
|
||||
thumbnail: Boolean = false,
|
||||
acceptImage: (String) -> Boolean = { true },
|
||||
onDownload: (bytesSentTotal: Long, contentLength: Long?) -> Unit = { _, _ -> },
|
||||
): Result<Pair<ByteReadChannel, String>> {
|
||||
return runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val url = getImageURL(galleryFile, thumbnail).firstOrNull(acceptImage)
|
||||
?: error("No available image")
|
||||
val channel: ByteReadChannel = httpClient.get(url) { onDownload(onDownload) }.body()
|
||||
Pair(channel, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
135
app/src/main/java/xyz/quaver/pupil/networking/ImageCache.kt
Normal file
@@ -0,0 +1,135 @@
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import io.ktor.util.cio.writeChannel
|
||||
import io.ktor.utils.io.copyAndClose
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
sealed class ImageLoadProgress {
|
||||
data object NotStarted : ImageLoadProgress()
|
||||
data class Progress(val bytesSent: Long, val contentLength: Long?) : ImageLoadProgress()
|
||||
data class Finished(val file: File) : ImageLoadProgress()
|
||||
data class Error(val exception: Throwable) : ImageLoadProgress()
|
||||
}
|
||||
|
||||
interface ImageCache {
|
||||
suspend fun load(
|
||||
galleryFile: GalleryFile,
|
||||
forceDownload: Boolean = false,
|
||||
): StateFlow<ImageLoadProgress>
|
||||
|
||||
suspend fun free(vararg files: GalleryFile)
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
class FileImageCache(
|
||||
private val cacheDir: File,
|
||||
private val cacheLimit: Long = 128 * 1024 * 1024, // 128MB
|
||||
) : ImageCache {
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val requests = mutableMapOf<String, Pair<Job, StateFlow<ImageLoadProgress>>>()
|
||||
private val activeFiles = mutableMapOf<String, File>()
|
||||
|
||||
private suspend fun cleanup() = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val size = cacheDir.listFiles()?.sumOf { it.length() } ?: 0
|
||||
|
||||
if (size > cacheLimit) {
|
||||
cacheDir.listFiles { file ->
|
||||
file.nameWithoutExtension !in activeFiles
|
||||
}?.forEach { file ->
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun free(vararg files: GalleryFile) = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
files.forEach { file ->
|
||||
val hash = file.hash
|
||||
|
||||
requests[hash]?.let { (job, _) ->
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
requests.remove(hash)
|
||||
activeFiles.remove(hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear(): Unit = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
requests.forEach { _, (job, _) -> job.cancel() }
|
||||
activeFiles.clear()
|
||||
cacheDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun load(
|
||||
galleryFile: GalleryFile,
|
||||
forceDownload: Boolean,
|
||||
): StateFlow<ImageLoadProgress> {
|
||||
val hash = galleryFile.hash
|
||||
|
||||
mutex.withLock {
|
||||
val file = activeFiles[hash]
|
||||
if (!forceDownload && file != null) {
|
||||
return MutableStateFlow(ImageLoadProgress.Finished(file))
|
||||
}
|
||||
}
|
||||
|
||||
cleanup()
|
||||
|
||||
mutex.withLock {
|
||||
requests[hash]?.first?.cancelAndJoin()
|
||||
activeFiles[hash]?.delete()
|
||||
|
||||
val flow = MutableStateFlow<ImageLoadProgress>(ImageLoadProgress.NotStarted)
|
||||
val job = coroutineScope {
|
||||
launch {
|
||||
runCatching {
|
||||
val (channel, url) = HitomiHttpClient.loadImage(galleryFile) { sent, total ->
|
||||
flow.value = ImageLoadProgress.Progress(sent, total)
|
||||
}.onFailure {
|
||||
FirebaseCrashlytics.getInstance().recordException(it)
|
||||
flow.value = ImageLoadProgress.Error(it)
|
||||
}.getOrThrow()
|
||||
|
||||
val file = File(cacheDir, "$hash.${url.substringAfterLast('.')}")
|
||||
|
||||
mutex.withLock {
|
||||
activeFiles.put(hash, file)
|
||||
}
|
||||
|
||||
channel.copyAndClose(file.writeChannel())
|
||||
|
||||
file
|
||||
}.onSuccess { file ->
|
||||
flow.value = ImageLoadProgress.Finished(file)
|
||||
}.onFailure {
|
||||
activeFiles.remove(hash)
|
||||
FirebaseCrashlytics.getInstance().recordException(it)
|
||||
flow.value = ImageLoadProgress.Error(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requests[hash] = job to flow
|
||||
|
||||
return flow
|
||||
}
|
||||
}
|
||||
}
|
||||
107
app/src/main/java/xyz/quaver/pupil/networking/Node.kt
Normal file
@@ -0,0 +1,107 @@
|
||||
@file:OptIn(ExperimentalUnsignedTypes::class)
|
||||
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.min
|
||||
|
||||
private fun sha256(data: ByteArray): ByteArray =
|
||||
MessageDigest.getInstance("SHA-256").digest(data)
|
||||
|
||||
private fun hashTerm(term: String): UByteArray =
|
||||
sha256(term.toByteArray()).sliceArray(0..<4).toUByteArray()
|
||||
|
||||
data class Node(
|
||||
val keys: List<Key>,
|
||||
val datas: List<Data>,
|
||||
val subNodeAddresses: List<Long>
|
||||
) {
|
||||
data class Key(
|
||||
private val key: UByteArray
|
||||
): Comparable<Key> {
|
||||
|
||||
constructor(term: String): this(hashTerm(term))
|
||||
|
||||
override fun compareTo(other: Key): Int {
|
||||
val minSize = min(this.key.size, other.key.size)
|
||||
|
||||
for (i in 0..<minSize) {
|
||||
if (this.key[i] < other.key[i]) {
|
||||
return -1
|
||||
} else if(this.key[i] > other.key[i]) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Key
|
||||
|
||||
return key.contentEquals(other.key)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return key.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
data class Data(
|
||||
val offset: Long,
|
||||
val length: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun decodeNode(buffer: ByteBuffer): Node {
|
||||
val numberOfKeys = buffer.int
|
||||
val keys = mutableListOf<Node.Key>()
|
||||
|
||||
for (i in 0..<numberOfKeys) {
|
||||
val keySize = buffer.int
|
||||
|
||||
val key = ByteArray(keySize)
|
||||
buffer.get(key)
|
||||
|
||||
keys.add(Node.Key(key.toUByteArray()))
|
||||
}
|
||||
|
||||
val numberOfDatas = buffer.int
|
||||
val datas = mutableListOf<Data>()
|
||||
|
||||
for (i in 0..<numberOfDatas) {
|
||||
val offset = buffer.long
|
||||
val length = buffer.int
|
||||
|
||||
datas.add(Data(offset, length))
|
||||
}
|
||||
|
||||
val numberOfSubNodeAddresses = B+1
|
||||
val subNodeAddresses = mutableListOf<Long>()
|
||||
|
||||
for (i in 0..<numberOfSubNodeAddresses) {
|
||||
val subNodeAddress = buffer.long
|
||||
subNodeAddresses.add(subNodeAddress)
|
||||
}
|
||||
|
||||
return Node(keys, datas, subNodeAddresses)
|
||||
}
|
||||
}
|
||||
|
||||
val isLeaf: Boolean = subNodeAddresses.all { it == 0L }
|
||||
|
||||
fun locateKey(target: Key): Pair<Boolean, Int> {
|
||||
val index = keys.indexOfFirst { key -> target <= key }
|
||||
|
||||
if (index == -1) {
|
||||
return Pair(false, keys.size)
|
||||
}
|
||||
|
||||
return Pair(keys[index] == target, index)
|
||||
}
|
||||
}
|
||||
|
||||
80
app/src/main/java/xyz/quaver/pupil/networking/SSL.kt
Normal file
@@ -0,0 +1,80 @@
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import android.content.res.Resources
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.security.KeyStore
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
const val ISRG_ROOT_X1 = """-----BEGIN CERTIFICATE-----
|
||||
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||
-----END CERTIFICATE-----"""
|
||||
|
||||
object SSLSettings {
|
||||
val keyStore: KeyStore by lazy {
|
||||
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||
load(null, null)
|
||||
|
||||
val certificateFactory = CertificateFactory.getInstance("X.509")
|
||||
val certificate = certificateFactory.generateCertificate(ISRG_ROOT_X1.byteInputStream())
|
||||
|
||||
setCertificateEntry("isrgrootx1", certificate)
|
||||
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
|
||||
init(null as KeyStore?)
|
||||
trustManagers.filterIsInstance<X509TrustManager>().forEach { trustManager ->
|
||||
trustManager.acceptedIssuers.forEach { acceptedIssuer ->
|
||||
setCertificateEntry(acceptedIssuer.subjectDN.name, acceptedIssuer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val trustManagerFactory: TrustManagerFactory? by lazy {
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
|
||||
init(keyStore)
|
||||
}
|
||||
}
|
||||
|
||||
val sslContext: SSLContext? by lazy {
|
||||
SSLContext.getInstance("TLS").apply {
|
||||
init(null, trustManagerFactory?.trustManagers, null)
|
||||
}
|
||||
}
|
||||
|
||||
val trustManager: X509TrustManager? by lazy {
|
||||
trustManagerFactory?.trustManagers?.filterIsInstance<X509TrustManager>()?.firstOrNull()
|
||||
}
|
||||
}
|
||||
90
app/src/main/java/xyz/quaver/pupil/networking/SearchQuery.kt
Normal file
@@ -0,0 +1,90 @@
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
||||
val validNamespace = listOf(
|
||||
"female",
|
||||
"male",
|
||||
"artist",
|
||||
"group",
|
||||
"character",
|
||||
"series",
|
||||
"type",
|
||||
"language",
|
||||
"tag"
|
||||
)
|
||||
|
||||
class SearchQueryPreviewParameterProvider: PreviewParameterProvider<SearchQuery> {
|
||||
override val values = sequenceOf(
|
||||
SearchQuery.And(listOf(
|
||||
SearchQuery.Or(listOf(
|
||||
SearchQuery.And(listOf(
|
||||
SearchQuery.Tag("language", "thisisareallylongtagyoucantevenseetheendofthis"),
|
||||
SearchQuery.Tag("language", "korean"),
|
||||
SearchQuery.Tag("female", "unusual pupil"),
|
||||
SearchQuery.Tag("female", "collar")
|
||||
)),
|
||||
SearchQuery.And(listOf(
|
||||
SearchQuery.Tag("language", "japanese"),
|
||||
SearchQuery.Tag("female", "unusual pupil"),
|
||||
SearchQuery.Tag("female", "collar")
|
||||
))
|
||||
)),
|
||||
SearchQuery.Not(
|
||||
SearchQuery.And(listOf(
|
||||
SearchQuery.Tag("male", "yaoi"),
|
||||
SearchQuery.Tag("group", "zenmai kourogi")
|
||||
))
|
||||
)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
sealed interface SearchQuery {
|
||||
data class Tag(
|
||||
val namespace: String? = null,
|
||||
val tag: String
|
||||
): SearchQuery, TagLike {
|
||||
companion object {
|
||||
fun parseTag(tag: String): Tag {
|
||||
val splitTag = tag.split(':', limit = 1)
|
||||
|
||||
return if (splitTag.size == 1) {
|
||||
Tag(null, tag)
|
||||
} else {
|
||||
Tag(splitTag[0], splitTag[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString() = if (namespace == null) tag else "$namespace:$tag"
|
||||
|
||||
override fun toTag() = this
|
||||
}
|
||||
|
||||
|
||||
data class And(
|
||||
val queries: List<SearchQuery>
|
||||
): SearchQuery {
|
||||
init {
|
||||
if (queries.isEmpty()) {
|
||||
error("queries cannot be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Or(
|
||||
val queries: List<SearchQuery>
|
||||
): SearchQuery {
|
||||
init {
|
||||
if (queries.isEmpty()) {
|
||||
error("queries cannot be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Not(
|
||||
val query: SearchQuery
|
||||
): SearchQuery
|
||||
|
||||
}
|
||||
@@ -1,93 +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.receiver
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.util.Preferences
|
||||
import java.io.File
|
||||
|
||||
class UpdateBroadcastReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
context ?: return
|
||||
|
||||
when (intent?.action) {
|
||||
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
|
||||
|
||||
// Validate download
|
||||
val downloadID: Long = Preferences["update_download_id"]
|
||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2) != downloadID)
|
||||
return
|
||||
|
||||
// Get target uri
|
||||
|
||||
val query = DownloadManager.Query()
|
||||
.setFilterById(downloadID)
|
||||
|
||||
val uri = downloadManager.query(query).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)).let {
|
||||
val uri = Uri.parse(it)
|
||||
|
||||
when (uri.scheme) {
|
||||
"file" ->
|
||||
FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!)
|
||||
)
|
||||
"content" -> uri
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
} else
|
||||
null
|
||||
} ?: return
|
||||
|
||||
// Build Notification
|
||||
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
|
||||
}, 0)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, "update")
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle(context.getText(R.string.update_download_completed))
|
||||
.setContentText(context.getText(R.string.update_download_completed_description))
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(R.id.notification_id_update, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,425 +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.services
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import androidx.core.content.ContextCompat
|
||||
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.*
|
||||
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 interceptor: PupilInterceptor = { 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()
|
||||
}
|
||||
//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) {
|
||||
if (e.message?.contains("cancel", true) == false) {
|
||||
val galleryID = (call.request().tag() as Tag).galleryID
|
||||
|
||||
// Retry
|
||||
cancel(galleryID)
|
||||
download(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() }.body()?.use { it.bytes() } ?: throw Exception()
|
||||
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 {
|
||||
cancel(galleryID)
|
||||
download(galleryID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 reader = cache.getReader()
|
||||
|
||||
// Gallery doesn't exist
|
||||
if (reader == null) {
|
||||
delete(galleryID)
|
||||
progress[galleryID] = mutableListOf()
|
||||
return@launch
|
||||
}
|
||||
|
||||
histories.add(galleryID)
|
||||
|
||||
progress[galleryID] = MutableList(reader.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(reader.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)
|
||||
}
|
||||
}
|
||||
|
||||
reader.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.services
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.job.JobParameters
|
||||
import android.app.job.JobService
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.ktor.util.cio.writeChannel
|
||||
import io.ktor.util.collections.ConcurrentMap
|
||||
import io.ktor.util.collections.ConcurrentSet
|
||||
import io.ktor.utils.io.copyAndClose
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import xyz.quaver.pupil.Pupil
|
||||
import xyz.quaver.pupil.networking.GalleryFile
|
||||
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||
import java.io.File
|
||||
|
||||
@SuppressLint("SpecifyJobSchedulerIdRange")
|
||||
@AndroidEntryPoint
|
||||
class ImageCacheService : JobService() {
|
||||
override fun onStartJob(params: JobParameters?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onStopJob(params: JobParameters?): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
22
app/src/main/java/xyz/quaver/pupil/types/SendLogException.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2022 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.types
|
||||
|
||||
class SendLogException : Exception()
|
||||
class JavascriptException(message: String?) : Exception(message)
|
||||
@@ -1,50 +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.types
|
||||
|
||||
import kotlinx.android.parcel.IgnoredOnParcel
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
|
||||
import xyz.quaver.hitomi.Suggestion
|
||||
import xyz.quaver.pupil.util.translations
|
||||
|
||||
@Parcelize
|
||||
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)
|
||||
|
||||
@IgnoredOnParcel
|
||||
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
|
||||
@@ -1,67 +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
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,279 +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.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.os.Bundle
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.andrognito.patternlockview.PatternLockView
|
||||
import com.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.ui.fragment.PINLockFragment
|
||||
import xyz.quaver.pupil.ui.fragment.PatternLockFragment
|
||||
import xyz.quaver.pupil.util.Lock
|
||||
import xyz.quaver.pupil.util.LockManager
|
||||
import xyz.quaver.pupil.util.Preferences
|
||||
|
||||
private var lastUnlocked = 0L
|
||||
class LockActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var lockManager: LockManager
|
||||
private var mode: String? = null
|
||||
|
||||
private val patternLockFragment = PatternLockFragment().apply {
|
||||
var lastPass = ""
|
||||
onPatternDrawn = {
|
||||
when(mode) {
|
||||
null -> {
|
||||
val result = lockManager.check(it)
|
||||
|
||||
if (result == true) {
|
||||
lastUnlocked = System.currentTimeMillis()
|
||||
setResult(Activity.RESULT_OK)
|
||||
finish()
|
||||
} else
|
||||
lock_pattern_view.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
||||
}
|
||||
"add_lock" -> {
|
||||
if (lastPass.isEmpty()) {
|
||||
lastPass = it
|
||||
|
||||
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
|
||||
} else {
|
||||
if (lastPass == it) {
|
||||
LockManager(context!!).add(Lock.generate(Lock.Type.PATTERN, it))
|
||||
finish()
|
||||
} else {
|
||||
lock_pattern_view.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
||||
lastPass = ""
|
||||
|
||||
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val pinLockFragment = PINLockFragment().apply {
|
||||
var lastPass = ""
|
||||
onPINEntered = {
|
||||
when(mode) {
|
||||
null -> {
|
||||
val result = lockManager.check(it)
|
||||
|
||||
if (result == true) {
|
||||
lastUnlocked = System.currentTimeMillis()
|
||||
setResult(Activity.RESULT_OK)
|
||||
finish()
|
||||
} else {
|
||||
indicator_dots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
|
||||
setAnimationListener(object: Animation.AnimationListener {
|
||||
override fun onAnimationEnd(animation: Animation?) {
|
||||
pin_lock_view.resetPinLockView()
|
||||
pin_lock_view.isEnabled = true
|
||||
}
|
||||
|
||||
override fun onAnimationStart(animation: Animation?) {
|
||||
pin_lock_view.isEnabled = false
|
||||
}
|
||||
|
||||
override fun onAnimationRepeat(animation: Animation?) {
|
||||
// Do Nothing
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
"add_lock" -> {
|
||||
if (lastPass.isEmpty()) {
|
||||
lastPass = it
|
||||
|
||||
pin_lock_view.resetPinLockView()
|
||||
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
|
||||
} else {
|
||||
if (lastPass == it) {
|
||||
LockManager(context!!).add(Lock.generate(Lock.Type.PIN, it))
|
||||
finish()
|
||||
} else {
|
||||
indicator_dots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
|
||||
setAnimationListener(object: Animation.AnimationListener {
|
||||
override fun onAnimationEnd(animation: Animation?) {
|
||||
pin_lock_view.resetPinLockView()
|
||||
pin_lock_view.isEnabled = true
|
||||
}
|
||||
|
||||
override fun onAnimationStart(animation: Animation?) {
|
||||
pin_lock_view.isEnabled = false
|
||||
}
|
||||
|
||||
override fun onAnimationRepeat(animation: Animation?) {
|
||||
// Do Nothing
|
||||
}
|
||||
})
|
||||
})
|
||||
lastPass = ""
|
||||
|
||||
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBiometricPrompt() {
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(getText(R.string.settings_lock_fingerprint_prompt))
|
||||
.setSubtitle(getText(R.string.settings_lock_fingerprint_prompt_subtitle))
|
||||
.setNegativeButtonText(getText(android.R.string.cancel))
|
||||
.setConfirmationRequired(false)
|
||||
.build()
|
||||
|
||||
val biometricPrompt = BiometricPrompt(this, ContextCompat.getMainExecutor(this),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
lastUnlocked = System.currentTimeMillis()
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// Displays the "log in" prompt.
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_lock)
|
||||
|
||||
lockManager = try {
|
||||
LockManager(this)
|
||||
} catch (e: Exception) {
|
||||
AlertDialog.Builder(this).apply {
|
||||
setTitle(R.string.warning)
|
||||
setMessage(R.string.lock_corrupted)
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
finish()
|
||||
}
|
||||
}.show()
|
||||
return
|
||||
}
|
||||
|
||||
mode = intent.getStringExtra("mode")
|
||||
val force = intent.getBooleanExtra("force", false)
|
||||
|
||||
when(mode) {
|
||||
null -> {
|
||||
if (lockManager.isEmpty()) {
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
if (System.currentTimeMillis() - lastUnlocked < 5*60*1000 && !force) {
|
||||
lastUnlocked = System.currentTimeMillis()
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
Preferences["lock_fingerprint"]
|
||||
&& BiometricManager.from(this).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS
|
||||
) {
|
||||
lock_fingerprint.apply {
|
||||
isEnabled = true
|
||||
setOnClickListener {
|
||||
showBiometricPrompt()
|
||||
}
|
||||
}
|
||||
showBiometricPrompt()
|
||||
}
|
||||
|
||||
lock_pattern.apply {
|
||||
isEnabled = lockManager.contains(Lock.Type.PATTERN)
|
||||
setOnClickListener {
|
||||
supportFragmentManager.beginTransaction().replace(
|
||||
R.id.lock_content, patternLockFragment
|
||||
).commit()
|
||||
}
|
||||
}
|
||||
lock_pin.apply {
|
||||
isEnabled = lockManager.contains(Lock.Type.PIN)
|
||||
setOnClickListener {
|
||||
supportFragmentManager.beginTransaction().replace(
|
||||
R.id.lock_content, pinLockFragment
|
||||
).commit()
|
||||
}
|
||||
}
|
||||
lock_password.isEnabled = false
|
||||
|
||||
when (lockManager.locks!!.first().type) {
|
||||
Lock.Type.PIN -> {
|
||||
|
||||
supportFragmentManager.beginTransaction().add(
|
||||
R.id.lock_content, pinLockFragment
|
||||
).commit()
|
||||
}
|
||||
Lock.Type.PATTERN -> {
|
||||
supportFragmentManager.beginTransaction().add(
|
||||
R.id.lock_content, patternLockFragment
|
||||
).commit()
|
||||
}
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
"add_lock" -> {
|
||||
lock_pattern.isEnabled = false
|
||||
lock_pin.isEnabled = false
|
||||
lock_fingerprint.isEnabled = false
|
||||
lock_password.isEnabled = false
|
||||
|
||||
when(intent.getStringExtra("type")!!) {
|
||||
"pattern" -> {
|
||||
lock_pattern.isEnabled = true
|
||||
supportFragmentManager.beginTransaction().add(
|
||||
R.id.lock_content, patternLockFragment
|
||||
).commit()
|
||||
}
|
||||
"pin" -> {
|
||||
lock_pin.isEnabled = true
|
||||
supportFragmentManager.beginTransaction().add(
|
||||
R.id.lock_content, pinLockFragment
|
||||
).commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,608 +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.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.view.*
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnticipateInterpolator
|
||||
import android.view.animation.OvershootInterpolator
|
||||
import android.view.animation.TranslateAnimation
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.PagerSnapHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import com.google.mlkit.vision.face.Face
|
||||
import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
|
||||
import kotlinx.android.synthetic.main.activity_reader.*
|
||||
import kotlinx.android.synthetic.main.activity_reader.view.*
|
||||
import kotlinx.android.synthetic.main.dialog_numberpicker.view.*
|
||||
import kotlinx.android.synthetic.main.reader_eye_card.view.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.quaver.Code
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.adapters.ReaderAdapter
|
||||
import xyz.quaver.pupil.favorites
|
||||
import xyz.quaver.pupil.services.DownloadService
|
||||
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 : BaseActivity() {
|
||||
|
||||
private var galleryID = 0
|
||||
private var currentPage = 0
|
||||
|
||||
private var isScroll = true
|
||||
private var isFullscreen = false
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
(reader_recyclerview.adapter as ReaderAdapter).isFullScreen = value
|
||||
}
|
||||
|
||||
private lateinit var cache: Cache
|
||||
var downloader: DownloadService? = null
|
||||
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 snapHelper = PagerSnapHelper()
|
||||
private var menu: Menu? = null
|
||||
|
||||
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
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_reader)
|
||||
|
||||
title = getString(R.string.reader_loading)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||
|
||||
handleIntent(intent)
|
||||
cache = Cache.getInstance(this, galleryID)
|
||||
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", galleryID)
|
||||
|
||||
if (galleryID == 0) {
|
||||
onBackPressed()
|
||||
return
|
||||
}
|
||||
|
||||
initDownloadListener()
|
||||
initView()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_VIEW) {
|
||||
val uri = intent.data
|
||||
val lastPathSegment = uri?.lastPathSegment
|
||||
if (uri != null && lastPathSegment != null) {
|
||||
galleryID = when (uri.host) {
|
||||
"hitomi.la" ->
|
||||
Regex("([0-9]+).html").find(lastPathSegment)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
"hiyobi.me" -> lastPathSegment.toInt()
|
||||
"e-hentai.org" -> uri.pathSegments[1].toInt()
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
galleryID = intent.getIntExtra("galleryID", 0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.reader, menu)
|
||||
|
||||
with(menu?.findItem(R.id.reader_menu_favorite)) {
|
||||
this ?: return@with
|
||||
|
||||
if (favorites.contains(galleryID))
|
||||
(icon as Animatable).start()
|
||||
}
|
||||
|
||||
this.menu = menu
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when(item.itemId) {
|
||||
R.id.reader_menu_page_indicator -> {
|
||||
val view = LayoutInflater.from(this).inflate(R.layout.dialog_numberpicker, reader_layout, false)
|
||||
with(view.dialog_number_picker) {
|
||||
minValue = 1
|
||||
maxValue = cache.metadata.reader?.galleryInfo?.files?.size ?: 0
|
||||
value = currentPage
|
||||
}
|
||||
val dialog = AlertDialog.Builder(this).apply {
|
||||
setView(view)
|
||||
}.create()
|
||||
view.dialog_ok.setOnClickListener {
|
||||
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(view.dialog_number_picker.value-1, 0)
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
R.id.reader_menu_favorite -> {
|
||||
val id = galleryID
|
||||
val favorite = menu?.findItem(R.id.reader_menu_favorite) ?: return true
|
||||
|
||||
if (favorites.contains(id)) {
|
||||
favorites.remove(id)
|
||||
favorite.icon = AnimatedVectorDrawableCompat.create(this, R.drawable.avd_star)
|
||||
} else {
|
||||
favorites.add(id)
|
||||
(favorite.icon as Animatable).start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
super.onDestroy()
|
||||
|
||||
update = false
|
||||
|
||||
if (!DownloadManager.getInstance(this).isDownloading(galleryID))
|
||||
DownloadService.cancel(this, galleryID)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (isScroll and !isFullscreen)
|
||||
super.onBackPressed()
|
||||
|
||||
if (isFullscreen) {
|
||||
isFullscreen = false
|
||||
fullscreen(false)
|
||||
}
|
||||
|
||||
if (!isScroll) {
|
||||
isScroll = true
|
||||
scrollMode(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
//currentPage is 1-based
|
||||
return when(keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage-2, 0)
|
||||
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage, 0)
|
||||
|
||||
true
|
||||
}
|
||||
else -> super.onKeyDown(keyCode, event)
|
||||
}
|
||||
}
|
||||
|
||||
private var update = true
|
||||
private fun initDownloadListener() {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
while (update) {
|
||||
delay(1000)
|
||||
|
||||
val downloader = downloader ?: continue
|
||||
|
||||
if (!downloader.progress.containsKey(galleryID)) //loading
|
||||
continue
|
||||
|
||||
if (downloader.progress[galleryID]?.isEmpty() == true) { //Gallery not found
|
||||
update = false
|
||||
Snackbar
|
||||
.make(reader_layout, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE)
|
||||
.show()
|
||||
|
||||
return@launch
|
||||
}
|
||||
|
||||
reader_download_progressbar.max = reader_recyclerview.adapter?.itemCount ?: 0
|
||||
reader_download_progressbar.progress =
|
||||
downloader.progress[galleryID]?.count { it.isInfinite() } ?: 0
|
||||
|
||||
if (title == getString(R.string.reader_loading)) {
|
||||
val reader = cache.metadata.reader
|
||||
|
||||
if (reader != null) {
|
||||
with(reader_recyclerview.adapter as ReaderAdapter) {
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (downloader.isCompleted(galleryID)) { //Download finished
|
||||
reader_download_progressbar.visibility = View.GONE
|
||||
|
||||
animateDownloadFAB(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
with(reader_recyclerview) {
|
||||
adapter = ReaderAdapter(this@ReaderActivity, galleryID).apply {
|
||||
onItemClickListener = {
|
||||
if (isScroll) {
|
||||
isScroll = false
|
||||
isFullscreen = true
|
||||
|
||||
scrollMode(false)
|
||||
fullscreen(true)
|
||||
} else {
|
||||
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPosition(currentPage) //Moves to next page because currentPage is 1-based indexing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addOnScrollListener(object: RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(recyclerView, dx, dy)
|
||||
|
||||
if (dy < 0)
|
||||
this@ReaderActivity.reader_fab.showMenuButton(true)
|
||||
else if (dy > 0)
|
||||
this@ReaderActivity.reader_fab.hideMenuButton(true)
|
||||
|
||||
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
||||
|
||||
if (layoutManager.findFirstVisibleItemPosition() == -1)
|
||||
return
|
||||
currentPage = layoutManager.findFirstVisibleItemPosition()+1
|
||||
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${recyclerView.adapter!!.itemCount}"
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
with(reader_fab_download) {
|
||||
animateDownloadFAB(DownloadManager.getInstance(this@ReaderActivity).getDownloadFolder(galleryID) != null) //If download in progress, animate button
|
||||
|
||||
setOnClickListener {
|
||||
val downloadManager = DownloadManager.getInstance(this@ReaderActivity)
|
||||
|
||||
if (downloadManager.isDownloading(galleryID)) {
|
||||
downloadManager.deleteDownloadFolder(galleryID)
|
||||
animateDownloadFAB(false)
|
||||
} else {
|
||||
downloadManager.addDownloadFolder(galleryID)
|
||||
DownloadService.download(context, galleryID, true)
|
||||
animateDownloadFAB(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with(reader_fab_retry) {
|
||||
setImageResource(R.drawable.refresh)
|
||||
setOnClickListener {
|
||||
DownloadService.download(context, galleryID)
|
||||
}
|
||||
}
|
||||
|
||||
with(reader_fab_auto) {
|
||||
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) {
|
||||
setImageResource(R.drawable.ic_fullscreen)
|
||||
setOnClickListener {
|
||||
isFullscreen = true
|
||||
fullscreen(isFullscreen)
|
||||
|
||||
this@ReaderActivity.reader_fab.close(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fullscreen(isFullscreen: Boolean) {
|
||||
with(window.attributes) {
|
||||
if (isFullscreen) {
|
||||
flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
supportActionBar?.hide()
|
||||
this@ReaderActivity.reader_fab.visibility = View.INVISIBLE
|
||||
this@ReaderActivity.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 {
|
||||
flags = flags and WindowManager.LayoutParams.FLAG_FULLSCREEN.inv()
|
||||
supportActionBar?.show()
|
||||
this@ReaderActivity.reader_fab.visibility = View.VISIBLE
|
||||
this@ReaderActivity.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
|
||||
}
|
||||
|
||||
reader_recyclerview.adapter = reader_recyclerview.adapter // Force to redraw
|
||||
}
|
||||
|
||||
private fun scrollMode(isScroll: Boolean) {
|
||||
if (isScroll) {
|
||||
snapHelper.attachToRecyclerView(null)
|
||||
reader_recyclerview.layoutManager = LinearLayoutManager(this)
|
||||
} else {
|
||||
snapHelper.attachToRecyclerView(reader_recyclerview)
|
||||
reader_recyclerview.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, Preferences["rtl", false])
|
||||
}
|
||||
|
||||
(reader_recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0)
|
||||
}
|
||||
|
||||
private fun animateDownloadFAB(animate: Boolean) {
|
||||
with(reader_fab_download) {
|
||||
if (animate) {
|
||||
val icon = AnimatedVectorDrawableCompat.create(context, R.drawable.ic_downloading)
|
||||
|
||||
icon?.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() {
|
||||
override fun onAnimationEnd(drawable: Drawable?) {
|
||||
if (downloader?.isCompleted(galleryID) == true) // If download is finished, stop animating
|
||||
post {
|
||||
setImageResource(R.drawable.ic_download)
|
||||
labelText = getString(R.string.reader_fab_download_cancel)
|
||||
}
|
||||
else // Or continue animate
|
||||
post {
|
||||
icon.start()
|
||||
labelText = getString(R.string.reader_fab_download_cancel)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setImageDrawable(icon)
|
||||
icon?.start()
|
||||
} else {
|
||||
setImageResource(R.drawable.ic_download)
|
||||
labelText = getString(R.string.reader_fab_download)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cameraCallback: (List<Face>) -> Unit = callback@{ faces ->
|
||||
eye_card.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(eye_card) {
|
||||
left_eye.setImageDrawable(it)
|
||||
right_eye.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(eye_card) {
|
||||
left_eye.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
context,
|
||||
if (left) R.drawable.eye else R.drawable.eye_closed
|
||||
)
|
||||
)
|
||||
right_eye.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
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) {
|
||||
(this@ReaderActivity.reader_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 = this@ReaderActivity.eye_card
|
||||
when (camera) {
|
||||
null -> {
|
||||
reader_fab_auto.labelText = getString(R.string.reader_fab_auto_cancel)
|
||||
reader_fab_auto.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 -> {
|
||||
reader_fab_auto.labelText = getString(R.string.reader_fab_auto)
|
||||
reader_fab_auto.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +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.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.ui.fragment.SettingsFragment
|
||||
|
||||
class SettingsActivity : BaseActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.settings_activity)
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.settings, SettingsFragment())
|
||||
.commit()
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> onBackPressed()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
enum class ContentType {
|
||||
SINGLE_PANE, DUAL_PANE
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import android.graphics.Rect
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
||||
sealed interface DevicePosture {
|
||||
data object NormalPosture: DevicePosture
|
||||
|
||||
data class BookPosture(
|
||||
val hingePosition: Rect
|
||||
): DevicePosture
|
||||
|
||||
data class Separating(
|
||||
val hingePosition: Rect,
|
||||
val orientation: FoldingFeature.Orientation
|
||||
): DevicePosture
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
fun isBookPosture(foldingFeature: FoldingFeature?): Boolean {
|
||||
contract { returns(true) implies (foldingFeature != null) }
|
||||
|
||||
return foldingFeature?.state == FoldingFeature.State.HALF_OPENED &&
|
||||
foldingFeature.orientation == FoldingFeature.Orientation.VERTICAL
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
fun isSeparating(foldingFeature: FoldingFeature?): Boolean {
|
||||
contract { returns(true) implies (foldingFeature != null) }
|
||||
|
||||
return foldingFeature?.state == FoldingFeature.State.FLAT && foldingFeature.isSeparating
|
||||
}
|
||||
504
app/src/main/java/xyz/quaver/pupil/ui/composable/Gallery.kt
Normal file
@@ -0,0 +1,504 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BrokenImage
|
||||
import androidx.compose.material.icons.filled.QuestionMark
|
||||
import androidx.compose.material.icons.filled.StarOutline
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.networking.Artist
|
||||
import xyz.quaver.pupil.networking.Character
|
||||
import xyz.quaver.pupil.networking.GalleryFile
|
||||
import xyz.quaver.pupil.networking.GalleryInfo
|
||||
import xyz.quaver.pupil.networking.GalleryTag
|
||||
import xyz.quaver.pupil.networking.Group
|
||||
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||
import xyz.quaver.pupil.networking.Language
|
||||
import xyz.quaver.pupil.networking.SearchQuery
|
||||
import xyz.quaver.pupil.networking.Series
|
||||
import xyz.quaver.pupil.networking.joinToCapitalizedString
|
||||
import xyz.quaver.pupil.ui.theme.Blue500
|
||||
import xyz.quaver.pupil.ui.theme.Green500
|
||||
import xyz.quaver.pupil.ui.theme.Purple500
|
||||
import xyz.quaver.pupil.ui.theme.Red500
|
||||
import xyz.quaver.pupil.ui.theme.Yellow500
|
||||
|
||||
private val languageMap = mapOf(
|
||||
"indonesian" to "Bahasa Indonesia",
|
||||
"catalan" to "català",
|
||||
"cebuano" to "Cebuano",
|
||||
"czech" to "Čeština",
|
||||
"danish" to "Dansk",
|
||||
"german" to "Deutsch",
|
||||
"estonian" to "eesti",
|
||||
"english" to "English",
|
||||
"spanish" to "Español",
|
||||
"esperanto" to "Esperanto",
|
||||
"french" to "Français",
|
||||
"italian" to "Italiano",
|
||||
"latin" to "Latina",
|
||||
"hungarian" to "magyar",
|
||||
"dutch" to "Nederlands",
|
||||
"norwegian" to "norsk",
|
||||
"polish" to "polski",
|
||||
"portuguese" to "Português",
|
||||
"romanian" to "română",
|
||||
"albanian" to "shqip",
|
||||
"slovak" to "Slovenčina",
|
||||
"finnish" to "Suomi",
|
||||
"swedish" to "Svenska",
|
||||
"tagalog" to "Tagalog",
|
||||
"vietnamese" to "tiếng việt",
|
||||
"turkish" to "Türkçe",
|
||||
"greek" to "Ελληνικά",
|
||||
"mongolian" to "Монгол",
|
||||
"russian" to "Русский",
|
||||
"ukrainian" to "Українська",
|
||||
"hebrew" to "עברית",
|
||||
"arabic" to "العربية",
|
||||
"persian" to "فارسی",
|
||||
"thai" to "ไทย",
|
||||
"korean" to "한국어",
|
||||
"chinese" to "中文",
|
||||
"japanese" to "日本語"
|
||||
)
|
||||
|
||||
private val galleryTypeStringMap = mapOf(
|
||||
"doujinshi" to R.string.doujinshi,
|
||||
"manga" to R.string.manga,
|
||||
"artistcg" to R.string.artist_cg,
|
||||
"gamecg" to R.string.game_cg,
|
||||
"imageset" to R.string.image_set
|
||||
)
|
||||
|
||||
private val galleryTypeColorMap = mapOf(
|
||||
"doujinshi" to Red500,
|
||||
"manga" to Yellow500,
|
||||
"artistcg" to Purple500,
|
||||
"gamecg" to Green500,
|
||||
"imageset" to Blue500
|
||||
)
|
||||
|
||||
class GalleryInfoProvider: PreviewParameterProvider<GalleryInfo> {
|
||||
override val values = sequenceOf(
|
||||
GalleryInfo(
|
||||
id = "2296437",
|
||||
title = "Kakyuu Majutsushi, Inmon ni Somaru | 하급 마술사, 음문에 물들다",
|
||||
language = "korean",
|
||||
type = "doujinshi",
|
||||
date = "2022-08-11 07:14:00-05",
|
||||
artists = listOf(Artist("wagashi")),
|
||||
groups = listOf(Group("dagashiya")),
|
||||
series = listOf(Series("original")),
|
||||
tags = listOf(
|
||||
GalleryTag("ahegao", female="1"),
|
||||
GalleryTag("big penis", male="1"),
|
||||
GalleryTag("bike shorts", female="1"),
|
||||
GalleryTag("blowjob", female="1"),
|
||||
GalleryTag("blowjob face", female="1"),
|
||||
GalleryTag("bukkake", female="1"),
|
||||
GalleryTag("bunny girl", female="1"),
|
||||
GalleryTag("clone", male="1"),
|
||||
GalleryTag("corruption", female="1"),
|
||||
GalleryTag("crotch tattoo", female="1"),
|
||||
GalleryTag("gloves", female="1"),
|
||||
GalleryTag("gokkun", female="1"),
|
||||
GalleryTag("group"),
|
||||
GalleryTag("kemonomimi", female="1"),
|
||||
GalleryTag("leotard", female="1"),
|
||||
GalleryTag("lingerie", female="1"),
|
||||
GalleryTag("loli", female="1"),
|
||||
GalleryTag("masked face", female="1"),
|
||||
GalleryTag("masturbation", female="1"),
|
||||
GalleryTag("mind control", female="1"),
|
||||
GalleryTag("mmf threesome"),
|
||||
GalleryTag("moral degeneration", female="1"),
|
||||
GalleryTag("mouth mask", female="1"),
|
||||
GalleryTag("nakadashi", female="1"),
|
||||
GalleryTag("prostitution", female="1"),
|
||||
GalleryTag("smell", male="1"),
|
||||
GalleryTag("unusual pupils", female="1"),
|
||||
),
|
||||
related = listOf(2806924, 2806923, 2319091, 1647024, 2580808),
|
||||
languages = listOf(
|
||||
Language(galleryID="2806923", name="korean"),
|
||||
Language(galleryID="2609305", name="english"),
|
||||
Language(galleryID="2302333", name="spanish"),
|
||||
Language(galleryID="2392785", name="portuguese"),
|
||||
Language(galleryID="2303940", name="russian"),
|
||||
Language(galleryID="2736129", name="chinese"),
|
||||
Language(galleryID="2295647", name="japanese")
|
||||
),
|
||||
characters = listOf(Character("Kakyuu Majutsushi")),
|
||||
files = listOf(
|
||||
GalleryFile(name="01.jpg", hash="d441383396a6ba41a2db914328dc80d16b5191e53d23a5f0f9f8a0cd8f2e7cef", width=4185, height=6000, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="02.png", hash="a42517a19c7db6369749807bbc6676906e35709be07f780247f2e68d516ed1d5", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="03.png", hash="ee0841953755f34a0a146a7f757cf2993c678384f53e88715b1c97a00abe5c27", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="04.png", hash="66fafca77a7ed0287666e77fe268a02f75b4e27c2b9b77e6577bb3132396132b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="05.png", hash="0ef8081ad9eef5093077c8551e87903e8b275e607634717857c2986e8d3c51a9", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="06.png", hash="2e59ffd59fa761355ea855a9e0f366cb39569207165a99659a9f0868cbba7e94", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="07.png", hash="5dc19fb97a2f1c64cae5634cd651f593d022b3114bbacaab15ba114be581cbea", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="08.png", hash="9121781d4f8fb1aaaee124f82c2ab98c97eb3e1f9508bab7c1d8771bb5eddfdb", width=4520, height=6295, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="09.png", hash="e5ceae4da5e497bd95a79a607f2c85bb3e8dc7386e041086cd5ccb9a9fb4dcb1", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="10.png", hash="be56219811a29f86dfcf5a7af0b25addc7436b134acd1a94d51c69c987da9d1b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="11.png", hash="4e3b09ac015360ad4daffcea46265f3eb319bbe638ce90f26d4cbae37dc8c0f1", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="12.png", hash="5d57c0a0cd00604382eeaf0b32446b938dafc9d980eb086673e1fa0307166e39", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="13.png", hash="1ff2313fe979b52b826d482be90699a7c086afc251fa22306e92dfc582611f10", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="14.png", hash="7ad92a9408a059afafc68d3086c5f6a070c7e0d550bc2d328b5c9e16a62be01c", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="15.png", hash="4a6db95b7111b647e450c155af07c617d28313c02871ed94ad0c5986d3c5d1aa", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="16.png", hash="d4b53bf416c9bd2f72850e80ddaaf8467c663c72433c8ebebf3a70742fea7c32", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="17.png", hash="d189d5321f18414de816c049d3e2d72a7d31124d628037ca9f7da4572025cf01", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="18.png", hash="3ff372d7ba4e34cff9f7b46f5323e60b36f9b9df3dd5d02be4c71de4a7ee23c7", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="19.png", hash="2965852c2000fb17f756263b47ca196563995be2d03143f64c297f5930248d1e", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="20.png", hash="3713f95947cc6df0b67af5532a440f023c99ee37d483f3f9252400168e3a55ad", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="21.png", hash="c0b2b2d5ee79c3dc3b737c0eaff19ed1a731d81495adc2c94260de7ebbd85415", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="22.png", hash="8835fe309a26fce6882c0fcdf37cbd5f5bcc69dd2c32e436deb644891ca8499b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="23.png", hash="5eb28619c1919ad29b86fb6cdaeccd50ad2ad857c81f56c060e8b66a2ed315d0", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="24.png", hash="7ea3ecf4c0b0e5e632163a5b0dc2475a071e1209a0fed8e8e49243093c35babb", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="25.png", hash="d01355724eb60c41e43159607652812d1fbbbac12962b2f9068a9e620ee0c246", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="26.png", hash="58a33c1d709b005a17600f7beb14a81711a106619bdb029d30646a9060c245c7", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="27.png", hash="0b18753f2fe7ea97c2e2c13a082a5a675f36085558bcd3fb7be916b6118c6000", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="28.png", hash="e8f0a2f9d35ec2c1974a4aea07eefd792462049e7b0972bf8cd5532dd82cee21", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="29.png", hash="90ec7a13aedf22c1f9317f75e843a4ace4e236d6100a8c8bb5aeb8160ff1cd00", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="30.png", hash="3229400975e6cef763c54a82a4ce0e6f51ec41c9b674072e74f66b41e325b655", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="31.png", hash="4e04efa56981804f7d13858a98935038fe421956367dcabba8e9118d273135d5", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="32.png", hash="e0d8641127aaaf587ec5e3040c49edee9cda866a5f1f377e567b908514b1eb68", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="33.png", hash="00125ab9090be7d3462fd6943c209bc68c236ef50ca0f1552530fd8253d87f7d", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="34.png", hash="1ea303198badaecfcf66a438baa6867690416d77a542f1ff0181b1895df95ccb", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="35.png", hash="40181ac057808a16716e640c462cc2b992822999ab8db43d454bb331d50bd39f", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="36.png", hash="92cbe1067c1c554c5a2e01b58b6fb86677a523cba586dfa209d61b4af70b2f54", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="37.png", hash="4fbbdc6d5450eb1f2f7e45a97ce8360d43e199454a8eb9e536740309c2067999", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="38.png", hash="0cb74b8af27e51604c728a26cf4788899fa04f854853badea9884b008c024e94", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="39.png", hash="254676c13ab418ce1110a9bad009554abc72af7e2ff9d719409881ea3c635d46", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="40.png", hash="33527e1171b9cf21ab164be2b76cb5b9daa91980a3330f2441dd9bf911e2e05b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="41.png", hash="4dc331acb058665500aa308143c106efb6999855d1c3b0665c1dd331c8364430", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="42.png", hash="7674f03fa02cf96a20f1e192caf76d85e7e556add0cdb65aabdc86d417093d39", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="43.png", hash="27a4a280483142b213b26d53f06b991be35122cb263c69a87435e624b2a307fd", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="44.png", hash="3114ba3bff094abbae9160656894d462e0567cea23e6fa2693469c5e7defa6fe", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="45.png", hash="dc22ffed6a678a560e781f795b3ee0876ec8726ae2af942226710102758d44db", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="46.png", hash="4f7257ad75b990cc2b8dad0c5a09a831cb1670e7aaeadc71809f4bd7400f0071", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="47.png", hash="bf74fcd74ce77b5089bac98dc9f5737ba1fe87cdcfb01ad80e63024c93f82692", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="48.png", hash="d0baed210584183efa654ca7a483c558afe9eb18b71452ed2cfd4264b269ffca", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="49.png", hash="2b9c90a038e4e918655dffa84fbbb08cbedfce642277b1140d070aa9a711ebac", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="50.png", hash="cdfba6a9b8a570f2f317fb01e66ebc72ede5735a41256b326a38af2296799095", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="51.png", hash="84ad9d51aab2e8ae1cbb5a0918cc3f62473e98512db9f473ab1ffe3a4f7aa75a", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="52.png", hash="0d7a420aa23e23cee7f8e124b12080e309cffb3d7269389f23e1ac3d5b7363a5", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="53.png", hash="575cf99346f9786d6e30bc163dc6d5fa439bd157a9ffce90586bfc5657121981", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="54.png", hash="9004d3fd46dc278b79cf7a5bf72002dd4b0b03ad6bded30ceebcb633185b49ce", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="55.png", hash="8e53c301e2c2b539783c7b8c4f5028f221198d33e114dcc40e6a0025aee840ab", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="56.png", hash="576764073554fcf644c12d80f26b3bae42f39f3516ed7841c9e297248324b237", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="57.png", hash="110da01b9ae7ed4a145792f55498a5efa22cebec4da84f6220904840b508c75e", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="58.jpg", hash="3ae38577135465b6224e0487c0cdcd37cf11764883f3b78a67545d48c6beade5", width=4260, height=6000, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun TagGroup(tags: List<SearchQuery.Tag>, folded: Boolean = false) {
|
||||
var isFolded by remember { mutableStateOf(folded) }
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
tags.sortedBy {
|
||||
when(it.namespace) {
|
||||
"female" -> 1
|
||||
"male" -> 2
|
||||
else -> 3
|
||||
}
|
||||
}.let {
|
||||
if (isFolded) it.take(10) else it
|
||||
}.forEach { tag ->
|
||||
TagChip(tag = tag.toTag())
|
||||
}
|
||||
|
||||
if (isFolded && tags.size > 10)
|
||||
Surface(
|
||||
modifier = Modifier.height(32.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
onClick = { isFolded = false }
|
||||
) {
|
||||
Text(
|
||||
"…",
|
||||
modifier = Modifier.padding(16.dp, 8.dp),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GalleryTypeIndicator(galleryType: String) {
|
||||
Surface(
|
||||
modifier = Modifier.height(32.dp),
|
||||
color = galleryTypeColorMap[galleryType] ?: MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Box(Modifier.fillMaxHeight()) {
|
||||
Text(
|
||||
galleryTypeStringMap[galleryType]?.let { stringResource(it) } ?: galleryType,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.align(Alignment.Center),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LanguageTitle(title: String, language: String?) {
|
||||
val icon = languageIconMap[language]
|
||||
|
||||
if (icon != null) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
appendInlineContent("language", "<language>")
|
||||
append(' ')
|
||||
append(title)
|
||||
},
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
inlineContent = mapOf(
|
||||
"language" to InlineTextContent(
|
||||
Placeholder(
|
||||
width = 20.sp,
|
||||
height = 20.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painterResource(icon),
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) {
|
||||
val thumbnailFile = galleryInfo.files.first()
|
||||
val aspectRatio = thumbnailFile.let { it.width / it.height.toFloat() }
|
||||
|
||||
if (thumbnailFile.let { it.width > it.height }) {
|
||||
Column {
|
||||
if (thumbnailUrl != null) {
|
||||
SubcomposeAsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(thumbnailUrl)
|
||||
.setHeader("Referer", "https://hitomi.la/")
|
||||
.build(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(aspectRatio)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
loading = { CircularProgressIndicator(Modifier.align(Alignment.Center)) },
|
||||
error = {
|
||||
Icon(
|
||||
Icons.Default.BrokenImage,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
contentDescription = "Thumbnail"
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(aspectRatio)) {
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
LanguageTitle(galleryInfo.title, galleryInfo.language)
|
||||
|
||||
val artistsAndGroups = buildString {
|
||||
if (!galleryInfo.artists.isNullOrEmpty())
|
||||
append(galleryInfo.artists.joinToCapitalizedString())
|
||||
|
||||
if (!galleryInfo.groups.isNullOrEmpty()) {
|
||||
if (this.isNotEmpty()) append(' ')
|
||||
append('(')
|
||||
append(galleryInfo.groups.joinToCapitalizedString())
|
||||
append(')')
|
||||
}
|
||||
}
|
||||
|
||||
if (artistsAndGroups.isNotEmpty()) {
|
||||
Text(
|
||||
artistsAndGroups,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (thumbnailUrl != null) {
|
||||
SubcomposeAsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(thumbnailUrl)
|
||||
.setHeader("Referer", "https://hitomi.la/")
|
||||
.build(),
|
||||
modifier = Modifier
|
||||
.height(200.dp)
|
||||
.aspectRatio(aspectRatio)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
loading = { CircularProgressIndicator(Modifier.align(Alignment.Center)) },
|
||||
error = {
|
||||
Icon(
|
||||
Icons.Default.BrokenImage,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
contentDescription = "Thumbnail"
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.height(200.dp)
|
||||
.aspectRatio(aspectRatio)) {
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
Column(Modifier.heightIn(min = 200.dp)) {
|
||||
LanguageTitle(galleryInfo.title, galleryInfo.language)
|
||||
|
||||
val artistsAndGroups = buildString {
|
||||
if (!galleryInfo.artists.isNullOrEmpty())
|
||||
append(galleryInfo.artists.joinToCapitalizedString())
|
||||
|
||||
if (!galleryInfo.groups.isNullOrEmpty()) {
|
||||
if (this.isNotEmpty()) append(' ')
|
||||
append('(')
|
||||
append(galleryInfo.groups.joinToCapitalizedString())
|
||||
append(')')
|
||||
}
|
||||
}
|
||||
|
||||
if (artistsAndGroups.isNotEmpty()) {
|
||||
Text(
|
||||
artistsAndGroups,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DetailedGalleryInfo(
|
||||
@PreviewParameter(GalleryInfoProvider::class) galleryInfo: GalleryInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var thumbnailUrl by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(galleryInfo) {
|
||||
thumbnailUrl = galleryInfo.files.firstOrNull()?.let {
|
||||
HitomiHttpClient.getImageURL(it, true).firstOrNull()
|
||||
} ?: ""
|
||||
}
|
||||
|
||||
Card(modifier) {
|
||||
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DetailedGalleryInfoHeader(galleryInfo, thumbnailUrl)
|
||||
|
||||
GalleryTypeIndicator(galleryInfo.type)
|
||||
|
||||
if (galleryInfo.tags?.isNotEmpty() == true) {
|
||||
TagGroup(galleryInfo.tags.map { it.toTag() }, folded = true)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp)) {
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.CenterStart),
|
||||
text = galleryInfo.id,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
text = "${galleryInfo.files.size}P",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.size(32.dp),
|
||||
imageVector = Icons.Default.StarOutline,
|
||||
contentDescription = null,
|
||||
tint = Yellow500
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
306
app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt
Normal file
@@ -0,0 +1,306 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.PermanentNavigationDrawer
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.activity
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.window.layout.DisplayFeature
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.networking.GalleryInfo
|
||||
import xyz.quaver.pupil.networking.SearchQuery
|
||||
import xyz.quaver.pupil.ui.viewmodel.SearchState
|
||||
|
||||
@Composable
|
||||
fun MainApp(
|
||||
windowSize: WindowSizeClass,
|
||||
displayFeatures: List<DisplayFeature>,
|
||||
uiState: SearchState,
|
||||
navController: NavHostController,
|
||||
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||
closeGalleryDetails: () -> Unit,
|
||||
onQueryChange: (SearchQuery?) -> Unit,
|
||||
loadSearchResult: (IntRange) -> Unit,
|
||||
) {
|
||||
val navigationType: NavigationType
|
||||
val contentType: ContentType
|
||||
|
||||
val foldingFeature: FoldingFeature? = displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
|
||||
val foldingDevicePosture = when {
|
||||
isBookPosture(foldingFeature) -> DevicePosture.BookPosture(foldingFeature.bounds)
|
||||
isSeparating(foldingFeature) -> DevicePosture.Separating(foldingFeature.bounds, foldingFeature.orientation)
|
||||
else -> DevicePosture.NormalPosture
|
||||
}
|
||||
|
||||
when (windowSize.widthSizeClass) {
|
||||
WindowWidthSizeClass.Compact -> {
|
||||
navigationType = NavigationType.BOTTOM_NAVIGATION
|
||||
contentType = ContentType.SINGLE_PANE
|
||||
}
|
||||
WindowWidthSizeClass.Medium -> {
|
||||
navigationType = NavigationType.NAVIGATION_RAIL
|
||||
contentType = if (foldingDevicePosture != DevicePosture.NormalPosture) {
|
||||
ContentType.DUAL_PANE
|
||||
} else {
|
||||
ContentType.SINGLE_PANE
|
||||
}
|
||||
}
|
||||
WindowWidthSizeClass.Expanded -> {
|
||||
navigationType = if (foldingDevicePosture is DevicePosture.BookPosture) {
|
||||
NavigationType.NAVIGATION_RAIL
|
||||
} else {
|
||||
NavigationType.PERMANENT_NAVIGATION_DRAWER
|
||||
}
|
||||
contentType = ContentType.DUAL_PANE
|
||||
}
|
||||
else -> {
|
||||
navigationType = NavigationType.BOTTOM_NAVIGATION
|
||||
contentType = ContentType.SINGLE_PANE
|
||||
}
|
||||
}
|
||||
|
||||
val navigationContentPosition = when (windowSize.heightSizeClass) {
|
||||
WindowHeightSizeClass.Compact -> NavigationContentPosition.TOP
|
||||
WindowHeightSizeClass.Medium,
|
||||
WindowHeightSizeClass.Expanded -> NavigationContentPosition.CENTER
|
||||
else -> NavigationContentPosition.TOP
|
||||
}
|
||||
|
||||
MainNavigationWrapper(
|
||||
navigationType,
|
||||
contentType,
|
||||
displayFeatures,
|
||||
navigationContentPosition,
|
||||
uiState,
|
||||
navController,
|
||||
openGalleryDetails = openGalleryDetails,
|
||||
closeGalleryDetails = closeGalleryDetails,
|
||||
onQueryChange = onQueryChange,
|
||||
loadSearchResult = loadSearchResult
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainNavigationWrapper(
|
||||
navigationType: NavigationType,
|
||||
contentType: ContentType,
|
||||
displayFeatures: List<DisplayFeature>,
|
||||
navigationContentPosition: NavigationContentPosition,
|
||||
uiState: SearchState,
|
||||
navController: NavHostController,
|
||||
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||
closeGalleryDetails: () -> Unit,
|
||||
onQueryChange: (SearchQuery?) -> Unit,
|
||||
loadSearchResult: (IntRange) -> Unit
|
||||
) {
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
val openDrawer: () -> Unit = {
|
||||
coroutineScope.launch {
|
||||
drawerState.open()
|
||||
}
|
||||
}
|
||||
|
||||
if (navigationType == NavigationType.PERMANENT_NAVIGATION_DRAWER) {
|
||||
PermanentNavigationDrawer(
|
||||
drawerContent = {
|
||||
PermanentNavigationDrawerContent(
|
||||
selectedDestination = currentRoute,
|
||||
navigateToDestination = { navController.navigate(it.route) {
|
||||
popUpTo(MainDestination.Search.route)
|
||||
launchSingleTop = true
|
||||
} },
|
||||
navigationContentPosition = navigationContentPosition,
|
||||
)
|
||||
}
|
||||
) {
|
||||
MainContent(
|
||||
navigationType = navigationType,
|
||||
contentType = contentType,
|
||||
displayFeatures = displayFeatures,
|
||||
uiState = uiState,
|
||||
navController = navController,
|
||||
onDrawerClicked = openDrawer,
|
||||
openGalleryDetails = openGalleryDetails,
|
||||
closeGalleryDetails = closeGalleryDetails,
|
||||
onQueryChange = onQueryChange,
|
||||
loadSearchResult = loadSearchResult,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ModalNavigationDrawer(
|
||||
drawerContent = {
|
||||
ModalNavigationDrawerContent(
|
||||
selectedDestination = currentRoute,
|
||||
navigateToDestination = { navController.navigate(it.route) {
|
||||
popUpTo(MainDestination.Search.route)
|
||||
launchSingleTop = true
|
||||
} },
|
||||
navigationContentPosition = navigationContentPosition,
|
||||
onDrawerClicked = {
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
drawerState = drawerState
|
||||
) {
|
||||
MainContent(
|
||||
navigationType = navigationType,
|
||||
contentType = contentType,
|
||||
displayFeatures = displayFeatures,
|
||||
uiState = uiState,
|
||||
navController = navController,
|
||||
onDrawerClicked = openDrawer,
|
||||
openGalleryDetails = openGalleryDetails,
|
||||
closeGalleryDetails = closeGalleryDetails,
|
||||
onQueryChange = onQueryChange,
|
||||
loadSearchResult = loadSearchResult,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotImplemented() {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("(⁄ ⁄•⁄ω⁄•⁄ ⁄)", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
|
||||
Text(stringResource(R.string.not_implemented), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainContent(
|
||||
navigationType: NavigationType,
|
||||
contentType: ContentType,
|
||||
displayFeatures: List<DisplayFeature>,
|
||||
uiState: SearchState,
|
||||
navController: NavHostController,
|
||||
onDrawerClicked: () -> Unit,
|
||||
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||
closeGalleryDetails: () -> Unit,
|
||||
onQueryChange: (SearchQuery?) -> Unit,
|
||||
loadSearchResult: (IntRange) -> Unit,
|
||||
) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
AnimatedVisibility(visible = navigationType == NavigationType.NAVIGATION_RAIL) {
|
||||
MainNavigationRail(
|
||||
selectedDestination = currentRoute,
|
||||
navigateToDestination = { navController.navigate(it.route) {
|
||||
popUpTo(MainDestination.Search.route)
|
||||
launchSingleTop = true
|
||||
} },
|
||||
onDrawerClicked = onDrawerClicked
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.inverseOnSurface)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.run {
|
||||
if (navigationType == NavigationType.BOTTOM_NAVIGATION) {
|
||||
this
|
||||
.consumeWindowInsets(WindowInsets.ime)
|
||||
.consumeWindowInsets(WindowInsets.navigationBars)
|
||||
} else this
|
||||
}
|
||||
) {
|
||||
NavHost(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
navController = navController,
|
||||
startDestination = MainDestination.Search.route
|
||||
) {
|
||||
composable(MainDestination.Search.route) {
|
||||
SearchScreen(
|
||||
contentType = contentType,
|
||||
displayFeatures = displayFeatures,
|
||||
uiState = uiState,
|
||||
openGalleryDetails = openGalleryDetails,
|
||||
closeGalleryDetails = closeGalleryDetails,
|
||||
onQueryChange = onQueryChange,
|
||||
loadSearchResult = loadSearchResult,
|
||||
openGallery = {
|
||||
navController.navigate(MainDestination.ImageViewer(it.id).route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(MainDestination.History.route) {
|
||||
NotImplemented()
|
||||
}
|
||||
composable(MainDestination.Downloads.route) {
|
||||
NotImplemented()
|
||||
}
|
||||
composable(MainDestination.Favorites.route) {
|
||||
NotImplemented()
|
||||
}
|
||||
composable(MainDestination.Settings.route) {
|
||||
NotImplemented()
|
||||
}
|
||||
composable(MainDestination.ImageViewer.commonRoute) {
|
||||
NotImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = navigationType == NavigationType.BOTTOM_NAVIGATION) {
|
||||
BottomNavigationBar(
|
||||
selectedDestination = currentRoute,
|
||||
navigateToDestination = { navController.navigate(it.route) {
|
||||
popUpTo(MainDestination.Search.route)
|
||||
launchSingleTop = true
|
||||
} }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import xyz.quaver.pupil.R
|
||||
|
||||
sealed interface MainDestination {
|
||||
val route: String
|
||||
val icon: ImageVector
|
||||
val textId: Int
|
||||
|
||||
data object Search: MainDestination {
|
||||
override val route = "search"
|
||||
override val icon = Icons.Default.Search
|
||||
override val textId = R.string.main_destination_search
|
||||
}
|
||||
|
||||
data object History: MainDestination {
|
||||
override val route = "history"
|
||||
override val icon = Icons.Default.History
|
||||
override val textId = R.string.main_destination_history
|
||||
}
|
||||
|
||||
data object Downloads: MainDestination {
|
||||
override val route = "downloads"
|
||||
override val icon = Icons.Default.Download
|
||||
override val textId = R.string.main_destination_downloads
|
||||
}
|
||||
|
||||
data object Favorites: MainDestination {
|
||||
override val route = "favorites"
|
||||
override val icon = Icons.Default.Favorite
|
||||
override val textId = R.string.main_destination_favorites
|
||||
}
|
||||
|
||||
data object Settings: MainDestination {
|
||||
override val route = "settings"
|
||||
override val icon = Icons.Default.Settings
|
||||
override val textId = R.string.main_destination_settings
|
||||
}
|
||||
|
||||
class ImageViewer(galleryID: String): MainDestination {
|
||||
override val route = "image_viewer/$galleryID"
|
||||
override val icon = Icons.AutoMirrored.Filled.MenuBook
|
||||
override val textId = R.string.main_destination_image_viewer
|
||||
|
||||
companion object {
|
||||
val commonRoute = "image_viewer/{galleryID}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mainDestinations = listOf(
|
||||
MainDestination.Search,
|
||||
MainDestination.History,
|
||||
MainDestination.Downloads,
|
||||
MainDestination.Favorites,
|
||||
MainDestination.Settings
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
enum class NavigationContentPosition {
|
||||
TOP, CENTER
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuOpen
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.PermanentDrawerSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasurePolicy
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.offset
|
||||
import androidx.navigation.NavDestination
|
||||
import xyz.quaver.pupil.R
|
||||
|
||||
@Composable
|
||||
fun PermanentNavigationDrawerContent(
|
||||
selectedDestination: String?,
|
||||
navigateToDestination: (MainDestination) -> Unit,
|
||||
navigationContentPosition: NavigationContentPosition,
|
||||
) {
|
||||
PermanentDrawerSheet(
|
||||
modifier = Modifier.sizeIn(minWidth = 200.dp, maxWidth = 300.dp),
|
||||
drawerContainerColor = MaterialTheme.colorScheme.inverseOnSurface
|
||||
) {
|
||||
Layout(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.inverseOnSurface)
|
||||
.padding(16.dp),
|
||||
content = {
|
||||
Row(
|
||||
modifier = Modifier.layoutId(LayoutType.HEADER),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(32.dp),
|
||||
painter = painterResource(R.drawable.app_icon),
|
||||
tint = Color.Unspecified,
|
||||
contentDescription = "app icon"
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text = "Pupil",
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layoutId(LayoutType.CONTENT)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
mainDestinations.forEach { destination ->
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(destination.textId),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.textId)
|
||||
)
|
||||
},
|
||||
selected = selectedDestination == destination.route,
|
||||
colors = NavigationDrawerItemDefaults.colors(
|
||||
unselectedContainerColor = Color.Transparent
|
||||
),
|
||||
onClick = { navigateToDestination(destination) }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
measurePolicy = navigationMeasurePolicy(navigationContentPosition)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModalNavigationDrawerContent(
|
||||
selectedDestination: String?,
|
||||
navigationContentPosition: NavigationContentPosition,
|
||||
navigateToDestination: (MainDestination) -> Unit,
|
||||
onDrawerClicked: () -> Unit
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
Layout(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.inverseOnSurface)
|
||||
.padding(16.dp),
|
||||
content = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.layoutId(LayoutType.HEADER)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(32.dp),
|
||||
painter = painterResource(R.drawable.app_icon),
|
||||
tint = Color.Unspecified,
|
||||
contentDescription = "app icon"
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text = "Pupil",
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onDrawerClicked) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.MenuOpen,
|
||||
contentDescription = stringResource(R.string.main_open_navigation_drawer)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.layoutId(LayoutType.CONTENT)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
mainDestinations.forEach { destination ->
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(destination.textId),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.textId)
|
||||
)
|
||||
},
|
||||
selected = selectedDestination == destination.route,
|
||||
colors = NavigationDrawerItemDefaults.colors(
|
||||
unselectedContainerColor = Color.Transparent
|
||||
),
|
||||
onClick = { navigateToDestination(destination) }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
measurePolicy = navigationMeasurePolicy(navigationContentPosition)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainNavigationRail(
|
||||
selectedDestination: String?,
|
||||
navigateToDestination: (MainDestination) -> Unit,
|
||||
onDrawerClicked: () -> Unit
|
||||
) {
|
||||
NavigationRail (
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
containerColor = MaterialTheme.colorScheme.inverseOnSurface
|
||||
) {
|
||||
NavigationRailItem(
|
||||
selected = false,
|
||||
onClick = onDrawerClicked,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.main_open_navigation_drawer)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
mainDestinations.forEach { destination ->
|
||||
NavigationRailItem(
|
||||
selected = selectedDestination == destination.route,
|
||||
onClick = { navigateToDestination(destination) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.textId)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomNavigationBar(
|
||||
selectedDestination: String?,
|
||||
navigateToDestination: (MainDestination) -> Unit
|
||||
) {
|
||||
NavigationBar(modifier = Modifier.fillMaxWidth(), windowInsets = WindowInsets.ime.union(WindowInsets.navigationBars)) {
|
||||
mainDestinations.forEach { destination ->
|
||||
NavigationBarItem(
|
||||
selected = selectedDestination == destination.route,
|
||||
onClick = { navigateToDestination(destination) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.textId)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigationMeasurePolicy(
|
||||
navigationContentPosition: NavigationContentPosition,
|
||||
): MeasurePolicy {
|
||||
return MeasurePolicy { measurables, constraints ->
|
||||
lateinit var headerMeasurable: Measurable
|
||||
lateinit var contentMeasurable: Measurable
|
||||
measurables.forEach {
|
||||
when (it.layoutId) {
|
||||
LayoutType.HEADER -> headerMeasurable = it
|
||||
LayoutType.CONTENT -> contentMeasurable = it
|
||||
else -> error("Unknown layoutId encountered!")
|
||||
}
|
||||
}
|
||||
|
||||
val headerPlaceable = headerMeasurable.measure(constraints)
|
||||
val contentPlaceable = contentMeasurable.measure(
|
||||
constraints.offset(vertical = -headerPlaceable.height)
|
||||
)
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
headerPlaceable.placeRelative(0, 0)
|
||||
|
||||
val nonContentVerticalSpace = constraints.maxHeight - contentPlaceable.height
|
||||
|
||||
val contentPlaceableY = when (navigationContentPosition) {
|
||||
NavigationContentPosition.TOP -> 0
|
||||
NavigationContentPosition.CENTER -> nonContentVerticalSpace / 2
|
||||
}.coerceAtLeast(headerPlaceable.height)
|
||||
|
||||
contentPlaceable.placeRelative(0, contentPlaceableY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class LayoutType {
|
||||
HEADER, CONTENT
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
enum class NavigationType {
|
||||
NAVIGATION_RAIL, PERMANENT_NAVIGATION_DRAWER, BOTTOM_NAVIGATION
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
|
||||
import androidx.compose.material.icons.automirrored.filled.NavigateNext
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.ui.theme.Blue300
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
fun OverscrollPager(
|
||||
prevPage: Int?,
|
||||
nextPage: Int?,
|
||||
onPageTurn: (Int) -> Unit,
|
||||
prevPageTurnIndicatorOffset: Dp = 0.dp,
|
||||
nextPageTurnIndicatorOffset: Dp = 0.dp,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() }
|
||||
|
||||
var overscroll: Float? by remember { mutableStateOf(null) }
|
||||
|
||||
var size: Size? by remember { mutableStateOf(null) }
|
||||
val circleRadius = (size?.width ?: 0f) / 2
|
||||
|
||||
val topCircleRadius by animateFloatAsState(if (overscroll?.let { it >= pageTurnIndicatorHeight } == true) circleRadius else 0f, label = "topCircleRadius")
|
||||
val bottomCircleRadius by animateFloatAsState(if (overscroll?.let { it <= -pageTurnIndicatorHeight } == true) circleRadius else 0f, label = "bottomCircleRadius")
|
||||
|
||||
val prevPageTurnIndicatorOffsetPx = LocalDensity.current.run { prevPageTurnIndicatorOffset.toPx() }
|
||||
val nextPageTurnIndicatorOffsetPx = LocalDensity.current.run { nextPageTurnIndicatorOffset.toPx() }
|
||||
|
||||
if (topCircleRadius != 0f || bottomCircleRadius != 0f)
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawCircle(
|
||||
Blue300,
|
||||
center = Offset(this.center.x, prevPageTurnIndicatorOffsetPx),
|
||||
radius = topCircleRadius
|
||||
)
|
||||
drawCircle(
|
||||
Blue300,
|
||||
center = Offset(this.center.x, this.size.height-nextPageTurnIndicatorOffsetPx),
|
||||
radius = bottomCircleRadius
|
||||
)
|
||||
}
|
||||
|
||||
val isOverscrollOverHeight = overscroll?.let { abs(it) >= pageTurnIndicatorHeight } == true
|
||||
LaunchedEffect(isOverscrollOverHeight) {
|
||||
if (isOverscrollOverHeight) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.onGloballyPositioned {
|
||||
size = it.size.toSize()
|
||||
}
|
||||
) {
|
||||
overscroll?.let { overscroll ->
|
||||
if (overscroll > 0f && prevPage != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(0.dp, prevPageTurnIndicatorOffset),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateBefore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.move_to_page, prevPage))
|
||||
}
|
||||
}
|
||||
|
||||
if (overscroll < 0f && nextPage != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.offset(0.dp, -nextPageTurnIndicatorOffset),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.move_to_page, nextPage))
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateNext,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
0.dp,
|
||||
overscroll
|
||||
?.coerceIn(-pageTurnIndicatorHeight, pageTurnIndicatorHeight)
|
||||
?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } }
|
||||
?: 0.dp)
|
||||
.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
val overscrollSnapshot = overscroll
|
||||
|
||||
return if (overscrollSnapshot == null || overscrollSnapshot == 0f) {
|
||||
Offset.Zero
|
||||
} else {
|
||||
val newOverscroll =
|
||||
if (overscrollSnapshot > 0f && available.y < 0f)
|
||||
max(overscrollSnapshot + available.y, 0f)
|
||||
else if (overscrollSnapshot < 0f && available.y > 0f)
|
||||
min(overscrollSnapshot + available.y, 0f)
|
||||
else
|
||||
overscrollSnapshot
|
||||
|
||||
Offset(0f, newOverscroll - overscrollSnapshot).also {
|
||||
overscroll = newOverscroll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
if (
|
||||
available.y == 0f ||
|
||||
prevPage == null && available.y > 0f ||
|
||||
nextPage == null && available.y < 0f
|
||||
) return Offset.Zero
|
||||
|
||||
return overscroll?.let {
|
||||
overscroll = it + available.y
|
||||
Offset(0f, available.y)
|
||||
} ?: Offset.Zero
|
||||
}
|
||||
})
|
||||
.pointerInput(prevPage, nextPage) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var pointer = down.id
|
||||
overscroll = 0f
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val dragEvent =
|
||||
event.changes.fastFirstOrNull { it.id == pointer }!!
|
||||
|
||||
if (dragEvent.changedToUpIgnoreConsumed()) {
|
||||
val otherDown = event.changes.fastFirstOrNull { it.pressed }
|
||||
if (otherDown == null) {
|
||||
if (dragEvent.positionChange() != Offset.Zero) dragEvent.consume()
|
||||
overscroll?.let {
|
||||
if (abs(it) > pageTurnIndicatorHeight) {
|
||||
if (it > 0 && prevPage != null) onPageTurn(prevPage)
|
||||
if (it < 0 && nextPage != null) onPageTurn(nextPage)
|
||||
}
|
||||
}
|
||||
overscroll = null
|
||||
break
|
||||
} else
|
||||
pointer = otherDown.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
793
app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt
Normal file
@@ -0,0 +1,793 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AddCircleOutline
|
||||
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardColors
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||
import xyz.quaver.pupil.networking.SearchQuery
|
||||
import xyz.quaver.pupil.networking.Suggestion
|
||||
import xyz.quaver.pupil.networking.validNamespace
|
||||
import xyz.quaver.pupil.ui.theme.Blue300
|
||||
import xyz.quaver.pupil.ui.theme.Blue600
|
||||
import xyz.quaver.pupil.ui.theme.Gray300
|
||||
import xyz.quaver.pupil.ui.theme.Pink600
|
||||
import xyz.quaver.pupil.ui.theme.Red300
|
||||
import xyz.quaver.pupil.ui.theme.Yellow400
|
||||
|
||||
private fun SearchQuery.toEditableStateInternal(): EditableSearchQueryState = when (this) {
|
||||
is SearchQuery.Tag -> EditableSearchQueryState.Tag(namespace, tag)
|
||||
is SearchQuery.And -> EditableSearchQueryState.And(queries.map { it.toEditableStateInternal() })
|
||||
is SearchQuery.Or -> EditableSearchQueryState.Or(queries.map { it.toEditableStateInternal() })
|
||||
is SearchQuery.Not -> EditableSearchQueryState.Not(query.toEditableStateInternal())
|
||||
}
|
||||
|
||||
fun SearchQuery?.toEditableState(): EditableSearchQueryState.Root =
|
||||
EditableSearchQueryState.Root(this?.toEditableStateInternal())
|
||||
|
||||
private fun EditableSearchQueryState.Tag.toSearchQueryInternal(): SearchQuery.Tag? =
|
||||
if (namespace.value != null || tag.value.isNotBlank()) SearchQuery.Tag(
|
||||
namespace.value,
|
||||
tag.value.lowercase().trim()
|
||||
) else null
|
||||
|
||||
private fun EditableSearchQueryState.And.toSearchQueryInternal(): SearchQuery.And? =
|
||||
queries.mapNotNull { it.toSearchQueryInternal() }
|
||||
.let { if (it.isNotEmpty()) SearchQuery.And(it) else null }
|
||||
|
||||
private fun EditableSearchQueryState.Or.toSearchQueryInternal(): SearchQuery.Or? =
|
||||
queries.mapNotNull { it.toSearchQueryInternal() }
|
||||
.let { if (it.isNotEmpty()) SearchQuery.Or(it) else null }
|
||||
|
||||
private fun EditableSearchQueryState.Not.toSearchQueryInternal(): SearchQuery.Not? =
|
||||
query.value?.toSearchQueryInternal()?.let { SearchQuery.Not(it) }
|
||||
|
||||
private fun EditableSearchQueryState.toSearchQueryInternal(): SearchQuery? = when (this) {
|
||||
is EditableSearchQueryState.Tag -> this.toSearchQueryInternal()
|
||||
is EditableSearchQueryState.And -> this.toSearchQueryInternal()
|
||||
is EditableSearchQueryState.Or -> this.toSearchQueryInternal()
|
||||
is EditableSearchQueryState.Not -> this.toSearchQueryInternal()
|
||||
}
|
||||
|
||||
fun EditableSearchQueryState.Root.toSearchQuery(): SearchQuery? =
|
||||
query.value?.toSearchQueryInternal()
|
||||
|
||||
fun coalesceTags(
|
||||
oldTag: EditableSearchQueryState.Tag?,
|
||||
newTag: EditableSearchQueryState?,
|
||||
): EditableSearchQueryState? = if (oldTag != null) {
|
||||
when (newTag) {
|
||||
is EditableSearchQueryState.Tag,
|
||||
is EditableSearchQueryState.Not,
|
||||
-> EditableSearchQueryState.And(listOf(oldTag, newTag))
|
||||
|
||||
is EditableSearchQueryState.And -> newTag.apply { queries.add(oldTag) }
|
||||
is EditableSearchQueryState.Or -> newTag.apply { queries.add(oldTag) }
|
||||
null -> oldTag
|
||||
}
|
||||
} else newTag
|
||||
|
||||
sealed interface EditableSearchQueryState {
|
||||
class Tag(
|
||||
namespace: String? = null,
|
||||
tag: String = "",
|
||||
expanded: Boolean = false,
|
||||
) : EditableSearchQueryState {
|
||||
val namespace = mutableStateOf(namespace)
|
||||
val tag = mutableStateOf(tag)
|
||||
val expanded = mutableStateOf(expanded)
|
||||
}
|
||||
|
||||
class And(
|
||||
queries: List<EditableSearchQueryState> = emptyList(),
|
||||
) : EditableSearchQueryState {
|
||||
val queries = queries.toMutableStateList()
|
||||
}
|
||||
|
||||
class Or(
|
||||
queries: List<EditableSearchQueryState> = emptyList(),
|
||||
) : EditableSearchQueryState {
|
||||
val queries = queries.toMutableStateList()
|
||||
}
|
||||
|
||||
class Not(
|
||||
query: EditableSearchQueryState? = null,
|
||||
) : EditableSearchQueryState {
|
||||
val query = mutableStateOf(query)
|
||||
}
|
||||
|
||||
class Root(
|
||||
query: EditableSearchQueryState? = null,
|
||||
) {
|
||||
val query = mutableStateOf(query)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TagSuggestionList(
|
||||
state: EditableSearchQueryState.Tag,
|
||||
) {
|
||||
var suggestionList: List<Suggestion>? by remember { mutableStateOf(null) }
|
||||
|
||||
var namespace by state.namespace
|
||||
var tag by state.tag
|
||||
var expanded by state.expanded
|
||||
|
||||
LaunchedEffect(namespace, tag) {
|
||||
suggestionList = null
|
||||
|
||||
val searchQuery = state.toSearchQueryInternal()
|
||||
|
||||
suggestionList = if (searchQuery != null) {
|
||||
HitomiHttpClient.getSuggestionsForQuery(searchQuery)
|
||||
.getOrDefault(emptyList())
|
||||
.filterNot { it.tag == SearchQuery.Tag(namespace, tag) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val suggestionListSnapshot = suggestionList
|
||||
if (suggestionListSnapshot == null) {
|
||||
Row(
|
||||
Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(24.dp))
|
||||
Text("Loading")
|
||||
}
|
||||
} else if (suggestionListSnapshot.isNotEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
suggestionListSnapshot.forEach { suggestion ->
|
||||
TagChip(
|
||||
tag = suggestion.tag,
|
||||
onClick = {
|
||||
namespace = it.namespace
|
||||
tag = it.tag
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditableTagChip(
|
||||
state: EditableSearchQueryState.Tag,
|
||||
isFavorite: Boolean = false,
|
||||
autoFocus: Boolean = true,
|
||||
requestScrollTo: (Float) -> Unit,
|
||||
leftIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag -> TagChipIcon(tag) },
|
||||
rightIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { _ -> Spacer(Modifier.width(16.dp)) },
|
||||
content: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag ->
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
text = tag.tag.ifBlank { stringResource(R.string.search_bar_edit_tag) }
|
||||
)
|
||||
},
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var namespace by state.namespace
|
||||
var tag by state.tag
|
||||
var expanded by state.expanded
|
||||
var wasFocused by remember { mutableStateOf(false) }
|
||||
|
||||
var positionY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
LaunchedEffect(expanded) {
|
||||
if (!expanded) {
|
||||
wasFocused = false
|
||||
}
|
||||
}
|
||||
|
||||
val surfaceColor by animateColorAsState(
|
||||
when {
|
||||
expanded -> MaterialTheme.colorScheme.surface
|
||||
isFavorite -> Yellow400
|
||||
namespace == "male" -> Blue600
|
||||
namespace == "female" -> Pink600
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}, label = "tag surface color"
|
||||
)
|
||||
|
||||
val contentColor by animateColorAsState(
|
||||
when {
|
||||
expanded -> Color.White
|
||||
isFavorite -> Color.White
|
||||
namespace == "male" -> Color.White
|
||||
namespace == "female" -> Color.White
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}, label = "tag content color"
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.onGloballyPositioned {
|
||||
positionY = it.positionInRoot().y
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = surfaceColor,
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
AnimatedContent(targetState = expanded, label = "open tag editor") { targetExpanded ->
|
||||
if (!targetExpanded) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides contentColor,
|
||||
LocalTextStyle provides MaterialTheme.typography.bodyMedium
|
||||
) {
|
||||
val queryTag = SearchQuery.Tag(namespace, tag)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.clickable { expanded = true },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
leftIcon(queryTag)
|
||||
content(queryTag)
|
||||
rightIcon(queryTag)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp, bottom = 8.dp, end = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "close tag editor"
|
||||
)
|
||||
}
|
||||
|
||||
var selection by remember(tag) { mutableStateOf(TextRange(tag.length)) }
|
||||
var composition by remember { mutableStateOf<TextRange?>(null) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
val textFieldValue = remember(tag, selection, composition) {
|
||||
TextFieldValue(tag, selection, composition)
|
||||
}
|
||||
|
||||
LaunchedEffect(expanded) {
|
||||
if (autoFocus && expanded) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = textFieldValue,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
leadingIcon = {
|
||||
TagChipIcon(SearchQuery.Tag(namespace, tag))
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onKeyEvent { event ->
|
||||
if (event.key == Key.Backspace && tag.isEmpty()) {
|
||||
val newTag = namespace?.dropLast(1) ?: ""
|
||||
namespace = null
|
||||
tag = newTag
|
||||
selection = TextRange(newTag.length)
|
||||
composition = null
|
||||
true
|
||||
} else false
|
||||
}
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { event ->
|
||||
if (event.isFocused) {
|
||||
wasFocused = true
|
||||
coroutineScope.launch {
|
||||
delay(300)
|
||||
requestScrollTo(positionY)
|
||||
}
|
||||
} else if (wasFocused) {
|
||||
expanded = false
|
||||
}
|
||||
},
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
expanded = false
|
||||
}
|
||||
),
|
||||
onValueChange = { newTextValue ->
|
||||
val newTag = newTextValue.text
|
||||
val possibleNamespace = newTag.dropLast(1).lowercase().trim()
|
||||
tag =
|
||||
if (namespace == null && newTag.endsWith(':') && possibleNamespace in validNamespace) {
|
||||
namespace = possibleNamespace
|
||||
""
|
||||
} else newTag
|
||||
selection = newTextValue.selection
|
||||
composition = newTextValue.composition
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
TagSuggestionList(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewQueryChip(
|
||||
currentQuery: EditableSearchQueryState?,
|
||||
onNewQuery: (EditableSearchQueryState) -> Unit,
|
||||
) {
|
||||
var opened by remember { mutableStateOf(false) }
|
||||
|
||||
@Composable
|
||||
fun NewQueryRow(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector = Icons.Default.AddCircleOutline,
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.height(32.dp)
|
||||
.clickable(onClick = onClick),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(16.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = text
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(shape = RoundedCornerShape(16.dp), shadowElevation = 4.dp) {
|
||||
AnimatedContent(targetState = opened, label = "add new query") { targetOpened ->
|
||||
if (targetOpened) {
|
||||
Column {
|
||||
NewQueryRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
icon = Icons.Default.RemoveCircleOutline,
|
||||
text = stringResource(android.R.string.cancel)
|
||||
) {
|
||||
opened = false
|
||||
}
|
||||
HorizontalDivider()
|
||||
if (currentQuery != null && currentQuery !is EditableSearchQueryState.Tag && currentQuery !is EditableSearchQueryState.And) {
|
||||
NewQueryRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.search_add_query_item_tag)
|
||||
) {
|
||||
opened = false
|
||||
onNewQuery(EditableSearchQueryState.Tag(expanded = true))
|
||||
}
|
||||
}
|
||||
if (currentQuery !is EditableSearchQueryState.And) {
|
||||
HorizontalDivider()
|
||||
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "AND") {
|
||||
opened = false
|
||||
onNewQuery(EditableSearchQueryState.And())
|
||||
}
|
||||
}
|
||||
if (currentQuery !is EditableSearchQueryState.Or) {
|
||||
HorizontalDivider()
|
||||
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "OR") {
|
||||
opened = false
|
||||
onNewQuery(EditableSearchQueryState.Or())
|
||||
}
|
||||
}
|
||||
if (currentQuery !is EditableSearchQueryState.Not || currentQuery.query.value != null) {
|
||||
HorizontalDivider()
|
||||
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "NOT") {
|
||||
opened = false
|
||||
onNewQuery(EditableSearchQueryState.Not())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NewQueryRow(text = stringResource(R.string.search_add_query_item)) {
|
||||
opened = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QueryEditorQueryView(
|
||||
state: EditableSearchQueryState,
|
||||
onQueryRemove: (EditableSearchQueryState) -> Unit,
|
||||
requestScrollTo: (Float) -> Unit,
|
||||
requestScrollBy: (Float) -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is EditableSearchQueryState.Tag -> {
|
||||
EditableTagChip(
|
||||
state,
|
||||
requestScrollTo = requestScrollTo,
|
||||
rightIcon = {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(16.dp)
|
||||
.clickable {
|
||||
onQueryRemove(state)
|
||||
},
|
||||
imageVector = Icons.Default.RemoveCircleOutline,
|
||||
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is EditableSearchQueryState.Or -> {
|
||||
Card(
|
||||
colors = CardColors(
|
||||
containerColor = Blue300,
|
||||
contentColor = Color.Black,
|
||||
disabledContainerColor = Blue300,
|
||||
disabledContentColor = Color.Black
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
"OR",
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clickable { onQueryRemove(state) },
|
||||
imageVector = Icons.Default.RemoveCircleOutline,
|
||||
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||
)
|
||||
}
|
||||
state.queries.forEachIndexed { index, subQueryState ->
|
||||
if (index != 0) {
|
||||
Text("+", modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
QueryEditorQueryView(
|
||||
subQueryState,
|
||||
onQueryRemove = { state.queries.remove(it) },
|
||||
requestScrollTo = requestScrollTo,
|
||||
requestScrollBy = requestScrollBy
|
||||
)
|
||||
}
|
||||
NewQueryChip(state) { newQueryState ->
|
||||
state.queries.add(newQueryState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is EditableSearchQueryState.And -> {
|
||||
Card(
|
||||
colors = CardColors(
|
||||
containerColor = Gray300,
|
||||
contentColor = Color.Black,
|
||||
disabledContainerColor = Gray300,
|
||||
disabledContentColor = Color.Black
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
val newSearchQuery = remember { EditableSearchQueryState.Tag() }
|
||||
|
||||
var newQueryNamespace by newSearchQuery.namespace
|
||||
var newQueryTag by newSearchQuery.tag
|
||||
var newQueryExpanded by newSearchQuery.expanded
|
||||
|
||||
val offset = with(LocalDensity.current) { 40.dp.toPx() }
|
||||
|
||||
LaunchedEffect(newQueryExpanded) {
|
||||
if (!newQueryExpanded && (newQueryNamespace != null || newQueryTag.isNotBlank())) {
|
||||
state.queries.add(
|
||||
EditableSearchQueryState.Tag(
|
||||
newQueryNamespace,
|
||||
newQueryTag
|
||||
)
|
||||
)
|
||||
newQueryNamespace = null
|
||||
newQueryTag = ""
|
||||
newQueryExpanded = true
|
||||
requestScrollBy(offset)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
"AND",
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clickable { onQueryRemove(state) },
|
||||
imageVector = Icons.Default.RemoveCircleOutline,
|
||||
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||
)
|
||||
}
|
||||
state.queries.forEach { subQuery ->
|
||||
QueryEditorQueryView(
|
||||
subQuery,
|
||||
onQueryRemove = { state.queries.remove(it) },
|
||||
requestScrollTo = requestScrollTo,
|
||||
requestScrollBy = requestScrollBy
|
||||
)
|
||||
}
|
||||
EditableTagChip(
|
||||
newSearchQuery,
|
||||
requestScrollTo = requestScrollTo,
|
||||
)
|
||||
NewQueryChip(state) { newQueryState ->
|
||||
state.queries.add(newQueryState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is EditableSearchQueryState.Not -> {
|
||||
var subQueryState by state.query
|
||||
|
||||
Card(
|
||||
colors = CardColors(
|
||||
containerColor = Red300,
|
||||
contentColor = Color.Black,
|
||||
disabledContainerColor = Red300,
|
||||
disabledContentColor = Color.Black
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
"-",
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clickable { onQueryRemove(state) },
|
||||
imageVector = Icons.Default.RemoveCircleOutline,
|
||||
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||
)
|
||||
}
|
||||
val subQueryStateSnapshot = subQueryState
|
||||
if (subQueryStateSnapshot != null) {
|
||||
QueryEditorQueryView(
|
||||
subQueryStateSnapshot,
|
||||
onQueryRemove = { subQueryState = null },
|
||||
requestScrollTo = requestScrollTo,
|
||||
requestScrollBy = requestScrollBy,
|
||||
)
|
||||
}
|
||||
|
||||
if (subQueryStateSnapshot == null) {
|
||||
NewQueryChip(state) { newQueryState ->
|
||||
subQueryState = newQueryState
|
||||
}
|
||||
}
|
||||
|
||||
if (subQueryStateSnapshot is EditableSearchQueryState.Tag) {
|
||||
NewQueryChip(state) { newQueryState ->
|
||||
subQueryState = coalesceTags(subQueryStateSnapshot, newQueryState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QueryEditor(
|
||||
state: EditableSearchQueryState.Root,
|
||||
) {
|
||||
var rootQuery by state.query
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
var topY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val scrollOffset = with(LocalDensity.current) { 16.dp.toPx() }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned {
|
||||
topY = it.positionInRoot().y
|
||||
}
|
||||
.verticalScroll(scrollState)
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
val rootQuerySnapshot = rootQuery
|
||||
|
||||
val requestScrollTo: (Float) -> Unit = { target ->
|
||||
val topYSnapshot = topY
|
||||
|
||||
coroutineScope.launch {
|
||||
scrollState.animateScrollBy(
|
||||
target - topYSnapshot - scrollOffset,
|
||||
spring(stiffness = Spring.StiffnessLow)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val requestScrollBy: (Float) -> Unit = { value ->
|
||||
coroutineScope.launch {
|
||||
scrollState.animateScrollBy(value)
|
||||
}
|
||||
}
|
||||
|
||||
if (rootQuerySnapshot != null) {
|
||||
QueryEditorQueryView(
|
||||
state = rootQuerySnapshot,
|
||||
onQueryRemove = { rootQuery = null },
|
||||
requestScrollTo = requestScrollTo,
|
||||
requestScrollBy = requestScrollBy
|
||||
)
|
||||
}
|
||||
|
||||
if (rootQuerySnapshot is EditableSearchQueryState.Tag?) {
|
||||
val newSearchQuery = remember { EditableSearchQueryState.Tag(expanded = true) }
|
||||
|
||||
var newQueryNamespace by newSearchQuery.namespace
|
||||
var newQueryTag by newSearchQuery.tag
|
||||
var newQueryExpanded by newSearchQuery.expanded
|
||||
|
||||
val offset = with(LocalDensity.current) { 40.dp.toPx() }
|
||||
|
||||
LaunchedEffect(newQueryExpanded) {
|
||||
if (!newQueryExpanded && (newQueryNamespace != null || newQueryTag.isNotBlank())) {
|
||||
rootQuery = if (rootQuerySnapshot == null) {
|
||||
EditableSearchQueryState.Tag(newQueryNamespace, newQueryTag)
|
||||
} else {
|
||||
EditableSearchQueryState.And(
|
||||
listOf(
|
||||
rootQuerySnapshot,
|
||||
EditableSearchQueryState.Tag(newQueryNamespace, newQueryTag)
|
||||
)
|
||||
)
|
||||
}
|
||||
newQueryNamespace = null
|
||||
newQueryTag = ""
|
||||
newQueryExpanded = true
|
||||
requestScrollBy(offset)
|
||||
}
|
||||
}
|
||||
|
||||
EditableTagChip(
|
||||
newSearchQuery,
|
||||
requestScrollTo = requestScrollTo
|
||||
)
|
||||
NewQueryChip(rootQuerySnapshot) { newState ->
|
||||
rootQuery = coalesceTags(rootQuerySnapshot, newState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
703
app/src/main/java/xyz/quaver/pupil/ui/composable/SearchScreen.kt
Normal file
@@ -0,0 +1,703 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.AnimationState
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.animateTo
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsTopHeight
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Label
|
||||
import androidx.compose.material.icons.filled.Book
|
||||
import androidx.compose.material.icons.filled.Brush
|
||||
import androidx.compose.material.icons.filled.Face
|
||||
import androidx.compose.material.icons.filled.Female
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material.icons.filled.Male
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.window.layout.DisplayFeature
|
||||
import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy
|
||||
import com.google.accompanist.adaptive.TwoPane
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.networking.GalleryInfo
|
||||
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||
import xyz.quaver.pupil.networking.SearchQuery
|
||||
import xyz.quaver.pupil.ui.theme.Blue600
|
||||
import xyz.quaver.pupil.ui.theme.Pink600
|
||||
import xyz.quaver.pupil.ui.theme.Yellow400
|
||||
import xyz.quaver.pupil.ui.viewmodel.SearchState
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val iconMap = mapOf(
|
||||
"female" to Icons.Default.Female,
|
||||
"male" to Icons.Default.Male,
|
||||
"artist" to Icons.Default.Brush,
|
||||
"group" to Icons.Default.Group,
|
||||
"character" to Icons.Default.Face,
|
||||
"series" to Icons.Default.Book,
|
||||
"type" to Icons.Default.Folder,
|
||||
"language" to Icons.Default.Translate,
|
||||
"tag" to Icons.AutoMirrored.Filled.Label,
|
||||
)
|
||||
|
||||
val languageIconMap = mapOf(
|
||||
"indonesian" to R.drawable.language_indonesian,
|
||||
"javanese" to R.drawable.language_javanese,
|
||||
"catalan" to R.drawable.language_catalan,
|
||||
"cebuano" to R.drawable.language_philippines,
|
||||
"czech" to R.drawable.language_czech,
|
||||
"danish" to R.drawable.language_danish,
|
||||
"german" to R.drawable.language_german,
|
||||
"estonian" to R.drawable.language_estonian,
|
||||
"english" to R.drawable.language_english,
|
||||
"spanish" to R.drawable.language_spanish,
|
||||
"french" to R.drawable.language_french,
|
||||
"italian" to R.drawable.language_italian,
|
||||
"latin" to R.drawable.language_latin,
|
||||
"hungarian" to R.drawable.language_hungarian,
|
||||
"dutch" to R.drawable.language_dutch,
|
||||
"norwegian" to R.drawable.language_norwegian,
|
||||
"polish" to R.drawable.language_polish,
|
||||
"portuguese" to R.drawable.language_portuguese,
|
||||
"romanian" to R.drawable.language_romanian,
|
||||
"albanian" to R.drawable.language_albanian,
|
||||
"slovak" to R.drawable.language_slovak,
|
||||
"finnish" to R.drawable.language_finnish,
|
||||
"swedish" to R.drawable.language_swedish,
|
||||
"tagalog" to R.drawable.language_philippines,
|
||||
"vietnamese" to R.drawable.language_vietnamese,
|
||||
"turkish" to R.drawable.language_turkish,
|
||||
"greek" to R.drawable.language_greek,
|
||||
"mongolian" to R.drawable.language_mongolian,
|
||||
"russian" to R.drawable.language_russian,
|
||||
"ukrainian" to R.drawable.language_ukrainian,
|
||||
"hebrew" to R.drawable.language_hebrew,
|
||||
"persian" to R.drawable.language_persian,
|
||||
"thai" to R.drawable.language_thai,
|
||||
"korean" to R.drawable.language_korean,
|
||||
"chinese" to R.drawable.language_chinese,
|
||||
"japanese" to R.drawable.language_japanese,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TagChipIcon(tag: SearchQuery.Tag) {
|
||||
val icon = iconMap[tag.namespace]
|
||||
|
||||
if (icon != null) {
|
||||
if (tag.namespace == "language" && languageIconMap.contains(tag.tag)) {
|
||||
Icon(
|
||||
painter = painterResource(languageIconMap[tag.tag]!!),
|
||||
contentDescription = "icon",
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.size(24.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = "icon",
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.width(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TagChip(
|
||||
tag: SearchQuery.Tag,
|
||||
isFavorite: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
onClick: (SearchQuery.Tag) -> Unit = { },
|
||||
leftIcon: @Composable (SearchQuery.Tag) -> Unit = { TagChipIcon(it) },
|
||||
rightIcon: @Composable (SearchQuery.Tag) -> Unit = { Spacer(Modifier.width(16.dp)) },
|
||||
content: @Composable RowScope.(SearchQuery.Tag) -> Unit = {
|
||||
Text(
|
||||
it.tag,
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
)
|
||||
},
|
||||
) {
|
||||
val surfaceColor = if (isFavorite) Yellow400 else when (tag.namespace) {
|
||||
"male" -> Blue600
|
||||
"female" -> Pink600
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
val contentColor =
|
||||
if (surfaceColor == MaterialTheme.colorScheme.surface)
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
else
|
||||
Color.White
|
||||
|
||||
val inner = @Composable {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides contentColor,
|
||||
LocalTextStyle provides MaterialTheme.typography.bodyMedium
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
leftIcon(tag)
|
||||
content(tag)
|
||||
rightIcon(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val modifier = Modifier.height(32.dp)
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
|
||||
if (enabled)
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = shape,
|
||||
color = surfaceColor,
|
||||
onClick = { onClick(tag) },
|
||||
content = inner,
|
||||
shadowElevation = 4.dp
|
||||
)
|
||||
else
|
||||
Surface(
|
||||
modifier,
|
||||
shape = shape,
|
||||
color = surfaceColor,
|
||||
content = inner,
|
||||
shadowElevation = 4.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QueryView(
|
||||
query: SearchQuery?,
|
||||
topLevel: Boolean = true,
|
||||
) {
|
||||
val modifier = if (topLevel) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.border(
|
||||
width = 0.5.dp,
|
||||
color = LocalContentColor.current,
|
||||
shape = CardDefaults.shape
|
||||
)
|
||||
}
|
||||
|
||||
when (query) {
|
||||
null -> {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.height(60.dp)
|
||||
.wrapContentHeight()
|
||||
.padding(horizontal = 16.dp),
|
||||
text = stringResource(id = R.string.search_hint),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
is SearchQuery.Tag -> {
|
||||
TagChip(
|
||||
query,
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
|
||||
is SearchQuery.Or -> {
|
||||
Row(
|
||||
modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
query.queries.forEachIndexed { index, subQuery ->
|
||||
if (index != 0) {
|
||||
Text("+")
|
||||
}
|
||||
QueryView(subQuery, topLevel = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SearchQuery.And -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
query.queries.forEach { subQuery ->
|
||||
QueryView(subQuery, topLevel = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SearchQuery.Not -> {
|
||||
Row(
|
||||
modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("-")
|
||||
QueryView(query.query, topLevel = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
contentType: ContentType,
|
||||
query: SearchQuery?,
|
||||
onQueryChange: (SearchQuery?) -> Unit,
|
||||
onSearchBarPositioned: (Int) -> Unit,
|
||||
topOffset: Int,
|
||||
onTopOffsetChange: (Int) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val scrimAlpha: Float by animateFloatAsState(
|
||||
if (focused && contentType == ContentType.SINGLE_PANE) 0.3f else 0f,
|
||||
label = "scrim alpha"
|
||||
)
|
||||
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val state = remember(query) { query.toEditableState() }
|
||||
|
||||
LaunchedEffect(focused) {
|
||||
if (!focused) {
|
||||
onQueryChange(state.toSearchQuery())
|
||||
} else {
|
||||
AnimationState(Int.VectorConverter, topOffset).animateTo(0) { onTopOffsetChange(value) }
|
||||
}
|
||||
}
|
||||
|
||||
if (focused) {
|
||||
BackHandler {
|
||||
focused = false
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) {
|
||||
focused = false
|
||||
}
|
||||
) {
|
||||
val height: Dp by animateDpAsState(
|
||||
if (focused) maxHeight else 60.dp,
|
||||
label = "searchbar height"
|
||||
)
|
||||
val cardShape = RoundedCornerShape(30.dp)
|
||||
|
||||
content()
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = scrimAlpha))
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.safeDrawingPadding()
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) {
|
||||
focused = true
|
||||
}
|
||||
.onGloballyPositioned {
|
||||
onSearchBarPositioned(it.positionInRoot().y.roundToInt() + it.size.height)
|
||||
}
|
||||
.absoluteOffset { IntOffset(0, topOffset) },
|
||||
shape = cardShape,
|
||||
elevation = CardDefaults.cardElevation(6.dp)
|
||||
) {
|
||||
Box {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
!focused,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.heightIn(min = 60.dp)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(Modifier.size(8.dp))
|
||||
QueryView(query)
|
||||
Box(Modifier.size(8.dp))
|
||||
}
|
||||
}
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
focused,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
focused = false
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "close search bar"
|
||||
)
|
||||
}
|
||||
QueryEditor(state = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GalleryList(
|
||||
contentType: ContentType,
|
||||
galleries: List<GalleryInfo>,
|
||||
query: SearchQuery?,
|
||||
currentPage: Int,
|
||||
maxPage: Int,
|
||||
loading: Boolean = false,
|
||||
error: Boolean = false,
|
||||
onPageChange: (Int) -> Unit,
|
||||
onQueryChange: (SearchQuery?) -> Unit = {},
|
||||
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
var topOffset by remember { mutableIntStateOf(0) }
|
||||
var searchBarPosition by remember { mutableIntStateOf(0) }
|
||||
|
||||
val listModifier = Modifier.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
topOffset = (topOffset + available.y.roundToInt()).coerceIn(-searchBarPosition, 0)
|
||||
return Offset.Zero
|
||||
}
|
||||
})
|
||||
|
||||
LaunchedEffect(galleries) {
|
||||
listState.animateScrollToItem(0)
|
||||
topOffset = 0
|
||||
}
|
||||
|
||||
SearchBar(
|
||||
contentType = contentType,
|
||||
query = query,
|
||||
onQueryChange = onQueryChange,
|
||||
onSearchBarPositioned = { searchBarPosition = it },
|
||||
topOffset = topOffset,
|
||||
onTopOffsetChange = { topOffset = it },
|
||||
) {
|
||||
AnimatedVisibility(loading, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(error, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
"(´∇`)",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
Text("No sources found!\nLet's go download one.", textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(!loading && !error, enter = fadeIn(), exit = fadeOut()) {
|
||||
OverscrollPager(
|
||||
prevPage = if (currentPage != 0) currentPage else null,
|
||||
nextPage = if (currentPage < maxPage) currentPage + 2 else null,
|
||||
onPageTurn = { onPageChange(it - 1) }
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = listModifier,
|
||||
contentPadding = WindowInsets.systemBars.asPaddingValues()
|
||||
.let { systemBarPaddingValues ->
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
PaddingValues(
|
||||
top = systemBarPaddingValues.calculateTopPadding() + 96.dp,
|
||||
bottom = systemBarPaddingValues.calculateBottomPadding(),
|
||||
start = systemBarPaddingValues.calculateStartPadding(layoutDirection),
|
||||
end = systemBarPaddingValues.calculateEndPadding(layoutDirection),
|
||||
)
|
||||
},
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
state = listState
|
||||
) {
|
||||
items(galleries, key = { it.id }) { galleryInfo ->
|
||||
DetailedGalleryInfo(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.clickable { openGalleryDetails(galleryInfo) },
|
||||
galleryInfo = galleryInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DetailScreen(
|
||||
galleryInfo: GalleryInfo,
|
||||
closeGalleryDetails: () -> Unit = { },
|
||||
openGallery: (GalleryInfo) -> Unit = { },
|
||||
) {
|
||||
var thumbnailUrl by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(galleryInfo) {
|
||||
thumbnailUrl = galleryInfo.files.firstOrNull()?.let {
|
||||
HitomiHttpClient.getImageURL(it, true).firstOrNull()
|
||||
} ?: ""
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars))
|
||||
IconButton(onClick = closeGalleryDetails) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close Detail")
|
||||
}
|
||||
|
||||
DetailedGalleryInfoHeader(galleryInfo, thumbnailUrl)
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
FilledTonalButton(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 4.dp),
|
||||
onClick = { /*TODO*/ }
|
||||
) {
|
||||
Text(stringResource(R.string.download))
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 4.dp),
|
||||
onClick = { openGallery(galleryInfo) }
|
||||
) {
|
||||
Text("Open")
|
||||
}
|
||||
}
|
||||
|
||||
GalleryTypeIndicator(galleryInfo.type)
|
||||
|
||||
if (galleryInfo.series?.isNotEmpty() == true) {
|
||||
TagGroup(galleryInfo.series.map { it.toTag() })
|
||||
}
|
||||
|
||||
if (galleryInfo.characters?.isNotEmpty() == true) {
|
||||
TagGroup(galleryInfo.characters.map { it.toTag() })
|
||||
}
|
||||
|
||||
if (galleryInfo.tags?.isNotEmpty() == true) {
|
||||
TagGroup(galleryInfo.tags.map { it.toTag() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
contentType: ContentType,
|
||||
displayFeatures: List<DisplayFeature>,
|
||||
uiState: SearchState,
|
||||
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||
closeGalleryDetails: () -> Unit,
|
||||
onQueryChange: (SearchQuery?) -> Unit,
|
||||
loadSearchResult: (IntRange) -> Unit,
|
||||
openGallery: (GalleryInfo) -> Unit,
|
||||
) {
|
||||
val itemsPerPage by remember { mutableIntStateOf(20) }
|
||||
|
||||
val pageToRange: (Int) -> IntRange = remember(itemsPerPage) {
|
||||
{ page ->
|
||||
page * itemsPerPage..<(page + 1) * itemsPerPage
|
||||
}
|
||||
}
|
||||
|
||||
val currentPage = remember(uiState) {
|
||||
if (uiState.currentRange != IntRange.EMPTY) {
|
||||
uiState.currentRange.first / itemsPerPage
|
||||
} else 0
|
||||
}
|
||||
|
||||
val maxPage = remember(itemsPerPage, uiState) {
|
||||
if (uiState.galleryCount != null) {
|
||||
uiState.galleryCount / itemsPerPage + if (uiState.galleryCount % itemsPerPage != 0) 1 else 0
|
||||
} else 0
|
||||
}
|
||||
|
||||
val loadResult: (Int) -> Unit = remember(loadSearchResult) {
|
||||
{ page ->
|
||||
loadSearchResult(pageToRange(page))
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.query) { loadSearchResult(pageToRange(currentPage)) }
|
||||
|
||||
LaunchedEffect(contentType) {
|
||||
if (contentType == ContentType.SINGLE_PANE && !uiState.isDetailOnlyOpen) {
|
||||
closeGalleryDetails()
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType == ContentType.SINGLE_PANE && uiState.isDetailOnlyOpen) {
|
||||
BackHandler {
|
||||
closeGalleryDetails()
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType == ContentType.DUAL_PANE) {
|
||||
TwoPane(
|
||||
first = {
|
||||
GalleryList(
|
||||
contentType = contentType,
|
||||
galleries = uiState.galleries,
|
||||
query = uiState.query,
|
||||
currentPage = currentPage,
|
||||
maxPage = maxPage,
|
||||
loading = uiState.loading,
|
||||
error = uiState.error,
|
||||
onQueryChange = onQueryChange,
|
||||
onPageChange = loadResult,
|
||||
openGalleryDetails = openGalleryDetails
|
||||
)
|
||||
},
|
||||
second = {
|
||||
|
||||
},
|
||||
strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f, gapWidth = 16.dp),
|
||||
displayFeatures = displayFeatures
|
||||
)
|
||||
} else {
|
||||
val detailGallery = uiState.openedGallery
|
||||
AnimatedVisibility(!uiState.isDetailOnlyOpen || detailGallery == null) {
|
||||
GalleryList(
|
||||
contentType = contentType,
|
||||
galleries = uiState.galleries,
|
||||
query = uiState.query,
|
||||
currentPage = currentPage,
|
||||
maxPage = maxPage,
|
||||
loading = uiState.loading,
|
||||
error = uiState.error,
|
||||
onQueryChange = onQueryChange,
|
||||
onPageChange = loadResult,
|
||||
openGalleryDetails = openGalleryDetails
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(uiState.isDetailOnlyOpen && detailGallery != null) {
|
||||
if (detailGallery != null) {
|
||||
DetailScreen(
|
||||
galleryInfo = detailGallery,
|
||||
closeGalleryDetails = closeGalleryDetails,
|
||||
openGallery = openGallery
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,165 +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.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
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.types.Tags
|
||||
import xyz.quaver.pupil.util.Preferences
|
||||
|
||||
class DefaultQueryDialog(context : Context) : AlertDialog(context) {
|
||||
|
||||
private val languages = context.resources.getStringArray(R.array.languages).map {
|
||||
it.split("|").let { split ->
|
||||
Pair(split[0], split[1])
|
||||
}
|
||||
}.toMap()
|
||||
private val reverseLanguages = languages.entries.associate { (k, v) -> v to k }
|
||||
|
||||
private val excludeBL = "-male:yaoi"
|
||||
private val excludeGuro = listOf("-female:guro", "-male:guro")
|
||||
private val excludeLoli = listOf("-female:loli", "-male:shota")
|
||||
|
||||
var onPositiveButtonClickListener : ((Tags) -> (Unit))? = null
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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) {
|
||||
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 tags = Tags.parse(
|
||||
Preferences["default_query"]
|
||||
)
|
||||
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_default_query, null)
|
||||
|
||||
with(view.default_query_dialog_language_selector) {
|
||||
adapter =
|
||||
ArrayAdapter(
|
||||
context,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
arrayListOf(
|
||||
context.getString(R.string.default_query_dialog_language_selector_none)
|
||||
).apply {
|
||||
addAll(languages.values)
|
||||
}
|
||||
)
|
||||
if (tags.any { it.area == "language" && !it.isNegative }) {
|
||||
val tag = languages[tags.first { it.area == "language" }.tag]
|
||||
if (tag != null) {
|
||||
setSelection(
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(adapter as ArrayAdapter<String>).getPosition(tag)
|
||||
)
|
||||
tags.removeByArea("language", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with(view.default_query_dialog_BL_checkbox) {
|
||||
isChecked = tags.contains(excludeBL)
|
||||
if (tags.contains(excludeBL))
|
||||
tags.remove(excludeBL)
|
||||
}
|
||||
|
||||
with(view.default_query_dialog_guro_checkbox) {
|
||||
isChecked = excludeGuro.all { tags.contains(it) }
|
||||
if (excludeGuro.all { tags.contains(it) })
|
||||
excludeGuro.forEach {
|
||||
tags.remove(it)
|
||||
}
|
||||
}
|
||||
|
||||
with(view.default_query_dialog_loli_checkbox) {
|
||||
isChecked = excludeLoli.all { tags.contains(it) }
|
||||
if (excludeLoli.all { tags.contains(it) })
|
||||
excludeLoli.forEach {
|
||||
tags.remove(it)
|
||||
}
|
||||
}
|
||||
|
||||
with(view.default_query_dialog_edittext) {
|
||||
setText(tags.toString(), android.widget.TextView.BufferType.EDITABLE)
|
||||
addTextChangedListener(object : TextWatcher {
|
||||
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(java.util.Locale.getDefault())
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,74 +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.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.dialog_download_folder_name.view.*
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import xyz.quaver.pupil.R
|
||||
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() {
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private fun build(): View {
|
||||
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()
|
||||
}
|
||||
|
||||
return layoutInflater.inflate(R.layout.dialog_download_folder_name, null).apply {
|
||||
message.text = getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolder() ?: "")
|
||||
edittext.setText(Preferences["download_folder_name", "[-id-] -title-"])
|
||||
edittext.addTextChangedListener {
|
||||
message.text = getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolderTest(it.toString()) ?: "")
|
||||
}
|
||||
ok_button.setOnClickListener {
|
||||
val newValue = edittext.text.toString()
|
||||
|
||||
if ((newValue as? String)?.contains("/") != false) {
|
||||
Snackbar.make(this, R.string.settings_invalid_download_folder_name, Snackbar.LENGTH_SHORT).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
Preferences["download_folder_name"] = edittext.text.toString()
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
|
||||
Dialog(requireContext()).apply {
|
||||
setContentView(build())
|
||||
window?.attributes?.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui.dialog
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
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 kotlinx.android.synthetic.main.dialog_download_folder_name.view.*
|
||||
import kotlinx.android.synthetic.main.item_download_folder.view.*
|
||||
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.util.Preferences
|
||||
import xyz.quaver.pupil.util.byteToString
|
||||
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||
import xyz.quaver.pupil.util.migrate
|
||||
import java.io.File
|
||||
|
||||
class DownloadLocationDialogFragment : DialogFragment() {
|
||||
private val entries = mutableMapOf<File?, View>()
|
||||
|
||||
private val requestDownloadFolderLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
val activity = activity ?: return@registerForActivityResult
|
||||
val context = context ?: return@registerForActivityResult
|
||||
val dialog = dialog ?: return@registerForActivityResult
|
||||
|
||||
it.data?.data?.also { uri ->
|
||||
val takeFlags: Int =
|
||||
activity.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)
|
||||
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
|
||||
if (kotlin.runCatching { FileX(context, uri).canWrite() }.getOrDefault(false)) {
|
||||
entries[null]?.location_available?.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]!!.location_available.text = downloadFolder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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]!!.location_available.text = downloadFolder
|
||||
}
|
||||
else {
|
||||
entries[null]?.location_available?.text = directory
|
||||
Preferences["download_folder"] = File(directory).toURI().toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private fun build() : View? {
|
||||
val context = context ?: return null
|
||||
|
||||
val view = layoutInflater.inflate(R.layout.dialog_download_folder, null) as LinearLayout
|
||||
|
||||
val externalFilesDirs = ContextCompat.getExternalFilesDirs(context, null)
|
||||
|
||||
externalFilesDirs.forEachIndexed { index, dir ->
|
||||
dir ?: return@forEachIndexed
|
||||
|
||||
view.addView(layoutInflater.inflate(R.layout.item_download_folder, view, false).apply {
|
||||
location_type.text = context.getString(when (index) {
|
||||
0 -> R.string.settings_download_folder_internal
|
||||
else -> R.string.settings_download_folder_removable
|
||||
})
|
||||
location_available.text = context.getString(
|
||||
R.string.settings_download_folder_available,
|
||||
byteToString(dir.freeSpace)
|
||||
)
|
||||
setOnClickListener {
|
||||
entries.values.forEach {
|
||||
it.button.isChecked = false
|
||||
}
|
||||
button.performClick()
|
||||
Preferences["download_folder"] = dir.toUri().toString()
|
||||
}
|
||||
entries[dir] = this
|
||||
})
|
||||
}
|
||||
|
||||
view.addView(layoutInflater.inflate(R.layout.item_download_folder, view, false).apply {
|
||||
location_type.text = context.getString(R.string.settings_download_folder_custom)
|
||||
setOnClickListener {
|
||||
entries.values.forEach {
|
||||
it.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(context).downloadFolder.canonicalPath
|
||||
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
|
||||
entries[key]!!.button.isChecked = true
|
||||
if (key == null) entries[key]!!.location_available.text = downloadFolder
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val builder = AlertDialog.Builder(requireContext())
|
||||
|
||||
builder
|
||||
.setTitle(R.string.settings_download_folder)
|
||||
.setView(build())
|
||||
.setPositiveButton(requireContext().getText(android.R.string.ok)) { _, _ ->
|
||||
if (Preferences["download_folder", ""].isEmpty())
|
||||
Preferences["download_folder"] = context?.getExternalFilesDir(null)?.toUri()?.toString() ?: ""
|
||||
|
||||
DownloadManager.getInstance(requireContext()).migrate()
|
||||
}
|
||||
|
||||
isCancelable = false
|
||||
|
||||
return builder.create()
|
||||
}
|
||||
}
|
||||
@@ -1,271 +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.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout.LayoutParams
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.dialog_gallery.*
|
||||
import kotlinx.android.synthetic.main.dialog_gallery_details.view.*
|
||||
import kotlinx.android.synthetic.main.dialog_gallery_dotindicator.view.*
|
||||
import kotlinx.android.synthetic.main.item_gallery_details.view.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import xyz.quaver.hitomi.Gallery
|
||||
import xyz.quaver.hitomi.getGallery
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.adapters.GalleryBlockAdapter
|
||||
import xyz.quaver.pupil.adapters.ThumbnailPageAdapter
|
||||
import xyz.quaver.pupil.favoriteTags
|
||||
import xyz.quaver.pupil.histories
|
||||
import xyz.quaver.pupil.types.Tag
|
||||
import xyz.quaver.pupil.ui.ReaderActivity
|
||||
import xyz.quaver.pupil.ui.view.TagChip
|
||||
import xyz.quaver.pupil.util.ItemClickSupport
|
||||
import xyz.quaver.pupil.util.downloader.Cache
|
||||
import xyz.quaver.pupil.util.wordCapitalize
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class GalleryDialog(context: Context, private val galleryID: Int) : AlertDialog(context) {
|
||||
|
||||
val onChipClickedHandler = ArrayList<((Tag) -> (Unit))>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.dialog_gallery)
|
||||
|
||||
window?.attributes.apply {
|
||||
this ?: return@apply
|
||||
|
||||
width = LayoutParams.MATCH_PARENT
|
||||
height = LayoutParams.MATCH_PARENT
|
||||
}
|
||||
|
||||
with(gallery_fab) {
|
||||
setImageDrawable(ContextCompat.getDrawable(context, R.drawable.arrow_right))
|
||||
setOnClickListener {
|
||||
context.startActivity(Intent(context, ReaderActivity::class.java).apply {
|
||||
putExtra("galleryID", galleryID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val gallery = getGallery(galleryID)
|
||||
|
||||
gallery_cover.post {
|
||||
gallery_progressbar.visibility = View.GONE
|
||||
gallery_title.text = gallery.title
|
||||
gallery_artist.text = gallery.artists.joinToString(", ") { it.wordCapitalize() }
|
||||
|
||||
with(gallery_type) {
|
||||
text = gallery.type.wordCapitalize()
|
||||
setOnClickListener {
|
||||
gallery.type.let {
|
||||
when (it) {
|
||||
"artist CG" -> "artistcg"
|
||||
"game CG" -> "gamecg"
|
||||
else -> it
|
||||
}
|
||||
}.let {
|
||||
onChipClickedHandler.forEach { handler ->
|
||||
handler.invoke(Tag("type", it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gallery_cover.showImage(Uri.parse(gallery.cover))
|
||||
|
||||
addDetails(gallery)
|
||||
addThumbnails(gallery)
|
||||
addRelated(gallery)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Snackbar.make(gallery_layout, 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) {
|
||||
val inflater = LayoutInflater.from(context)
|
||||
|
||||
inflater.inflate(R.layout.dialog_gallery_details, gallery_contents, false).apply {
|
||||
gallery_details.setText(R.string.gallery_details)
|
||||
|
||||
listOf(
|
||||
R.string.gallery_artists,
|
||||
R.string.gallery_groups,
|
||||
R.string.gallery_language,
|
||||
R.string.gallery_series,
|
||||
R.string.gallery_characters,
|
||||
R.string.gallery_tags
|
||||
).zip(
|
||||
listOf(
|
||||
gallery.artists.map { Tag("artist", it) },
|
||||
gallery.groups.map { Tag("group", it) },
|
||||
listOf(gallery.language).map { Tag("language", it) },
|
||||
gallery.series.map { Tag("series", it) },
|
||||
gallery.characters.map { Tag("character", it) },
|
||||
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 ->
|
||||
when {
|
||||
tag.area != null -> tag
|
||||
else -> Tag("tag", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
).filter {
|
||||
(_, content) -> content.isNotEmpty()
|
||||
}.forEach { (title, content) ->
|
||||
inflater.inflate(R.layout.item_gallery_details, gallery_details_contents, false).apply {
|
||||
gallery_details_type.setText(title)
|
||||
|
||||
content.forEach { tag ->
|
||||
gallery_details_tags.addView(
|
||||
TagChip(context, tag).apply {
|
||||
setOnClickListener {
|
||||
onChipClickedHandler.forEach { handler ->
|
||||
handler.invoke(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}.let {
|
||||
gallery_details_contents.addView(it)
|
||||
}
|
||||
}
|
||||
}.let {
|
||||
gallery_contents.addView(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addThumbnails(gallery: Gallery) {
|
||||
val inflater = LayoutInflater.from(context)
|
||||
|
||||
inflater.inflate(R.layout.dialog_gallery_details, gallery_contents, false).apply {
|
||||
gallery_details.setText(R.string.gallery_thumbnails)
|
||||
|
||||
val pager = ViewPager2(context).apply {
|
||||
adapter = ThumbnailPageAdapter(gallery.thumbnails)
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
gallery_details_contents.addView(
|
||||
pager,
|
||||
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
)
|
||||
|
||||
LayoutInflater.from(context).inflate(R.layout.dialog_gallery_dotindicator, gallery_details_contents)
|
||||
|
||||
gallery_dotindicator.setViewPager2(pager)
|
||||
}.let {
|
||||
gallery_contents.addView(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addRelated(gallery: Gallery) {
|
||||
val inflater = LayoutInflater.from(context)
|
||||
val galleries = ArrayList<Int>()
|
||||
|
||||
val adapter = GalleryBlockAdapter(galleries).apply {
|
||||
onChipClickedHandler.add { tag ->
|
||||
this@GalleryDialog.onChipClickedHandler.forEach { handler ->
|
||||
handler.invoke(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inflater.inflate(R.layout.dialog_gallery_details, gallery_contents, false).apply {
|
||||
gallery_details.setText(R.string.gallery_related)
|
||||
|
||||
RecyclerView(context).apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
this.adapter = adapter
|
||||
|
||||
ItemClickSupport.addTo(this).apply {
|
||||
onItemClickListener = { _, position, _ ->
|
||||
context.startActivity(Intent(context, ReaderActivity::class.java).apply {
|
||||
putExtra("galleryID", galleries[position])
|
||||
})
|
||||
}
|
||||
onItemLongClickListener = { _, position, _ ->
|
||||
GalleryDialog(context, galleries[position]).apply {
|
||||
onChipClickedHandler.add { tag ->
|
||||
this@GalleryDialog.onChipClickedHandler.forEach { it.invoke(tag) }
|
||||
}
|
||||
}.show()
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
}.let {
|
||||
gallery_details_contents.addView(it, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
||||
}
|
||||
}.let {
|
||||
gallery_contents.addView(it)
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
gallery.related.forEach { galleryID ->
|
||||
Cache.getInstance(context, galleryID).getGalleryBlock()?.let {
|
||||
galleries.add(galleryID)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,91 +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.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
|
||||
import xyz.quaver.pupil.util.Preferences
|
||||
|
||||
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 = {
|
||||
Preferences["mirrors"] = it.joinToString(">")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,133 +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.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import kotlinx.android.synthetic.main.dialog_proxy.view.*
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.client
|
||||
import xyz.quaver.pupil.clientBuilder
|
||||
import xyz.quaver.pupil.clientHolder
|
||||
import xyz.quaver.pupil.util.Preferences
|
||||
import xyz.quaver.pupil.util.ProxyInfo
|
||||
import xyz.quaver.pupil.util.getProxyInfo
|
||||
import xyz.quaver.pupil.util.proxyInfo
|
||||
import java.net.Proxy
|
||||
|
||||
class ProxyDialog(context: Context) : AlertDialog(context) {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setView(build())
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private fun build() : View {
|
||||
val proxyInfo = getProxyInfo()
|
||||
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_proxy, null)
|
||||
|
||||
val enabler = { enable: Boolean ->
|
||||
view?.proxy_addr?.isEnabled = enable
|
||||
view?.proxy_port?.isEnabled = enable
|
||||
view?.proxy_username?.isEnabled = enable
|
||||
view?.proxy_password?.isEnabled = enable
|
||||
|
||||
if (!enable) {
|
||||
view?.proxy_addr?.text = null
|
||||
view?.proxy_port?.text = null
|
||||
view?.proxy_username?.text = null
|
||||
view?.proxy_password?.text = null
|
||||
}
|
||||
}
|
||||
|
||||
with(view.proxy_type_selector) {
|
||||
adapter = ArrayAdapter(
|
||||
context,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
context.resources.getStringArray(R.array.proxy_type)
|
||||
)
|
||||
|
||||
setSelection(proxyInfo.type.ordinal)
|
||||
|
||||
onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
enabler.invoke(position != 0)
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
}
|
||||
|
||||
view.proxy_addr.setText(proxyInfo.host)
|
||||
view.proxy_port.setText(proxyInfo.port?.toString())
|
||||
view.proxy_username.setText(proxyInfo.username)
|
||||
view.proxy_password.setText(proxyInfo.password)
|
||||
|
||||
enabler.invoke(proxyInfo.type != Proxy.Type.DIRECT)
|
||||
|
||||
view.proxy_cancel.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
view.proxy_ok.setOnClickListener {
|
||||
val type = Proxy.Type.values()[view.proxy_type_selector.selectedItemPosition]
|
||||
val addr = view.proxy_addr.text?.toString()
|
||||
val port = view.proxy_port.text?.toString()?.toIntOrNull()
|
||||
val username = view.proxy_username.text?.toString()
|
||||
val password = view.proxy_password.text?.toString()
|
||||
|
||||
if (type != Proxy.Type.DIRECT) {
|
||||
if (addr == null || addr.isEmpty())
|
||||
view.proxy_addr.error = context.getText(R.string.proxy_dialog_error)
|
||||
if (port == null)
|
||||
view.proxy_port.error = context.getText(R.string.proxy_dialog_error)
|
||||
|
||||
if (addr == null || addr.isEmpty() || port == null)
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
ProxyInfo(type, addr, port, username, password).let {
|
||||
Preferences["proxy"] = Json.encodeToString(it)
|
||||
|
||||
clientBuilder
|
||||
.proxyInfo(it)
|
||||
clientHolder = null
|
||||
client
|
||||
}
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui.fragment
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.ui.LockActivity
|
||||
import xyz.quaver.pupil.util.Lock
|
||||
import xyz.quaver.pupil.util.LockManager
|
||||
import xyz.quaver.pupil.util.Preferences
|
||||
|
||||
class LockSettingsFragment : PreferenceFragmentCompat() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
val lockManager = LockManager(requireContext())
|
||||
|
||||
findPreference<Preference>("lock_pattern")?.summary =
|
||||
if (lockManager.contains(Lock.Type.PATTERN))
|
||||
getString(R.string.settings_lock_enabled)
|
||||
else
|
||||
""
|
||||
|
||||
findPreference<Preference>("lock_pin")?.summary =
|
||||
if (lockManager.contains(Lock.Type.PIN))
|
||||
getString(R.string.settings_lock_enabled)
|
||||
else
|
||||
""
|
||||
|
||||
if (lockManager.isEmpty()) {
|
||||
(findPreference<Preference>("lock_fingerprint") as SwitchPreferenceCompat).isChecked = false
|
||||
|
||||
Preferences["lock_fingerprint"] = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.lock_preferences, rootKey)
|
||||
|
||||
with(findPreference<Preference>("lock_pattern")) {
|
||||
this!!
|
||||
|
||||
if (LockManager(requireContext()).contains(Lock.Type.PATTERN))
|
||||
summary = getString(R.string.settings_lock_enabled)
|
||||
|
||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
val lockManager = LockManager(requireContext())
|
||||
|
||||
if (lockManager.contains(Lock.Type.PATTERN)) {
|
||||
AlertDialog.Builder(requireContext()).apply {
|
||||
setTitle(R.string.warning)
|
||||
setMessage(R.string.settings_lock_remove_message)
|
||||
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
lockManager.remove(Lock.Type.PATTERN)
|
||||
onResume()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
}.show()
|
||||
} else {
|
||||
val intent = Intent(requireContext(), LockActivity::class.java).apply {
|
||||
putExtra("mode", "add_lock")
|
||||
putExtra("type", "pattern")
|
||||
}
|
||||
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
with(findPreference<Preference>("lock_pin")) {
|
||||
this!!
|
||||
|
||||
if (LockManager(requireContext()).contains(Lock.Type.PIN))
|
||||
summary = getString(R.string.settings_lock_enabled)
|
||||
|
||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
val lockManager = LockManager(requireContext())
|
||||
|
||||
if (lockManager.contains(Lock.Type.PIN)) {
|
||||
AlertDialog.Builder(requireContext()).apply {
|
||||
setTitle(R.string.warning)
|
||||
setMessage(R.string.settings_lock_remove_message)
|
||||
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
lockManager.remove(Lock.Type.PIN)
|
||||
onResume()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
}.show()
|
||||
} else {
|
||||
val intent = Intent(requireContext(), LockActivity::class.java).apply {
|
||||
putExtra("mode", "add_lock")
|
||||
putExtra("type", "pin")
|
||||
}
|
||||
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
with(findPreference<Preference>("lock_fingerprint")) {
|
||||
this!!
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
this as SwitchPreferenceCompat
|
||||
|
||||
if (newValue == true && LockManager(requireContext()).isEmpty()) {
|
||||
isChecked = false
|
||||
|
||||
Toast.makeText(requireContext(), R.string.settings_lock_fingerprint_without_lock, Toast.LENGTH_SHORT).show()
|
||||
} else
|
||||
isChecked = newValue as Boolean
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui.fragment
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import 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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui.fragment
|
||||
|
||||
import android.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 { (it as? FileX)?.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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.andrognito.pinlockview.PinLockListener
|
||||
import kotlinx.android.synthetic.main.fragment_pin_lock.view.*
|
||||
import xyz.quaver.pupil.R
|
||||
|
||||
class PINLockFragment : Fragment(), PinLockListener {
|
||||
|
||||
var onPINEntered: ((String) -> Unit)? = null
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_pin_lock, container, false).apply {
|
||||
pin_lock_view.attachIndicatorDots(indicator_dots)
|
||||
pin_lock_view.setPinLockListener(this@PINLockFragment)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComplete(pin: String?) {
|
||||
onPINEntered?.invoke(pin!!)
|
||||
}
|
||||
|
||||
override fun onEmpty() {
|
||||
|
||||
}
|
||||
|
||||
override fun onPinChange(pinLength: Int, intermediatePin: String?) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,63 +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.ui.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.andrognito.patternlockview.PatternLockView
|
||||
import com.andrognito.patternlockview.listener.PatternLockViewListener
|
||||
import com.andrognito.patternlockview.utils.PatternLockUtils
|
||||
import kotlinx.android.synthetic.main.fragment_pattern_lock.*
|
||||
import kotlinx.android.synthetic.main.fragment_pattern_lock.view.*
|
||||
import xyz.quaver.pupil.R
|
||||
|
||||
class PatternLockFragment : Fragment(), PatternLockViewListener {
|
||||
|
||||
var onPatternDrawn: ((String) -> Unit)? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_pattern_lock, container, false).apply {
|
||||
lock_pattern_view.addPatternLockListener(this@PatternLockFragment)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui.fragment
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.os.Bundle
|
||||
import android.os.LocaleList
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.preference.*
|
||||
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.io.util.getChild
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.ui.LockActivity
|
||||
import xyz.quaver.pupil.ui.SettingsActivity
|
||||
import xyz.quaver.pupil.ui.dialog.*
|
||||
import xyz.quaver.pupil.util.*
|
||||
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||
import java.util.*
|
||||
|
||||
class SettingsFragment :
|
||||
PreferenceFragmentCompat(),
|
||||
Preference.OnPreferenceClickListener,
|
||||
Preference.OnPreferenceChangeListener,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
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() {
|
||||
super.onResume()
|
||||
|
||||
val lockManager = LockManager(requireContext())
|
||||
|
||||
findPreference<Preference>("app_lock")?.summary = if (lockManager.locks.isNullOrEmpty()) {
|
||||
getString(R.string.settings_lock_none)
|
||||
} else {
|
||||
lockManager.locks?.joinToString(", ") {
|
||||
when(it.type) {
|
||||
Lock.Type.PATTERN -> getString(R.string.settings_lock_pattern)
|
||||
Lock.Type.PIN -> getString(R.string.settings_lock_pin)
|
||||
Lock.Type.PASSWORD -> getString(R.string.settings_lock_password)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceClick(preference: Preference?): Boolean {
|
||||
with (preference) {
|
||||
this ?: return false
|
||||
|
||||
when (key) {
|
||||
"app_version" -> {
|
||||
checkUpdate(activity as SettingsActivity, true)
|
||||
}
|
||||
"download_folder" -> {
|
||||
DownloadLocationDialogFragment().show(parentFragmentManager, "Download Location Dialog")
|
||||
}
|
||||
"default_query" -> {
|
||||
DefaultQueryDialog(requireContext()).apply {
|
||||
onPositiveButtonClickListener = { newTags ->
|
||||
Preferences["default_query"] = newTags.toString()
|
||||
summary = newTags.toString()
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
"app_lock" -> {
|
||||
val intent = Intent(requireContext(), LockActivity::class.java).apply {
|
||||
putExtra("force", true)
|
||||
}
|
||||
lockLauncher.launch(intent)
|
||||
}
|
||||
"mirrors" -> {
|
||||
MirrorDialog(requireContext())
|
||||
.show()
|
||||
}
|
||||
"proxy" -> {
|
||||
ProxyDialog(requireContext())
|
||||
.show()
|
||||
}
|
||||
"user_id" -> {
|
||||
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
|
||||
ClipData.newPlainText("user_id", Preferences.get<String>("user_id"))
|
||||
)
|
||||
Toast.makeText(context, R.string.settings_user_id_toast, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean {
|
||||
with (preference) {
|
||||
this ?: return false
|
||||
|
||||
when (key) {
|
||||
"tag_language" -> {
|
||||
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" -> {
|
||||
AppCompatDelegate.setDefaultNightMode(when (newValue as Boolean) {
|
||||
true -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
false -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
})
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
key ?: return
|
||||
|
||||
with(findPreference<Preference>(key)) {
|
||||
this ?: return
|
||||
|
||||
when (key) {
|
||||
"proxy" -> {
|
||||
summary = context?.let { getProxyInfo().type.name }
|
||||
}
|
||||
"download_folder" -> {
|
||||
summary = FileX(context, Preferences.get<String>("download_folder")).canonicalPath
|
||||
}
|
||||
"download_folder_name" -> {
|
||||
summary = Preferences["download_folder_name", "[-id-] -title-"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.root_preferences, rootKey)
|
||||
|
||||
Preferences.registerOnSharedPreferenceChangeListener(this)
|
||||
|
||||
initPreferences()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Preferences.unregisterOnSharedPreferenceChangeListener(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun initPreferences() {
|
||||
for (i in 0 until preferenceScreen.preferenceCount) {
|
||||
|
||||
preferenceScreen.getPreference(i).run {
|
||||
if (this is PreferenceCategory)
|
||||
(0 until preferenceCount).map { getPreference(it) }
|
||||
else
|
||||
listOf(this)
|
||||
}.forEach { preference ->
|
||||
with (preference) with@{
|
||||
|
||||
when (key) {
|
||||
"app_version" -> {
|
||||
val manager = requireContext().packageManager
|
||||
val info = manager.getPackageInfo(requireContext().packageName, 0)
|
||||
summary = requireContext().getString(R.string.settings_app_version_description, info.versionName)
|
||||
|
||||
onPreferenceClickListener = this@SettingsFragment
|
||||
}
|
||||
"download_folder_name" -> {
|
||||
summary = Preferences["download_folder_name", "[-id-] -title-"]
|
||||
|
||||
setOnPreferenceClickListener {
|
||||
DownloadFolderNameDialogFragment().show(requireActivity().supportFragmentManager, "Download Location Dialog")
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
"download_folder" -> {
|
||||
summary = FileX(context, Preferences.get<String>("download_folder")).canonicalPath
|
||||
|
||||
onPreferenceClickListener = this@SettingsFragment
|
||||
}
|
||||
"nomedia" -> {
|
||||
(this as SwitchPreferenceCompat).isChecked = kotlin.runCatching {
|
||||
DownloadManager.getInstance(context).downloadFolder.getChild(".nomedia").exists()
|
||||
}.getOrDefault(false)
|
||||
|
||||
onPreferenceChangeListener = this@SettingsFragment
|
||||
}
|
||||
"default_query" -> {
|
||||
summary = Preferences.get<String>("default_query")
|
||||
|
||||
onPreferenceClickListener = this@SettingsFragment
|
||||
}
|
||||
"app_lock" -> {
|
||||
val lockManager = LockManager(requireContext())
|
||||
summary =
|
||||
if (lockManager.locks.isNullOrEmpty()) {
|
||||
getString(R.string.settings_lock_none)
|
||||
} else {
|
||||
lockManager.locks?.joinToString(", ") {
|
||||
when (it.type) {
|
||||
Lock.Type.PATTERN -> getString(R.string.settings_lock_pattern)
|
||||
Lock.Type.PIN -> getString(R.string.settings_lock_pin)
|
||||
Lock.Type.PASSWORD -> getString(R.string.settings_lock_password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPreferenceClickListener = this@SettingsFragment
|
||||
}
|
||||
"tag_language" -> {
|
||||
this as ListPreference
|
||||
|
||||
isEnabled = false
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
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
|
||||
|
||||
}
|
||||
"mirrors" -> {
|
||||
onPreferenceClickListener = this@SettingsFragment
|
||||
}
|
||||
"proxy" -> {
|
||||
summary = getProxyInfo().type.name
|
||||
|
||||
onPreferenceClickListener = this@SettingsFragment
|
||||
}
|
||||
"dark_mode" -> {
|
||||
onPreferenceChangeListener = this@SettingsFragment
|
||||
}
|
||||
"old_import_galleries" -> {
|
||||
onPreferenceClickListener = this@SettingsFragment
|
||||
}
|
||||
"user_id" -> {
|
||||
summary = Preferences.get<String>("user_id")
|
||||
onPreferenceClickListener = this@SettingsFragment
|
||||
}
|
||||
"oss" -> {
|
||||
setOnPreferenceClickListener {
|
||||
context?.startActivity(Intent(context, OssLicensesMenuActivity::class.java))
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
148
app/src/main/java/xyz/quaver/pupil/ui/theme/Color.kt
Normal file
@@ -0,0 +1,148 @@
|
||||
package xyz.quaver.pupil.ui.theme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val md_theme_light_primary = Color(0xFF006688)
|
||||
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_primaryContainer = Color(0xFFC2E8FF)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF001E2B)
|
||||
val md_theme_light_secondary = Color(0xFF4E616D)
|
||||
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_secondaryContainer = Color(0xFFD1E5F3)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF091E28)
|
||||
val md_theme_light_tertiary = Color(0xFF5F5A7D)
|
||||
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_tertiaryContainer = Color(0xFFE5DEFF)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF1C1736)
|
||||
val md_theme_light_error = Color(0xFFBA1A1A)
|
||||
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410002)
|
||||
val md_theme_light_background = Color(0xFFFBFCFE)
|
||||
val md_theme_light_onBackground = Color(0xFF191C1E)
|
||||
val md_theme_light_surface = Color(0xFFFBFCFE)
|
||||
val md_theme_light_onSurface = Color(0xFF191C1E)
|
||||
val md_theme_light_surfaceVariant = Color(0xFFDCE3E9)
|
||||
val md_theme_light_onSurfaceVariant = Color(0xFF40484D)
|
||||
val md_theme_light_outline = Color(0xFF71787D)
|
||||
val md_theme_light_inverseOnSurface = Color(0xFFF0F1F3)
|
||||
val md_theme_light_inverseSurface = Color(0xFF2E3133)
|
||||
val md_theme_light_inversePrimary = Color(0xFF75D1FF)
|
||||
val md_theme_light_shadow = Color(0xFF000000)
|
||||
val md_theme_light_surfaceTint = Color(0xFF006688)
|
||||
val md_theme_light_outlineVariant = Color(0xFFC0C7CD)
|
||||
val md_theme_light_scrim = Color(0xFF000000)
|
||||
|
||||
val md_theme_dark_primary = Color(0xFF75D1FF)
|
||||
val md_theme_dark_onPrimary = Color(0xFF003548)
|
||||
val md_theme_dark_primaryContainer = Color(0xFF004D67)
|
||||
val md_theme_dark_onPrimaryContainer = Color(0xFFC2E8FF)
|
||||
val md_theme_dark_secondary = Color(0xFFB5C9D7)
|
||||
val md_theme_dark_onSecondary = Color(0xFF20333D)
|
||||
val md_theme_dark_secondaryContainer = Color(0xFF364954)
|
||||
val md_theme_dark_onSecondaryContainer = Color(0xFFD1E5F3)
|
||||
val md_theme_dark_tertiary = Color(0xFFC9C2EA)
|
||||
val md_theme_dark_onTertiary = Color(0xFF312C4C)
|
||||
val md_theme_dark_tertiaryContainer = Color(0xFF474364)
|
||||
val md_theme_dark_onTertiaryContainer = Color(0xFFE5DEFF)
|
||||
val md_theme_dark_error = Color(0xFFFFB4AB)
|
||||
val md_theme_dark_errorContainer = Color(0xFF93000A)
|
||||
val md_theme_dark_onError = Color(0xFF690005)
|
||||
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_background = Color(0xFF191C1E)
|
||||
val md_theme_dark_onBackground = Color(0xFFE1E2E5)
|
||||
val md_theme_dark_surface = Color(0xFF191C1E)
|
||||
val md_theme_dark_onSurface = Color(0xFFE1E2E5)
|
||||
val md_theme_dark_surfaceVariant = Color(0xFF40484D)
|
||||
val md_theme_dark_onSurfaceVariant = Color(0xFFC0C7CD)
|
||||
val md_theme_dark_outline = Color(0xFF8A9297)
|
||||
val md_theme_dark_inverseOnSurface = Color(0xFF191C1E)
|
||||
val md_theme_dark_inverseSurface = Color(0xFFE1E2E5)
|
||||
val md_theme_dark_inversePrimary = Color(0xFF006688)
|
||||
val md_theme_dark_shadow = Color(0xFF000000)
|
||||
val md_theme_dark_surfaceTint = Color(0xFF75D1FF)
|
||||
val md_theme_dark_outlineVariant = Color(0xFF40484D)
|
||||
val md_theme_dark_scrim = Color(0xFF000000)
|
||||
|
||||
|
||||
val seed = Color(0xFF4FC3F7)
|
||||
|
||||
val Gray50 = Color(0xFFF9FAFB)
|
||||
val Gray100 = Color(0xFFF3F4F6)
|
||||
val Gray200 = Color(0xFFE5E7EB)
|
||||
val Gray300 = Color(0xFFD1D5DB)
|
||||
val Gray400 = Color(0xFF9CA3AF)
|
||||
val Gray500 = Color(0xFF6B7280)
|
||||
val Gray600 = Color(0xFF4B5563)
|
||||
val Gray700 = Color(0xFF374151)
|
||||
val Gray800 = Color(0xFF1F2937)
|
||||
val Gray900 = Color(0xFF111827)
|
||||
val Red50 = Color(0xFFFEF2F2)
|
||||
val Red100 = Color(0xFFFEE2E2)
|
||||
val Red200 = Color(0xFFFECACA)
|
||||
val Red300 = Color(0xFFFCA5A5)
|
||||
val Red400 = Color(0xFFF87171)
|
||||
val Red500 = Color(0xFFEF4444)
|
||||
val Red600 = Color(0xFFDC2626)
|
||||
val Red700 = Color(0xFFB91C1C)
|
||||
val Red800 = Color(0xFF991B1B)
|
||||
val Red900 = Color(0xFF7F1D1D)
|
||||
val Yellow50 = Color(0xFFFFFBEB)
|
||||
val Yellow100 = Color(0xFFFEF3C7)
|
||||
val Yellow200 = Color(0xFFFDE68A)
|
||||
val Yellow300 = Color(0xFFFCD34D)
|
||||
val Yellow400 = Color(0xFFFBBF24)
|
||||
val Yellow500 = Color(0xFFF59E0B)
|
||||
val Yellow600 = Color(0xFFD97706)
|
||||
val Yellow700 = Color(0xFFB45309)
|
||||
val Yellow800 = Color(0xFF92400E)
|
||||
val Yellow900 = Color(0xFF78350F)
|
||||
val Green50 = Color(0xFFECFDF5)
|
||||
val Green100 = Color(0xFFD1FAE5)
|
||||
val Green200 = Color(0xFFA7F3D0)
|
||||
val Green300 = Color(0xFF6EE7B7)
|
||||
val Green400 = Color(0xFF34D399)
|
||||
val Green500 = Color(0xFF10B981)
|
||||
val Green600 = Color(0xFF059669)
|
||||
val Green700 = Color(0xFF047857)
|
||||
val Green800 = Color(0xFF065F46)
|
||||
val Green900 = Color(0xFF064E3B)
|
||||
val Blue50 = Color(0xFFEFF6FF)
|
||||
val Blue100 = Color(0xFFDBEAFE)
|
||||
val Blue200 = Color(0xFFBFDBFE)
|
||||
val Blue300 = Color(0xFF93C5FD)
|
||||
val Blue400 = Color(0xFF60A5FA)
|
||||
val Blue500 = Color(0xFF3B82F6)
|
||||
val Blue600 = Color(0xFF2563EB)
|
||||
val Blue700 = Color(0xFF1D4ED8)
|
||||
val Blue800 = Color(0xFF1E40AF)
|
||||
val Blue900 = Color(0xFF1E3A8A)
|
||||
val Indigo50 = Color(0xFFEEF2FF)
|
||||
val Indigo100 = Color(0xFFE0E7FF)
|
||||
val Indigo200 = Color(0xFFC7D2FE)
|
||||
val Indigo300 = Color(0xFFA5B4FC)
|
||||
val Indigo400 = Color(0xFF818CF8)
|
||||
val Indigo500 = Color(0xFF6366F1)
|
||||
val Indigo600 = Color(0xFF4F46E5)
|
||||
val Indigo700 = Color(0xFF4338CA)
|
||||
val Indigo800 = Color(0xFF3730A3)
|
||||
val Indigo900 = Color(0xFF312E81)
|
||||
val Purple50 = Color(0xFFF5F3FF)
|
||||
val Purple100 = Color(0xFFEDE9FE)
|
||||
val Purple200 = Color(0xFFDDD6FE)
|
||||
val Purple300 = Color(0xFFC4B5FD)
|
||||
val Purple400 = Color(0xFFA78BFA)
|
||||
val Purple500 = Color(0xFF8B5CF6)
|
||||
val Purple600 = Color(0xFF7C3AED)
|
||||
val Purple700 = Color(0xFF6D28D9)
|
||||
val Purple800 = Color(0xFF5B21B6)
|
||||
val Purple900 = Color(0xFF4C1D95)
|
||||
val Pink50 = Color(0xFFFDF2F8)
|
||||
val Pink100 = Color(0xFFFCE7F3)
|
||||
val Pink200 = Color(0xFFFBCFE8)
|
||||
val Pink300 = Color(0xFFF9A8D4)
|
||||
val Pink400 = Color(0xFFF472B6)
|
||||
val Pink500 = Color(0xFFEC4899)
|
||||
val Pink600 = Color(0xFFDB2777)
|
||||
val Pink700 = Color(0xFFBE185D)
|
||||
val Pink800 = Color(0xFF9D174D)
|
||||
val Pink900 = Color(0xFF831843)
|
||||
90
app/src/main/java/xyz/quaver/pupil/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,90 @@
|
||||
package xyz.quaver.pupil.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onError = md_theme_light_onError,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||
inverseSurface = md_theme_light_inverseSurface,
|
||||
inversePrimary = md_theme_light_inversePrimary,
|
||||
surfaceTint = md_theme_light_surfaceTint,
|
||||
outlineVariant = md_theme_light_outlineVariant,
|
||||
scrim = md_theme_light_scrim,
|
||||
)
|
||||
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
primaryContainer = md_theme_dark_primaryContainer,
|
||||
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||
secondary = md_theme_dark_secondary,
|
||||
onSecondary = md_theme_dark_onSecondary,
|
||||
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||
tertiary = md_theme_dark_tertiary,
|
||||
onTertiary = md_theme_dark_onTertiary,
|
||||
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||
error = md_theme_dark_error,
|
||||
errorContainer = md_theme_dark_errorContainer,
|
||||
onError = md_theme_dark_onError,
|
||||
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
surface = md_theme_dark_surface,
|
||||
onSurface = md_theme_dark_onSurface,
|
||||
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||
outline = md_theme_dark_outline,
|
||||
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||
inverseSurface = md_theme_dark_inverseSurface,
|
||||
inversePrimary = md_theme_dark_inversePrimary,
|
||||
surfaceTint = md_theme_dark_surfaceTint,
|
||||
outlineVariant = md_theme_dark_outlineVariant,
|
||||
scrim = md_theme_dark_scrim,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
useDarkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable() () -> Unit
|
||||
) {
|
||||
val colors = if (!useDarkTheme) {
|
||||
LightColors
|
||||
} else {
|
||||
DarkColors
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -1,218 +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.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Parcelable
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.util.AttributeSet
|
||||
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.MenuPopupHelper
|
||||
import xyz.quaver.floatingsearchview.util.view.MenuView
|
||||
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 = { a, b, c, d, e ->
|
||||
onBindSuggestion(a, b, c, d, e)
|
||||
}
|
||||
}
|
||||
|
||||
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.replace(Regex("\\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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +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.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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,101 +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.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.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
this.removeAllViews()
|
||||
|
||||
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 && parent == null)
|
||||
addView(moreView)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
applyAttributes(context.obtainStyledAttributes(attr, R.styleable.TagChipGroup))
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package xyz.quaver.pupil.ui.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.quaver.pupil.networking.GalleryInfo
|
||||
import xyz.quaver.pupil.networking.GallerySearchSource
|
||||
import xyz.quaver.pupil.networking.SearchQuery
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SearchState())
|
||||
val searchState: StateFlow<SearchState> = _uiState
|
||||
private var searchSource: GallerySearchSource = GallerySearchSource(null)
|
||||
private var job: Job? = null
|
||||
|
||||
fun openGalleryDetails(galleryInfo: GalleryInfo) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
openedGallery = galleryInfo,
|
||||
isDetailOnlyOpen = true
|
||||
)
|
||||
}
|
||||
|
||||
fun closeGalleryDetails() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isDetailOnlyOpen = false
|
||||
)
|
||||
}
|
||||
|
||||
fun onQueryChange(query: SearchQuery?) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
query = query,
|
||||
galleryCount = null,
|
||||
currentRange = IntRange.EMPTY
|
||||
)
|
||||
|
||||
searchSource = GallerySearchSource(query)
|
||||
}
|
||||
|
||||
fun loadSearchResult(range: IntRange) {
|
||||
Thread.dumpStack()
|
||||
job?.cancel()
|
||||
job = viewModelScope.launch {
|
||||
val sanitizedRange = max(range.first, 0) .. min(range.last, searchState.value.galleryCount ?: Int.MAX_VALUE)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
loading = true,
|
||||
error = false,
|
||||
currentRange = sanitizedRange
|
||||
)
|
||||
|
||||
var error = false
|
||||
val (galleries, galleryCount) = searchSource.load(range).getOrElse {
|
||||
error = true
|
||||
it.printStackTrace()
|
||||
emptyList<GalleryInfo>() to 0
|
||||
}
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
galleries = galleries,
|
||||
galleryCount = galleryCount,
|
||||
error = error,
|
||||
loading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToDetail() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
data class SearchState(
|
||||
val query: SearchQuery? = null,
|
||||
val galleries: List<GalleryInfo> = emptyList(),
|
||||
val loading: Boolean = false,
|
||||
val error: Boolean = false,
|
||||
val galleryCount: Int? = null,
|
||||
val currentRange: IntRange = IntRange.EMPTY,
|
||||
val openedGallery: GalleryInfo? = null,
|
||||
val isDetailOnlyOpen: Boolean = false
|
||||
)
|
||||
@@ -1,69 +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 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()
|
||||
}
|
||||
}
|
||||
@@ -1,48 +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 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()
|
||||
}
|
||||
}
|
||||
@@ -1,93 +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.*
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
class SavedSet <T: Any> (private val file: File, private val any: T, private val set: MutableSet<T> = Collections.synchronizedSet(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()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
synchronized(this) {
|
||||
set.clear()
|
||||
kotlin.runCatching {
|
||||
Json.decodeFromString(serializer, file.readText())
|
||||
}.onSuccess {
|
||||
set.addAll(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun save() {
|
||||
synchronized(this) {
|
||||
file.writeText(Json.encodeToString(serializer, set.toList()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun add(element: T): Boolean {
|
||||
load()
|
||||
|
||||
set.remove(element)
|
||||
|
||||
return set.add(element).also {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
override fun addAll(elements: Collection<T>): Boolean {
|
||||
load()
|
||||
|
||||
set.removeAll(elements)
|
||||
|
||||
return set.addAll(elements).also {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
override fun remove(element: T): Boolean {
|
||||
load()
|
||||
|
||||
return set.remove(element).also {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
set.clear()
|
||||
save()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,119 +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/>.
|
||||
*/
|
||||
|
||||
@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,269 +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.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.Code
|
||||
import xyz.quaver.hitomi.GalleryBlock
|
||||
import xyz.quaver.hitomi.Reader
|
||||
import xyz.quaver.io.FileX
|
||||
import xyz.quaver.io.util.*
|
||||
import xyz.quaver.pupil.client
|
||||
import xyz.quaver.pupil.util.Preferences
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Serializable
|
||||
data class Metadata(
|
||||
var galleryBlock: GalleryBlock? = null,
|
||||
var reader: Reader? = null,
|
||||
var imageList: MutableList<String?>? = null
|
||||
) {
|
||||
fun copy(): Metadata = Metadata(galleryBlock, reader, 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 {
|
||||
Json.decodeFromString<Metadata>(it)
|
||||
}
|
||||
}.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? {
|
||||
val sources = listOf(
|
||||
{ xyz.quaver.hitomi.getGalleryBlock(galleryID) },
|
||||
{ xyz.quaver.hiyobi.getGalleryBlock(galleryID) }
|
||||
)
|
||||
|
||||
return metadata.galleryBlock
|
||||
?: withContext(Dispatchers.IO) {
|
||||
var galleryBlock: GalleryBlock? = null
|
||||
|
||||
for (source in sources) {
|
||||
galleryBlock = try {
|
||||
source.invoke()
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
if (galleryBlock != null)
|
||||
break
|
||||
}
|
||||
|
||||
galleryBlock?.also {
|
||||
setMetadata { metadata -> metadata.galleryBlock = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 getReader(): Reader? {
|
||||
val mirrors = Preferences.get<String>("mirrors").let { if (it.isEmpty()) emptyList() else it.split('>') }
|
||||
|
||||
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{ o1, o2 -> mirrors.indexOf(o1.name) - mirrors.indexOf(o2.name) }
|
||||
else
|
||||
it
|
||||
}
|
||||
|
||||
return metadata.reader
|
||||
?: withContext(Dispatchers.IO) {
|
||||
var reader: Reader? = null
|
||||
|
||||
for (source in sources) {
|
||||
reader = try {
|
||||
source.value.invoke()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
if (reader != null)
|
||||
break
|
||||
}
|
||||
|
||||
reader?.also {
|
||||
setMetadata { metadata ->
|
||||
metadata.reader = it
|
||||
|
||||
if (metadata.imageList == null)
|
||||
metadata.imageList = MutableList(reader.galleryInfo.files.size) { 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.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!!
|
||||
}
|
||||
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +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 android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import xyz.quaver.pupil.histories
|
||||
import xyz.quaver.pupil.util.downloader.Cache
|
||||
import xyz.quaver.pupil.util.downloader.DownloadManager
|
||||
import java.io.File
|
||||
|
||||
val mutex = Mutex()
|
||||
fun cleanCache(context: Context) = CoroutineScope(Dispatchers.IO).launch {
|
||||
if (mutex.isLocked) return@launch
|
||||
|
||||
mutex.withLock {
|
||||
val cacheFolder = File(context.cacheDir, "imageCache")
|
||||
val downloadManager = DownloadManager.getInstance(context)
|
||||
|
||||
val limit = (Preferences.get<String>("cache_limit").toLongOrNull() ?: 0L)*1024*1024*1024
|
||||
|
||||
if (limit == 0L) return@withLock
|
||||
|
||||
val cacheSize = {
|
||||
var size = 0L
|
||||
|
||||
cacheFolder.walk().forEach {
|
||||
size += it.length()
|
||||
}
|
||||
|
||||
size
|
||||
}
|
||||
|
||||
if (cacheSize.invoke() > limit)
|
||||
while (cacheSize.invoke() > limit/2) {
|
||||
val caches = cacheFolder.list() ?: return@withLock
|
||||
|
||||
(histories.toList().firstOrNull {
|
||||
caches.contains(it.toString()) && !downloadManager.isDownloading(it)
|
||||
} ?: return@withLock).let {
|
||||
Cache.delete(context, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +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 android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
fun hash(password: String): String {
|
||||
val bytes = password.toByteArray()
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
return md.digest(bytes).fold("") { str, it -> str + "%02x".format(it) }
|
||||
}
|
||||
|
||||
// Ret1: SHA-256 Hash
|
||||
// Ret2: Hash salt
|
||||
fun hashWithSalt(password: String): Pair<String, String> {
|
||||
val salt = (0 until 12).map { source.random() }.joinToString()
|
||||
|
||||
return Pair(hash(password+salt), salt)
|
||||
}
|
||||
|
||||
const val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
@Serializable
|
||||
data class Lock(val type: Type, val hash: String, val salt: String) {
|
||||
|
||||
enum class Type {
|
||||
PATTERN,
|
||||
PIN,
|
||||
PASSWORD
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun generate(type: Type, password: String): Lock {
|
||||
val (hash, salt) = hashWithSalt(password)
|
||||
return Lock(type, hash, salt)
|
||||
}
|
||||
}
|
||||
|
||||
fun match(password: String): Boolean {
|
||||
return hash(password+salt) == hash
|
||||
}
|
||||
}
|
||||
|
||||
class LockManager(base: Context): ContextWrapper(base) {
|
||||
|
||||
var locks: ArrayList<Lock>? = null
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
val lock = File(ContextCompat.getDataDir(this), "lock.json")
|
||||
|
||||
if (!lock.exists()) {
|
||||
lock.createNewFile()
|
||||
lock.writeText("[]")
|
||||
}
|
||||
|
||||
locks = Json.decodeFromString(lock.readText())
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
val lock = File(ContextCompat.getDataDir(this), "lock.json")
|
||||
|
||||
if (!lock.exists())
|
||||
lock.createNewFile()
|
||||
|
||||
lock.writeText(Json.encodeToString(locks?.toList() ?: listOf()))
|
||||
}
|
||||
|
||||
fun add(lock: Lock) {
|
||||
remove(lock.type)
|
||||
locks?.add(lock)
|
||||
save()
|
||||
}
|
||||
|
||||
fun remove(type: Lock.Type) {
|
||||
locks?.removeAll { it.type == type }
|
||||
save()
|
||||
}
|
||||
|
||||
fun check(password: String): Boolean? {
|
||||
return locks?.any {
|
||||
it.match(password)
|
||||
}
|
||||
}
|
||||
|
||||
fun isEmpty(): Boolean {
|
||||
return locks.isNullOrEmpty()
|
||||
}
|
||||
|
||||
fun isNotEmpty(): Boolean = !isEmpty()
|
||||
|
||||
fun contains(type: Lock.Type): Boolean {
|
||||
return locks?.any { it.type == type } ?: false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,140 +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 android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import xyz.quaver.Code
|
||||
import xyz.quaver.hitomi.GalleryBlock
|
||||
import xyz.quaver.hitomi.Reader
|
||||
import xyz.quaver.hitomi.getReferer
|
||||
import xyz.quaver.hitomi.imageUrlFromImage
|
||||
import xyz.quaver.hiyobi.createImgList
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun String.wordCapitalize() : String {
|
||||
val result = ArrayList<String>()
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
for (word in this.split(" "))
|
||||
result.add(word.capitalize(Locale.US))
|
||||
|
||||
return result.joinToString(" ")
|
||||
}
|
||||
|
||||
private val suffix = listOf(
|
||||
"B",
|
||||
"kB",
|
||||
"MB",
|
||||
"GB",
|
||||
"TB" //really?
|
||||
)
|
||||
|
||||
fun byteToString(byte: Long, precision : Int = 1) : String {
|
||||
var size = byte.toDouble(); var suffixIndex = 0
|
||||
|
||||
while (size >= 1024) {
|
||||
size /= 1024
|
||||
suffixIndex++
|
||||
}
|
||||
|
||||
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 Reader.requestBuilders: List<Request.Builder>
|
||||
get() {
|
||||
val galleryID = this.galleryInfo.id ?: 0
|
||||
val lowQuality = Preferences["low_quality", true]
|
||||
|
||||
return when(code) {
|
||||
Code.HITOMI -> {
|
||||
this.galleryInfo.files.map {
|
||||
Request.Builder()
|
||||
.url(imageUrlFromImage(galleryID, it, !lowQuality))
|
||||
.header("Referer", getReferer(galleryID))
|
||||
}
|
||||
}
|
||||
Code.HIYOBI -> {
|
||||
createImgList(galleryID, this, lowQuality).map {
|
||||
Request.Builder()
|
||||
.url(it.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -1,58 +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 android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Credentials
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
|
||||
@Serializable
|
||||
data class ProxyInfo(
|
||||
val type: Proxy.Type,
|
||||
val host: String? = null,
|
||||
val port: Int? = null,
|
||||
val username: String? = null,
|
||||
val password: String? = null
|
||||
) {
|
||||
fun proxy() : Proxy {
|
||||
return if (host.isNullOrBlank() || port == null)
|
||||
return Proxy.NO_PROXY
|
||||
else
|
||||
Proxy(type, InetSocketAddress.createUnresolved(host, port))
|
||||
}
|
||||
|
||||
fun authenticator(): Authenticator? = if (username.isNullOrBlank() || password.isNullOrBlank()) null else
|
||||
Authenticator { _, response ->
|
||||
val credential = Credentials.basic(username, password)
|
||||
|
||||
response.request().newBuilder()
|
||||
.header("Proxy-Authorization", credential)
|
||||
.build()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun getProxyInfo(): ProxyInfo =
|
||||
Json.decodeFromString(Preferences["proxy", Json.encodeToString(ProxyInfo(Proxy.Type.DIRECT))])
|
||||
@@ -1,66 +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.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()
|
||||
translations = Json.decodeFromString<Map<String, String>>(client.newCall(
|
||||
Request.Builder()
|
||||
.url(contentURL + "${Preferences["tag_language", ""]}.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())
|
||||
}
|
||||
@@ -1,335 +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 android.annotation.SuppressLint
|
||||
import android.app.DownloadManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import android.webkit.URLUtil
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.*
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
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.io.FileX
|
||||
import xyz.quaver.io.util.getChild
|
||||
import xyz.quaver.io.util.readText
|
||||
import xyz.quaver.io.util.writeBytes
|
||||
import xyz.quaver.io.util.writeText
|
||||
import xyz.quaver.pupil.BuildConfig
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.client
|
||||
import xyz.quaver.pupil.favorites
|
||||
import xyz.quaver.pupil.services.DownloadService
|
||||
import xyz.quaver.pupil.util.downloader.Cache
|
||||
import xyz.quaver.pupil.util.downloader.Metadata
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
|
||||
fun getReleases(url: String) : JsonArray {
|
||||
return try {
|
||||
URL(url).readText().let {
|
||||
Json.parseToJsonElement(it).jsonArray
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
JsonArray(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
fun checkUpdate(url: String) : JsonObject? {
|
||||
val releases = getReleases(url)
|
||||
|
||||
if (releases.isEmpty())
|
||||
return null
|
||||
|
||||
return releases.firstOrNull {
|
||||
Preferences["beta"] || it.jsonObject["prerelease"]?.jsonPrimitive?.booleanOrNull == false
|
||||
}?.let {
|
||||
if (it.jsonObject["tag_name"]?.jsonPrimitive?.contentOrNull == BuildConfig.VERSION_NAME)
|
||||
null
|
||||
else
|
||||
it.jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
fun getApkUrl(releases: JsonObject) : String? {
|
||||
return releases["assets"]?.jsonArray?.firstOrNull {
|
||||
Regex("Pupil-v.+\\.apk").matches(it.jsonObject["name"]?.jsonPrimitive?.contentOrNull ?: "")
|
||||
}.let {
|
||||
it?.jsonObject?.get("browser_download_url")?.jsonPrimitive?.contentOrNull
|
||||
}
|
||||
}
|
||||
|
||||
fun checkUpdate(context: Context, force: Boolean = false) {
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val ignoreUpdateUntil = preferences.getLong("ignore_update_until", 0)
|
||||
|
||||
if (!force && ignoreUpdateUntil > System.currentTimeMillis())
|
||||
return
|
||||
|
||||
fun extractReleaseNote(update: JsonObject, locale: Locale) : String {
|
||||
val markdown = update["body"]!!.jsonPrimitive.content
|
||||
|
||||
val target = when(locale.language) {
|
||||
"ko" -> "한국어"
|
||||
"ja" -> "日本語"
|
||||
else -> "English"
|
||||
}
|
||||
|
||||
val releaseNote = Regex("^# Release Note.+$")
|
||||
val language = Regex("^## $target$")
|
||||
val end = Regex("^#.+$")
|
||||
|
||||
var releaseNoteFlag = false
|
||||
var languageFlag = false
|
||||
|
||||
val result = StringBuilder()
|
||||
|
||||
for(line in markdown.lines()) {
|
||||
if (releaseNote.matches(line)) {
|
||||
releaseNoteFlag = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (releaseNoteFlag) {
|
||||
if (language.matches(line)) {
|
||||
languageFlag = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (languageFlag) {
|
||||
if (end.matches(line))
|
||||
break
|
||||
|
||||
result.append(line+"\n")
|
||||
}
|
||||
}
|
||||
|
||||
return context.getString(R.string.update_release_note, update["tag_name"]?.jsonPrimitive?.contentOrNull, result.toString())
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val update =
|
||||
checkUpdate(context.getString(R.string.release_url)) ?: return@launch
|
||||
|
||||
val url = getApkUrl(update) ?: return@launch
|
||||
|
||||
val dialog = AlertDialog.Builder(context).apply {
|
||||
setTitle(R.string.update_title)
|
||||
val msg = extractReleaseNote(update, Locale.getDefault())
|
||||
setMessage(Markwon.create(context).toMarkdown(msg))
|
||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
//Cancel any download queued before
|
||||
|
||||
val id: Long = Preferences["update_download_id"]
|
||||
|
||||
if (id != -1L)
|
||||
downloadManager.remove(id)
|
||||
|
||||
val target = File(context.getExternalFilesDir(null), "Pupil.apk").also {
|
||||
it.delete()
|
||||
}
|
||||
|
||||
val request = DownloadManager.Request(Uri.parse(url))
|
||||
.setTitle(context.getText(R.string.update_notification_description))
|
||||
.setDestinationUri(Uri.fromFile(target))
|
||||
|
||||
downloadManager.enqueue(request).also {
|
||||
Preferences["update_download_id"] = it
|
||||
}
|
||||
}
|
||||
setNegativeButton(if (force) android.R.string.cancel else R.string.ignore_update) { _, _ ->
|
||||
if (!force)
|
||||
preferences.edit()
|
||||
.putLong("ignore_update_until", System.currentTimeMillis() + 604800000)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restore(url: String, onFailure: ((Throwable) -> Unit)? = null, onSuccess: ((List<Int>) -> Unit)? = null) {
|
||||
if (!URLUtil.isValidUrl(url)) {
|
||||
onFailure?.invoke(IllegalArgumentException())
|
||||
return
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object: Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
onFailure?.invoke(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
kotlin.runCatching {
|
||||
Json.decodeFromString<List<Int>>(response.also { if (it.code() != 200) throw IOException() }.body().use { it?.string() } ?: "[]").let {
|
||||
favorites.addAll(it)
|
||||
onSuccess?.invoke(it)
|
||||
}
|
||||
}.onFailure { onFailure?.invoke(it) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private var job: Job? = null
|
||||
private val receiver = object: BroadcastReceiver() {
|
||||
val ACTION_CANCEL = "ACTION_IMPORT_CANCEL"
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
context ?: return
|
||||
|
||||
when (intent?.action) {
|
||||
ACTION_CANCEL -> {
|
||||
job?.cancel()
|
||||
NotificationManagerCompat.from(context).cancel(R.id.notification_id_import)
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@SuppressLint("RestrictedApi")
|
||||
fun xyz.quaver.pupil.util.downloader.DownloadManager.migrate() {
|
||||
registerReceiver(receiver, IntentFilter().apply { addAction(receiver.ACTION_CANCEL) })
|
||||
|
||||
val notificationManager = NotificationManagerCompat.from(this)
|
||||
val action = NotificationCompat.Action.Builder(0, getText(android.R.string.cancel),
|
||||
PendingIntent.getBroadcast(this, R.id.notification_import_cancel_action.normalizeID(), Intent(receiver.ACTION_CANCEL), PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
).build()
|
||||
val notification = NotificationCompat.Builder(this, "import")
|
||||
.setContentTitle(getText(R.string.import_old_galleries_notification))
|
||||
.setProgress(0, 0, true)
|
||||
.addAction(action)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setOngoing(true)
|
||||
|
||||
DownloadService.cancel(this)
|
||||
|
||||
job?.cancel()
|
||||
job = CoroutineScope(Dispatchers.IO).launch {
|
||||
val downloadFolders = downloadFolder.listFiles { folder ->
|
||||
folder.isDirectory && !downloadFolderMap.values.contains(folder.name)
|
||||
}?.map {
|
||||
if (it !is FileX)
|
||||
FileX(this@migrate, it)
|
||||
else
|
||||
it
|
||||
}
|
||||
|
||||
if (downloadFolders.isNullOrEmpty()) return@launch
|
||||
|
||||
downloadFolders.forEachIndexed { index, folder ->
|
||||
notification
|
||||
.setContentText(getString(R.string.import_old_galleries_notification_text, index, downloadFolders.size))
|
||||
.setProgress(index, downloadFolders.size, false)
|
||||
notificationManager.notify(R.id.notification_id_import, notification.build())
|
||||
|
||||
kotlin.runCatching {
|
||||
val metadata = kotlin.runCatching {
|
||||
folder.getChild(".metadata").readText()?.let { Json.parseToJsonElement(it).jsonObject }
|
||||
}.getOrNull()
|
||||
|
||||
val galleryID = folder.name.toIntOrNull() ?: return@runCatching
|
||||
|
||||
val galleryBlock: GalleryBlock? = kotlin.runCatching {
|
||||
metadata?.get("galleryBlock")?.let { Json.decodeFromJsonElement<GalleryBlock>(it) }
|
||||
}.getOrNull() ?: getGalleryBlock(galleryID)
|
||||
val reader: Reader? = kotlin.runCatching {
|
||||
metadata?.get("reader")?.let { Json.decodeFromJsonElement<Reader>(it) }
|
||||
}.getOrNull() ?: getReader(galleryID)
|
||||
|
||||
metadata?.get("thumbnail")?.jsonPrimitive?.contentOrNull?.also { thumbnail ->
|
||||
val file = folder.getChild(".thumbnail").also {
|
||||
if (it.exists())
|
||||
it.delete()
|
||||
it.createNewFile()
|
||||
}
|
||||
|
||||
file.writeBytes(Base64.decode(thumbnail, Base64.DEFAULT))
|
||||
}
|
||||
|
||||
val list: MutableList<String?> =
|
||||
MutableList(reader!!.galleryInfo.files.size) { null }
|
||||
|
||||
folder.listFiles { file ->
|
||||
file?.nameWithoutExtension?.let {
|
||||
Regex("""\d{5}""").matches(it) && it.toIntOrNull() != null
|
||||
} == true
|
||||
}?.forEach {
|
||||
list[it.nameWithoutExtension.toInt()] = it.name
|
||||
}
|
||||
|
||||
folder.getChild(".metadata").also { if (it.exists()) it.delete(); it.createNewFile() }.writeText(
|
||||
Json.encodeToString(Metadata(galleryBlock, reader, list))
|
||||
)
|
||||
|
||||
synchronized(Cache) {
|
||||
Cache.delete(this@migrate, galleryID)
|
||||
}
|
||||
downloadFolderMap[galleryID] = folder.name
|
||||
|
||||
downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile(); it.writeText(Json.encodeToString(downloadFolderMap)) }
|
||||
}
|
||||
}
|
||||
|
||||
notification
|
||||
.setContentText(getText(R.string.import_old_galleries_notification_done))
|
||||
.setProgress(0, 0, false)
|
||||
.setOngoing(false)
|
||||
.mActions.clear()
|
||||
notificationManager.notify(R.id.notification_id_import, notification.build())
|
||||
|
||||
kotlin.runCatching {
|
||||
unregisterReceiver(receiver)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Pupil, Hitomi.la viewer for Android
|
||||
~ Copyright (C) 2020 tom5079
|
||||
~
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU General Public License as published by
|
||||
~ the Free Software Foundation, either version 3 of the License, or
|
||||
~ (at your option) any later version.
|
||||
~
|
||||
~ This program is distributed in the hope that it will be useful,
|
||||
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ GNU General Public License for more details.
|
||||
~
|
||||
~ You should have received a copy of the GNU General Public License
|
||||
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:duration="300"
|
||||
android:fromXDelta="0"
|
||||
android:interpolator="@anim/shake_cycle"
|
||||
android:toXDelta="10" />
|
||||
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Pupil, Hitomi.la viewer for Android
|
||||
~ Copyright (C) 2020 tom5079
|
||||
~
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU General Public License as published by
|
||||
~ the Free Software Foundation, either version 3 of the License, or
|
||||
~ (at your option) any later version.
|
||||
~
|
||||
~ This program is distributed in the hope that it will be useful,
|
||||
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ GNU General Public License for more details.
|
||||
~
|
||||
~ You should have received a copy of the GNU General Public License
|
||||
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<cycleInterpolator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:cycles="3" />
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Pupil, Hitomi.la viewer for Android
|
||||
~ Copyright (C) 2020 tom5079
|
||||
~
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU General Public License as published by
|
||||
~ the Free Software Foundation, either version 3 of the License, or
|
||||
~ (at your option) any later version.
|
||||
~
|
||||
~ This program is distributed in the hope that it will be useful,
|
||||
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ GNU General Public License for more details.
|
||||
~
|
||||
~ You should have received a copy of the GNU General Public License
|
||||
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_enabled="false" android:color="@android:color/darker_gray"/>
|
||||
<item android:color="@color/colorPrimary"/>
|
||||
</selector>
|
||||
|
Before Width: | Height: | Size: 325 B |
|
Before Width: | Height: | Size: 145 B |
|
Before Width: | Height: | Size: 571 B |
|
Before Width: | Height: | Size: 279 B |
|
Before Width: | Height: | Size: 255 B |
|
Before Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 235 B |
|
Before Width: | Height: | Size: 99 B |
|
Before Width: | Height: | Size: 345 B |