Compare commits
81 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 |
23
.github/workflows/watchdiff.yml
vendored
23
.github/workflows/watchdiff.yml
vendored
@@ -1,23 +0,0 @@
|
|||||||
# This is a basic workflow that is manually triggered
|
|
||||||
|
|
||||||
name: Watch hitomi.la file changes
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "*/10 * * * *"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
watchdiff:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
ref: watchdiff
|
|
||||||
- name: Download files
|
|
||||||
run: ./fetch.sh
|
|
||||||
- name: Commit and Push
|
|
||||||
id: push
|
|
||||||
run: |
|
|
||||||
git config --global user.name 'Watchdiff bot'
|
|
||||||
git config --global user.email 'tom5079@naver.com'
|
|
||||||
{ git add . && git commit -m "File update" && git push; } | tail -1 | grep -q "nothing to commit"
|
|
||||||
47
.gitignore
vendored
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
|
*.iml
|
||||||
.gradle
|
.idea/
|
||||||
/local.properties
|
misc.xml
|
||||||
/.idea/caches
|
deploymentTargetDropDown.xml
|
||||||
/.idea/libraries
|
render.experimental.xml
|
||||||
/.idea/modules.xml
|
|
||||||
/.idea/workspace.xml
|
|
||||||
/.idea/navEditor.xml
|
|
||||||
/.idea/assetWizardSettings.xml
|
|
||||||
.DS_Store
|
|
||||||
/build
|
|
||||||
/captures
|
|
||||||
.externalNativeBuild
|
|
||||||
|
|
||||||
#Github pages
|
# Keystore files
|
||||||
/gh-pages
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
#Private files
|
# Google Services (e.g. APIs or Firebase)
|
||||||
**/google-services.json
|
google-services.json
|
||||||
**/credentials.json
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|||||||
139
.idea/codeStyles/Project.xml
generated
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
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
6
.idea/compiler.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="CompilerConfiguration">
|
|
||||||
<bytecodeTargetLevel target="11" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/copyright/GPL.xml
generated
6
.idea/copyright/GPL.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<component name="CopyrightManager">
|
|
||||||
<copyright>
|
|
||||||
<option name="notice" value=" Pupil, Hitomi.la viewer for Android Copyright (C) &#36;today.year tom5079 This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>." />
|
|
||||||
<option name="myName" value="GPL" />
|
|
||||||
</copyright>
|
|
||||||
</component>
|
|
||||||
7
.idea/copyright/profiles_settings.xml
generated
7
.idea/copyright/profiles_settings.xml
generated
@@ -1,7 +0,0 @@
|
|||||||
<component name="CopyrightManager">
|
|
||||||
<settings>
|
|
||||||
<module2copyright>
|
|
||||||
<element module="Project Files" copyright="GPL" />
|
|
||||||
</module2copyright>
|
|
||||||
</settings>
|
|
||||||
</component>
|
|
||||||
17
.idea/deploymentTargetDropDown.xml
generated
17
.idea/deploymentTargetDropDown.xml
generated
@@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="deploymentTargetDropDown">
|
|
||||||
<targetSelectedWithDropDown>
|
|
||||||
<Target>
|
|
||||||
<type value="QUICK_BOOT_TARGET" />
|
|
||||||
<deviceKey>
|
|
||||||
<Key>
|
|
||||||
<type value="VIRTUAL_DEVICE_PATH" />
|
|
||||||
<value value="$USER_HOME$/.android/avd/Pixel_2_API_31.avd" />
|
|
||||||
</Key>
|
|
||||||
</deviceKey>
|
|
||||||
</Target>
|
|
||||||
</targetSelectedWithDropDown>
|
|
||||||
<timeTargetWasSelectedWithDropDown value="2022-01-24T19:40:46.041035Z" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
7
.idea/dictionaries/tom50.xml
generated
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
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>
|
|
||||||
22
.idea/gradle.xml
generated
22
.idea/gradle.xml
generated
@@ -1,22 +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="GRADLE" />
|
|
||||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
|
||||||
<option name="gradleHome" value="/usr/share/java/gradle" />
|
|
||||||
<option name="gradleJvm" value="11" />
|
|
||||||
<option name="modules">
|
|
||||||
<set>
|
|
||||||
<option value="$PROJECT_DIR$" />
|
|
||||||
<option value="$PROJECT_DIR$/app" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
<option name="resolveModulePerSourceSet" value="false" />
|
|
||||||
</GradleProjectSettings>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
6
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<profile version="1.0">
|
|
||||||
<option name="myName" value="Project Default" />
|
|
||||||
<inspection_tool class="AndroidLintNewerVersionAvailable" enabled="true" level="WARNING" enabled_by_default="true" />
|
|
||||||
</profile>
|
|
||||||
</component>
|
|
||||||
90
.idea/jarRepositories.xml
generated
90
.idea/jarRepositories.xml
generated
@@ -1,90 +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>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="maven2" />
|
|
||||||
<option name="name" value="maven2" />
|
|
||||||
<option name="url" value="https://guardian.github.io/maven/repo-releases/" />
|
|
||||||
</remote-repository>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="MavenLocal" />
|
|
||||||
<option name="name" value="MavenLocal" />
|
|
||||||
<option name="url" value="file:$USER_HOME$/.m2/repository/" />
|
|
||||||
</remote-repository>
|
|
||||||
<remote-repository>
|
|
||||||
<option name="id" value="maven2" />
|
|
||||||
<option name="name" value="maven2" />
|
|
||||||
<option name="url" value="https://oss.sonatype.org/content/repositories/snapshots" />
|
|
||||||
</remote-repository>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
7
.idea/kotlinCodeInsightSettings.xml
generated
7
.idea/kotlinCodeInsightSettings.xml
generated
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="KotlinCodeInsightWorkspaceSettings">
|
|
||||||
<option name="addUnambiguousImportsOnTheFly" value="true" />
|
|
||||||
<option name="optimizeImportsOnTheFly" value="true" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/kotlinc.xml
generated
6
.idea/kotlinc.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Kotlin2JvmCompilerArguments">
|
|
||||||
<option name="jvmTarget" value="1.8" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
17
.idea/misc.xml
generated
17
.idea/misc.xml
generated
@@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="DesignSurface">
|
|
||||||
<option name="filePathToZoomLevelMap">
|
|
||||||
<map>
|
|
||||||
<entry key="../../../../layout/custom_preview.xml" value="0.2564814814814815" />
|
|
||||||
<entry key="app/src/main/res/layout/reader_activity.xml" value="0.14351851851851852" />
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" 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>
|
|
||||||
3
.idea/scopes/Pupil.xml
generated
3
.idea/scopes/Pupil.xml
generated
@@ -1,3 +0,0 @@
|
|||||||
<component name="DependencyValidationManager">
|
|
||||||
<scope name="Pupil" pattern="file[app]:*/" />
|
|
||||||
</component>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
*Pupil, Hitomi.la viewer for Android*
|
*Pupil, Hitomi.la viewer for Android*
|
||||||
|
|
||||||

|

|
||||||
[](https://github.com/tom5079/Pupil/releases/download/5.2.20/Pupil-v5.2.20.apk)
|
[](https://github.com/tom5079/Pupil/releases/download/5.3.8-hotfix1/Pupil-v5.3.8-hotfix1.apk)
|
||||||
[](https://discord.gg/Stj4b5v)
|
[](https://discord.gg/Stj4b5v)
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|||||||
141
app/build.gradle
141
app/build.gradle
@@ -1,141 +0,0 @@
|
|||||||
apply plugin: "com.android.application"
|
|
||||||
apply plugin: "kotlin-android"
|
|
||||||
apply plugin: "kotlin-kapt"
|
|
||||||
apply plugin: "kotlin-parcelize"
|
|
||||||
apply plugin: "kotlinx-serialization"
|
|
||||||
apply plugin: "com.google.android.gms.oss-licenses-plugin"
|
|
||||||
|
|
||||||
if (file("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 31
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "xyz.quaver.pupil"
|
|
||||||
minSdkVersion 16
|
|
||||||
targetSdkVersion 31
|
|
||||||
versionCode 69
|
|
||||||
versionName "5.2.21"
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
vectorDrawables.useSupportLibrary = true
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
debug {
|
|
||||||
defaultConfig.minSdkVersion 21
|
|
||||||
|
|
||||||
minifyEnabled false
|
|
||||||
shrinkResources false
|
|
||||||
|
|
||||||
debuggable true
|
|
||||||
applicationIdSuffix ".debug"
|
|
||||||
versionNameSuffix "-DEBUG"
|
|
||||||
|
|
||||||
ext.enableCrashlytics = false
|
|
||||||
ext.alwaysUpdateBuildId = false
|
|
||||||
}
|
|
||||||
release {
|
|
||||||
minifyEnabled true
|
|
||||||
shrinkResources true
|
|
||||||
|
|
||||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildFeatures {
|
|
||||||
viewBinding true
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
|
||||||
freeCompilerArgs += "-Xuse-experimental=kotlin.Experimental"
|
|
||||||
}
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation fileTree(dir: "libs", include: ["*.jar", "*.aar"])
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2"
|
|
||||||
|
|
||||||
implementation "androidx.appcompat:appcompat:1.4.0"
|
|
||||||
implementation "androidx.activity:activity-ktx:1.4.0"
|
|
||||||
implementation "androidx.fragment:fragment-ktx:1.4.0"
|
|
||||||
implementation "androidx.preference:preference-ktx:1.1.1"
|
|
||||||
implementation "androidx.recyclerview:recyclerview:1.2.1"
|
|
||||||
implementation "androidx.constraintlayout:constraintlayout:2.1.2"
|
|
||||||
implementation "androidx.gridlayout:gridlayout:1.0.0"
|
|
||||||
implementation "androidx.biometric:biometric:1.1.0"
|
|
||||||
implementation "androidx.work:work-runtime-ktx:2.7.1"
|
|
||||||
implementation 'androidx.webkit:webkit:1.4.0'
|
|
||||||
|
|
||||||
implementation "com.daimajia.swipelayout:library:1.2.0@aar"
|
|
||||||
|
|
||||||
implementation "com.google.android.material:material:1.4.0"
|
|
||||||
|
|
||||||
implementation platform('com.google.firebase:firebase-bom:29.0.3')
|
|
||||||
implementation "com.google.firebase:firebase-analytics-ktx"
|
|
||||||
implementation "com.google.firebase:firebase-crashlytics-ktx"
|
|
||||||
implementation "com.google.firebase:firebase-perf-ktx"
|
|
||||||
|
|
||||||
implementation "com.google.android.gms:play-services-oss-licenses:17.0.0"
|
|
||||||
implementation "com.google.android.gms:play-services-mlkit-face-detection:16.2.1"
|
|
||||||
|
|
||||||
implementation "com.github.clans:fab:1.6.4"
|
|
||||||
|
|
||||||
//implementation "com.quiph.ui:recyclerviewfastscroller:0.2.1"
|
|
||||||
|
|
||||||
implementation 'com.github.piasy:BigImageViewer:1.8.1'
|
|
||||||
implementation 'com.github.piasy:FrescoImageLoader:1.8.1'
|
|
||||||
implementation 'com.github.piasy:FrescoImageViewFactory:1.8.1'
|
|
||||||
implementation 'com.facebook.fresco:imagepipeline-okhttp3:2.6.0'
|
|
||||||
|
|
||||||
//noinspection GradleDependency
|
|
||||||
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
|
||||||
|
|
||||||
implementation "com.tbuonomo.andrui:viewpagerdotsindicator:4.1.2"
|
|
||||||
|
|
||||||
implementation "net.rdrei.android.dirchooser:library:3.2@aar"
|
|
||||||
implementation "com.gu:option:1.3"
|
|
||||||
|
|
||||||
implementation "com.andrognito.patternlockview:patternlockview:1.0.0"
|
|
||||||
//implementation "com.andrognito.pinlockview:pinlockview:2.1.0"
|
|
||||||
|
|
||||||
implementation "ru.noties.markwon:core:3.1.0"
|
|
||||||
|
|
||||||
implementation "org.jsoup:jsoup:1.14.3"
|
|
||||||
|
|
||||||
implementation "xyz.quaver:documentfilex:0.7.2"
|
|
||||||
implementation "xyz.quaver:floatingsearchview:1.1.7"
|
|
||||||
|
|
||||||
testImplementation "junit:junit:4.13.2"
|
|
||||||
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0"
|
|
||||||
androidTestImplementation "androidx.test.ext:junit:1.1.3"
|
|
||||||
androidTestImplementation "androidx.test:rules:1.4.0"
|
|
||||||
androidTestImplementation "androidx.test:runner:1.4.0"
|
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
|
|
||||||
}
|
|
||||||
95
app/build.gradle.kts
Normal file
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)
|
||||||
|
}
|
||||||
5
app/proguard-rules.pro
vendored
5
app/proguard-rules.pro
vendored
@@ -1,6 +1,6 @@
|
|||||||
# Add project specific ProGuard rules here.
|
# Add project specific ProGuard rules here.
|
||||||
# You can control the set of applied configuration files using the
|
# 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
|
# For more details, see
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
@@ -31,6 +31,5 @@
|
|||||||
-keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
|
-keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
|
||||||
kotlinx.serialization.KSerializer serializer(...);
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
}
|
}
|
||||||
-keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment
|
|
||||||
-keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment
|
|
||||||
-keep class xyz.quaver.pupil.** { *; }
|
-keep class xyz.quaver.pupil.** { *; }
|
||||||
|
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"filters": [],
|
"filters": [],
|
||||||
"attributes": [],
|
"attributes": [],
|
||||||
"versionCode": 69,
|
"versionCode": 69,
|
||||||
"versionName": "5.2.21",
|
"versionName": "6.0.0",
|
||||||
"outputFile": "app-release.apk"
|
"outputFile": "app-release.apk"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -20,18 +20,18 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil
|
package xyz.quaver.pupil
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.webkit.*
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import kotlinx.coroutines.runBlocking
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
import okhttp3.OkHttpClient
|
||||||
import kotlinx.coroutines.*
|
import okhttp3.Request
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import xyz.quaver.pupil.hitomi.*
|
import xyz.quaver.pupil.hitomi.*
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instrumented test, which will execute on an Android device.
|
* Instrumented test, which will execute on an Android device.
|
||||||
@@ -40,81 +40,144 @@ import xyz.quaver.pupil.hitomi.*
|
|||||||
*/
|
*/
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class ExampleInstrumentedTest {
|
class ExampleInstrumentedTest {
|
||||||
|
// @Before
|
||||||
|
// fun init() {
|
||||||
|
// val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
// }
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun init() {
|
fun init() {
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
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()
|
||||||
|
|
||||||
runBlocking {
|
chain.proceed(request)
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
initWebView(appContext)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_getGalleryIDsFromNozomi() {
|
fun test_empty() {
|
||||||
runBlocking {
|
print(
|
||||||
val result = getGalleryIDsFromNozomi(null, "boten", "all")
|
"".trim()
|
||||||
|
.replace(Regex("""^\?"""), "")
|
||||||
Log.d("PUPILD", "getGalleryIDsFromNozomi: ${result.size}")
|
.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
|
@Test
|
||||||
fun test_getGalleryIDsForQuery() {
|
fun test_search() {
|
||||||
runBlocking {
|
val ids = getGalleryIDsForQuery("language:korean").reversed()
|
||||||
val result = getGalleryIDsForQuery("female:crotch tattoo")
|
|
||||||
|
|
||||||
Log.d("PUPILD", "getGalleryIDsForQuery: ${result.size}")
|
print(ids.size)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_getSuggestionsForQuery() {
|
fun test_suggestions() {
|
||||||
runBlocking {
|
val suggestions = getSuggestionsForQuery("language:g")
|
||||||
val result = getSuggestionsForQuery("fem")
|
|
||||||
|
|
||||||
Log.d("PUPILD", "getSuggestionsForQuery: ${result.size}")
|
print(suggestions)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_urlFromUrlFromHash() {
|
fun test_doSearch() {
|
||||||
runBlocking {
|
val r = runBlocking {
|
||||||
val galleryInfo = getGalleryInfo(2102416)
|
doSearch("language:korean")
|
||||||
|
|
||||||
val result = galleryInfo.files.map {
|
|
||||||
imageUrlFromImage(2102416, it, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d("PUPILD", result.toString())
|
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
|
@Test
|
||||||
fun test_getGalleryInfo() {
|
fun test_getGalleryInfo() {
|
||||||
runBlocking {
|
val info = getGalleryInfo(1469394)
|
||||||
val galleryInfo = getGalleryInfo(2102416)
|
|
||||||
|
|
||||||
Log.d("PUPILD", galleryInfo.toString())
|
print(info)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_getGallery() {
|
fun test_getReader() {
|
||||||
runBlocking {
|
val reader = getGalleryInfo(2128654)
|
||||||
val gallery = getGallery(2109479)
|
|
||||||
|
|
||||||
Log.d("PUPILD", gallery.toString())
|
Log.d("PUPILD", reader.toString())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_getGalleryBlock() {
|
fun test_getImages() { runBlocking {
|
||||||
runBlocking {
|
val galleryID = 2128654
|
||||||
val block = getGalleryBlock(2119310)
|
|
||||||
|
|
||||||
Log.d("PUPILD", block.toString())
|
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,7 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools" >
|
||||||
package="xyz.quaver.pupil">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
@@ -11,6 +10,8 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<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" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||||
@@ -23,9 +24,7 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
tools:replace="android:theme"
|
|
||||||
tools:ignore="UnusedAttribute" >
|
tools:ignore="UnusedAttribute" >
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
@@ -44,187 +43,15 @@
|
|||||||
|
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<service android:name=".services.DownloadService"
|
<service android:name=".services.ImageCacheService"
|
||||||
|
android:permission="android.permission.BIND_JOB_SERVICE"
|
||||||
android:exported="false" />
|
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"
|
|
||||||
android:exported="true">
|
|
||||||
<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
|
<activity
|
||||||
android:name=".ui.MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||||
android:theme="@style/NoActionBarAppTheme"
|
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -232,19 +59,7 @@
|
|||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
|
|
||||||
<data
|
|
||||||
android:scheme="http"
|
|
||||||
android:host="ix.io"
|
|
||||||
android:pathPattern="/..*" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
</activity>
|
||||||
<activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" />
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil
|
package xyz.quaver.pupil
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@@ -27,300 +26,16 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import com.google.firebase.FirebaseApp
|
||||||
import android.webkit.*
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import androidx.webkit.WebViewCompat
|
|
||||||
import com.facebook.imagepipeline.backends.okhttp3.OkHttpImagePipelineConfigFactory
|
|
||||||
import com.github.piasy.biv.BigImageViewer
|
|
||||||
import com.github.piasy.biv.loader.fresco.FrescoImageLoader
|
|
||||||
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
|
|
||||||
import com.google.android.gms.common.GooglePlayServicesRepairableException
|
|
||||||
import com.google.android.gms.security.ProviderInstaller
|
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
import kotlinx.coroutines.*
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.*
|
|
||||||
import xyz.quaver.io.FileX
|
import xyz.quaver.io.FileX
|
||||||
import xyz.quaver.pupil.hitomi.evaluationContext
|
import java.util.UUID
|
||||||
import xyz.quaver.pupil.types.JavascriptException
|
|
||||||
import xyz.quaver.pupil.types.Tag
|
|
||||||
import xyz.quaver.pupil.util.*
|
|
||||||
import java.io.File
|
|
||||||
import java.net.URL
|
|
||||||
import java.util.*
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
|
||||||
lateinit var webView: WebView
|
|
||||||
val _webViewFlow = MutableSharedFlow<Pair<String, String?>>()
|
|
||||||
val webViewFlow = _webViewFlow.asSharedFlow()
|
|
||||||
var webViewReady = false
|
|
||||||
var webViewFailed = false
|
|
||||||
var oldWebView = false
|
|
||||||
private var reloadJob: Job? = null
|
|
||||||
|
|
||||||
fun reloadWebView() {
|
|
||||||
if (reloadJob?.isActive == true) return
|
|
||||||
|
|
||||||
reloadJob = CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
webViewReady = false
|
|
||||||
webViewFailed = false
|
|
||||||
oldWebView = false
|
|
||||||
|
|
||||||
evaluationContext.cancelChildren(CancellationException("reload"))
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
URL(
|
|
||||||
if (BuildConfig.DEBUG)
|
|
||||||
"https://tom5079.github.io/PupilSources/hitomi-dev.html"
|
|
||||||
else
|
|
||||||
"https://tom5079.github.io/PupilSources/hitomi.html"
|
|
||||||
).readText()
|
|
||||||
}.onFailure {
|
|
||||||
webViewFailed = true
|
|
||||||
}.getOrNull()?.let { html ->
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
webView.loadDataWithBaseURL(
|
|
||||||
"https://hitomi.la/",
|
|
||||||
html,
|
|
||||||
"text/html",
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var htmlVersion: String = ""
|
|
||||||
fun reloadWhenFailedOrUpdate() = CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
while (true) {
|
|
||||||
if (
|
|
||||||
webViewFailed ||
|
|
||||||
runCatching {
|
|
||||||
URL(
|
|
||||||
if (BuildConfig.DEBUG)
|
|
||||||
"https://tom5079.github.io/PupilSources/hitomi-dev.html.ver"
|
|
||||||
else
|
|
||||||
"https://tom5079.github.io/PupilSources/hitomi.html.ver"
|
|
||||||
).readText()
|
|
||||||
}.getOrNull().let { version ->
|
|
||||||
(!version.isNullOrEmpty() && version != htmlVersion).also {
|
|
||||||
if (it) htmlVersion = version!!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
reloadWebView()
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(if (webViewReady && !webViewFailed) 10000 else 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
|
||||||
fun initWebView(context: Context) {
|
|
||||||
if (BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(true)
|
|
||||||
|
|
||||||
webView = WebView(context).apply {
|
|
||||||
with (settings) {
|
|
||||||
javaScriptEnabled = true
|
|
||||||
domStorageEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
userAgent = settings.userAgentString
|
|
||||||
|
|
||||||
webViewClient = object: WebViewClient() {
|
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
|
||||||
webViewReady = true
|
|
||||||
|
|
||||||
webView.evaluateJavascript(
|
|
||||||
"""
|
|
||||||
try {
|
|
||||||
new Function('(x => x?.y ?? z)');
|
|
||||||
true;
|
|
||||||
} catch (err) {
|
|
||||||
false;
|
|
||||||
}
|
|
||||||
""".trimIndent()
|
|
||||||
) {
|
|
||||||
val es2020: Boolean = Json.decodeFromString(it)
|
|
||||||
|
|
||||||
oldWebView = !es2020
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onReceivedError(
|
|
||||||
view: WebView?,
|
|
||||||
request: WebResourceRequest?,
|
|
||||||
error: WebResourceError?
|
|
||||||
) {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
FirebaseCrashlytics.getInstance().log(
|
|
||||||
"onReceivedError: ${error?.description}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
webChromeClient = object: WebChromeClient() {
|
|
||||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
|
||||||
FirebaseCrashlytics.getInstance().log(
|
|
||||||
"onConsoleMessage: ${consoleMessage?.message()} (${consoleMessage?.sourceId()}:${consoleMessage?.lineNumber()})"
|
|
||||||
)
|
|
||||||
|
|
||||||
return super.onConsoleMessage(consoleMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addJavascriptInterface(object {
|
|
||||||
@JavascriptInterface
|
|
||||||
fun onResult(uid: String, result: String) {
|
|
||||||
CoroutineScope(Dispatchers.Unconfined).launch {
|
|
||||||
_webViewFlow.emit(uid to result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@JavascriptInterface
|
|
||||||
fun onError(uid: String, script: String, message: String, stack: String) {
|
|
||||||
CoroutineScope(Dispatchers.Unconfined).launch {
|
|
||||||
_webViewFlow.emit(uid to "")
|
|
||||||
}
|
|
||||||
|
|
||||||
FirebaseCrashlytics.getInstance().recordException(
|
|
||||||
JavascriptException("onError script: $script\nmessage: $message\nstack: $stack")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, "Callback")
|
|
||||||
}
|
|
||||||
|
|
||||||
reloadWhenFailedOrUpdate()
|
|
||||||
}
|
|
||||||
|
|
||||||
lateinit var userAgent: String
|
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
class Pupil : Application() {
|
class Pupil : Application() {
|
||||||
|
|
||||||
companion object {
|
|
||||||
lateinit var instance: Pupil
|
|
||||||
private set
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
instance = this
|
FirebaseApp.initializeApp(this)
|
||||||
|
|
||||||
initWebView(this)
|
|
||||||
|
|
||||||
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().newBuilder()
|
|
||||||
.header("User-Agent", userAgent)
|
|
||||||
.header("Referer", "https://hitomi.la/")
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val tag = request.tag() ?: return@addInterceptor chain.proceed(request)
|
|
||||||
|
|
||||||
interceptors[tag::class]?.invoke(chain) ?: chain.proceed(request)
|
|
||||||
}.apply {
|
|
||||||
(Preferences.get<String>("max_concurrent_download").toIntOrNull() ?: 0).let {
|
|
||||||
if (it != 0)
|
|
||||||
dispatcher(Dispatcher(Executors.newFixedThreadPool(it)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Preferences.get<String>("download_folder").also {
|
|
||||||
if (it.startsWith("content://"))
|
|
||||||
contentResolver.takePersistableUriPermission(
|
|
||||||
Uri.parse(it),
|
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!FileX(this, it).canWrite())
|
|
||||||
throw Exception()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
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"), "")
|
|
||||||
|
|
||||||
favoriteTags.filter { it.tag.contains('_') }.forEach {
|
|
||||||
favoriteTags.remove(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
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,
|
|
||||||
OkHttpImagePipelineConfigFactory
|
|
||||||
.newBuilder(this, client)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
@@ -354,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()
|
super.onCreate()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,323 +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.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.Toast
|
|
||||||
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.coroutines.*
|
|
||||||
import xyz.quaver.io.util.getChild
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.databinding.GalleryblockItemBinding
|
|
||||||
import xyz.quaver.pupil.favoriteTags
|
|
||||||
import xyz.quaver.pupil.favorites
|
|
||||||
import xyz.quaver.pupil.hitomi.getGallery
|
|
||||||
import xyz.quaver.pupil.hitomi.getGalleryInfo
|
|
||||||
import xyz.quaver.pupil.types.Tag
|
|
||||||
import xyz.quaver.pupil.ui.view.ProgressCard
|
|
||||||
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 {
|
|
||||||
|
|
||||||
var updateAll = true
|
|
||||||
var thin: Boolean = Preferences["thin"]
|
|
||||||
|
|
||||||
inner class GalleryViewHolder(val binding: GalleryblockItemBinding) : RecyclerView.ViewHolder(binding.root) {
|
|
||||||
private var galleryID: Int = 0
|
|
||||||
|
|
||||||
init {
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
while (updateAll) {
|
|
||||||
updateProgress(itemView.context)
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateProgress(context: Context) = CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
with(binding.galleryblockCard) {
|
|
||||||
val imageList = Cache.getInstance(context, galleryID).metadata.imageList
|
|
||||||
|
|
||||||
if (imageList == null) {
|
|
||||||
max = 0
|
|
||||||
return@with
|
|
||||||
}
|
|
||||||
|
|
||||||
progress = imageList.count { it != null }
|
|
||||||
max = imageList.size
|
|
||||||
|
|
||||||
this@GalleryViewHolder.binding.galleryblockId.setOnClickListener {
|
|
||||||
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
|
|
||||||
ClipData.newPlainText("gallery_id", galleryID.toString())
|
|
||||||
)
|
|
||||||
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
type = if (!imageList.contains(null)) {
|
|
||||||
val downloadManager = DownloadManager.getInstance(context)
|
|
||||||
|
|
||||||
if (downloadManager.getDownloadFolder(galleryID) == null)
|
|
||||||
ProgressCard.Type.CACHE
|
|
||||||
else
|
|
||||||
ProgressCard.Type.DOWNLOAD
|
|
||||||
} else
|
|
||||||
ProgressCard.Type.LOADING
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bind(galleryID: Int) {
|
|
||||||
this.galleryID = galleryID
|
|
||||||
updateProgress(itemView.context)
|
|
||||||
|
|
||||||
val cache = Cache.getInstance(itemView.context, galleryID)
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val galleryBlock = cache.getGalleryBlock() ?: return@launch
|
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
val resources = itemView.context.resources
|
|
||||||
val languages = resources.getStringArray(R.array.languages).map {
|
|
||||||
it.split("|").let { split ->
|
|
||||||
Pair(split[0], split[1])
|
|
||||||
}
|
|
||||||
}.toMap()
|
|
||||||
|
|
||||||
val artists = galleryBlock.artists
|
|
||||||
val series = galleryBlock.series
|
|
||||||
|
|
||||||
binding.galleryblockThumbnail.apply {
|
|
||||||
setOnClickListener {
|
|
||||||
itemView.performClick()
|
|
||||||
}
|
|
||||||
setOnLongClickListener {
|
|
||||||
itemView.performLongClick()
|
|
||||||
}
|
|
||||||
setFailureImage(ContextCompat.getDrawable(context, R.drawable.image_broken_variant))
|
|
||||||
setImageLoaderCallback(object: ImageLoader.Callback {
|
|
||||||
override fun onFail(error: Exception?) {
|
|
||||||
Cache.delete(context, galleryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCacheHit(imageType: Int, image: File?) {}
|
|
||||||
override fun onCacheMiss(imageType: Int, image: File?) {}
|
|
||||||
override fun onFinish() {}
|
|
||||||
override fun onProgress(progress: Int) {}
|
|
||||||
override fun onStart() {}
|
|
||||||
override fun onSuccess(image: File?) {}
|
|
||||||
})
|
|
||||||
ssiv?.recycle()
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
cache.getThumbnail().let { launch(Dispatchers.Main) {
|
|
||||||
showImage(it)
|
|
||||||
} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.galleryblockTitle.text = galleryBlock.title
|
|
||||||
with(binding.galleryblockArtist) {
|
|
||||||
text = artists.joinToString { it.wordCapitalize() }
|
|
||||||
visibility = when {
|
|
||||||
artists.isNotEmpty() -> View.VISIBLE
|
|
||||||
else -> View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val gallery = runCatching {
|
|
||||||
getGallery(galleryID)
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
if (gallery?.groups?.isNotEmpty() != true)
|
|
||||||
return@launch
|
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
text = context.getString(
|
|
||||||
R.string.galleryblock_artist_with_group,
|
|
||||||
artists.joinToString { it.wordCapitalize() },
|
|
||||||
gallery.groups.joinToString { it.wordCapitalize() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with(binding.galleryblockSeries) {
|
|
||||||
text =
|
|
||||||
resources.getString(
|
|
||||||
R.string.galleryblock_series,
|
|
||||||
series.joinToString(", ") { it.wordCapitalize() })
|
|
||||||
visibility = when {
|
|
||||||
series.isNotEmpty() -> View.VISIBLE
|
|
||||||
else -> View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.galleryblockType.text = resources.getString(R.string.galleryblock_type, galleryBlock.type).wordCapitalize()
|
|
||||||
with(binding.galleryblockLanguage) {
|
|
||||||
text =
|
|
||||||
resources.getString(R.string.galleryblock_language, languages[galleryBlock.language])
|
|
||||||
visibility = when {
|
|
||||||
!galleryBlock.language.isNullOrEmpty() -> View.VISIBLE
|
|
||||||
else -> View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.galleryblockTagGroup) {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.galleryblockId.text = galleryBlock.id.toString()
|
|
||||||
binding.galleryblockPagecount.text = "-"
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val pageCount = kotlin.runCatching {
|
|
||||||
getGalleryInfo(galleryBlock.id).files.size
|
|
||||||
}.getOrNull() ?: return@launch
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
binding.galleryblockPagecount.text = itemView.context.getString(R.string.galleryblock_pagecount, pageCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.galleryblockFavorite) {
|
|
||||||
setImageResource(if (favorites.contains(galleryBlock.id)) R.drawable.ic_star_filled else R.drawable.ic_star_empty)
|
|
||||||
setOnClickListener {
|
|
||||||
when {
|
|
||||||
favorites.contains(galleryBlock.id) -> {
|
|
||||||
favorites.remove(galleryBlock.id)
|
|
||||||
|
|
||||||
setImageResource(R.drawable.ic_star_empty)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
favorites.add(galleryBlock.id)
|
|
||||||
|
|
||||||
setImageDrawable(AnimatedVectorDrawableCompat.create(context, R.drawable.avd_star).apply {
|
|
||||||
this ?: return@apply
|
|
||||||
|
|
||||||
registerAnimationCallback(object: Animatable2Compat.AnimationCallback() {
|
|
||||||
override fun onAnimationEnd(drawable: Drawable?) {
|
|
||||||
setImageResource(R.drawable.ic_star_filled)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
start()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make some views invisible to make it thinner
|
|
||||||
if (thin) {
|
|
||||||
binding.galleryblockTagGroup.visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val onChipClickedHandler = ArrayList<((Tag) -> Unit)>()
|
|
||||||
var onDownloadClickedHandler: ((Int) -> Unit)? = null
|
|
||||||
var onDeleteClickedHandler: ((Int) -> Unit)? = null
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
|
||||||
return GalleryViewHolder(GalleryblockItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
|
||||||
if (holder is GalleryViewHolder) {
|
|
||||||
val galleryID = galleries[position]
|
|
||||||
|
|
||||||
holder.bind(galleryID)
|
|
||||||
|
|
||||||
holder.binding.galleryblockCard.binding.download.setOnClickListener {
|
|
||||||
onDownloadClickedHandler?.invoke(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
holder.binding.galleryblockCard.binding.delete.setOnClickListener {
|
|
||||||
onDeleteClickedHandler?.invoke(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
mItemManger.bindView(holder.binding.root, position)
|
|
||||||
|
|
||||||
holder.binding.galleryblockCard.binding.swipeLayout.addSwipeListener(object: SwipeLayout.SwipeListener {
|
|
||||||
override fun onStartOpen(layout: SwipeLayout?) {
|
|
||||||
mItemManger.closeAllExcept(layout)
|
|
||||||
|
|
||||||
holder.binding.galleryblockCard.binding.download.text =
|
|
||||||
if (DownloadManager.getInstance(holder.binding.root.context).isDownloading(galleryID))
|
|
||||||
holder.binding.root.context.getString(android.R.string.cancel)
|
|
||||||
else
|
|
||||||
holder.binding.root.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
|
|
||||||
|
|
||||||
override fun getSwipeLayoutResourceId(position: Int) = R.id.swipe_layout
|
|
||||||
}
|
|
||||||
@@ -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.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.coroutines.*
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.databinding.ReaderItemBinding
|
|
||||||
import xyz.quaver.pupil.hitomi.GalleryInfo
|
|
||||||
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 galleryInfo: GalleryInfo? = null
|
|
||||||
|
|
||||||
var isFullScreen = false
|
|
||||||
|
|
||||||
var onItemClickListener : (() -> (Unit))? = null
|
|
||||||
|
|
||||||
inner class ViewHolder(private val binding: ReaderItemBinding) : RecyclerView.ViewHolder(binding.root) {
|
|
||||||
init {
|
|
||||||
with (binding.image) {
|
|
||||||
setImageViewFactory(FrescoImageViewFactory().apply {
|
|
||||||
updateView = { imageInfo ->
|
|
||||||
binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
|
||||||
dimensionRatio = "${imageInfo.width}:${imageInfo.height}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setImageShownCallback(object : ImageShownCallback {
|
|
||||||
override fun onMainImageShown() {
|
|
||||||
binding.image.mainView.let { v ->
|
|
||||||
when (v) {
|
|
||||||
is SubsamplingScaleImageView ->
|
|
||||||
if (!isFullScreen) binding.image.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onThumbnailShown() {}
|
|
||||||
})
|
|
||||||
|
|
||||||
setFailureImage(ContextCompat.getDrawable(itemView.context, R.drawable.image_broken_variant))
|
|
||||||
setOnClickListener {
|
|
||||||
onItemClickListener?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.root.setOnClickListener {
|
|
||||||
onItemClickListener?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bind(position: Int) {
|
|
||||||
if (cache == null)
|
|
||||||
cache = Cache.getInstance(itemView.context, galleryID)
|
|
||||||
|
|
||||||
if (!isFullScreen) {
|
|
||||||
binding.root.setBackgroundResource(R.drawable.reader_item_boundary)
|
|
||||||
binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
|
||||||
height = 0
|
|
||||||
dimensionRatio =
|
|
||||||
"${galleryInfo!!.files[position].width}:${galleryInfo!!.files[position].height}"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
|
||||||
height = ConstraintLayout.LayoutParams.MATCH_PARENT
|
|
||||||
dimensionRatio = null
|
|
||||||
}
|
|
||||||
binding.root.background = null
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.readerIndex.text = (position+1).toString()
|
|
||||||
|
|
||||||
val image = cache!!.getImage(position)
|
|
||||||
val progress = activity.downloader?.progress?.get(galleryID)?.get(position)
|
|
||||||
|
|
||||||
if (progress?.isInfinite() == true && image != null) {
|
|
||||||
binding.progressGroup.visibility = View.INVISIBLE
|
|
||||||
binding.image.showImage(image.uri)
|
|
||||||
} else {
|
|
||||||
binding.progressGroup.visibility = View.VISIBLE
|
|
||||||
binding.readerItemProgressbar.progress =
|
|
||||||
if (progress?.isInfinite() == true)
|
|
||||||
100
|
|
||||||
else
|
|
||||||
progress?.roundToInt() ?: 0
|
|
||||||
|
|
||||||
clear()
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
delay(1000)
|
|
||||||
notifyItemChanged(position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
binding.image.mainView.let {
|
|
||||||
when (it) {
|
|
||||||
is SubsamplingScaleImageView ->
|
|
||||||
it.recycle()
|
|
||||||
is SimpleDraweeView ->
|
|
||||||
it.controller = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
|
||||||
return ViewHolder(ReaderItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var cache: Cache? = null
|
|
||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
|
||||||
holder.bind(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount() = galleryInfo?.files?.size ?: 0
|
|
||||||
|
|
||||||
override fun onViewRecycled(holder: ViewHolder) {
|
|
||||||
holder.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class FrescoImageViewFactory : ImageViewFactory() {
|
|
||||||
var updateView: ((ImageInfo) -> Unit)? = null
|
|
||||||
|
|
||||||
override fun createAnimatedImageView(
|
|
||||||
context: Context, imageType: Int,
|
|
||||||
initScaleType: Int
|
|
||||||
): View {
|
|
||||||
val view = SimpleDraweeView(context)
|
|
||||||
view.hierarchy.actualImageScaleType = scaleType(initScaleType)
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadAnimatedContent(
|
|
||||||
view: View, imageType: Int,
|
|
||||||
imageFile: File
|
|
||||||
) {
|
|
||||||
if (view is SimpleDraweeView) {
|
|
||||||
val controller: DraweeController = Fresco.newDraweeControllerBuilder()
|
|
||||||
.setUri(Uri.parse("file://" + imageFile.absolutePath))
|
|
||||||
.setAutoPlayAnimations(true)
|
|
||||||
.setControllerListener(object: BaseControllerListener<ImageInfo>() {
|
|
||||||
override fun onIntermediateImageSet(id: String?, imageInfo: ImageInfo?) {
|
|
||||||
imageInfo?.let { updateView?.invoke(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFinalImageSet(id: String?, imageInfo: ImageInfo?, animatable: Animatable?) {
|
|
||||||
imageInfo?.let { updateView?.invoke(it) }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build()
|
|
||||||
view.controller = controller
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createThumbnailView(
|
|
||||||
context: Context,
|
|
||||||
scaleType: ImageView.ScaleType, willLoadFromNetwork: Boolean
|
|
||||||
): View {
|
|
||||||
return if (willLoadFromNetwork) {
|
|
||||||
val thumbnailView = SimpleDraweeView(context)
|
|
||||||
thumbnailView.hierarchy.actualImageScaleType = scaleType(scaleType)
|
|
||||||
thumbnailView
|
|
||||||
} else {
|
|
||||||
super.createThumbnailView(context, scaleType, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadThumbnailContent(view: View, thumbnail: Uri) {
|
|
||||||
if (view is SimpleDraweeView) {
|
|
||||||
val controller: DraweeController = Fresco.newDraweeControllerBuilder()
|
|
||||||
.setUri(thumbnail)
|
|
||||||
.build()
|
|
||||||
view.controller = controller
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scaleType(value: Int): ScalingUtils.ScaleType {
|
|
||||||
return when (value) {
|
|
||||||
BigImageView.INIT_SCALE_TYPE_CENTER -> ScalingUtils.ScaleType.CENTER
|
|
||||||
BigImageView.INIT_SCALE_TYPE_CENTER_CROP -> ScalingUtils.ScaleType.CENTER_CROP
|
|
||||||
BigImageView.INIT_SCALE_TYPE_CENTER_INSIDE -> ScalingUtils.ScaleType.CENTER_INSIDE
|
|
||||||
BigImageView.INIT_SCALE_TYPE_FIT_END -> ScalingUtils.ScaleType.FIT_END
|
|
||||||
BigImageView.INIT_SCALE_TYPE_FIT_START -> ScalingUtils.ScaleType.FIT_START
|
|
||||||
BigImageView.INIT_SCALE_TYPE_FIT_XY -> ScalingUtils.ScaleType.FIT_XY
|
|
||||||
BigImageView.INIT_SCALE_TYPE_FIT_CENTER -> ScalingUtils.ScaleType.FIT_CENTER
|
|
||||||
else -> ScalingUtils.ScaleType.FIT_CENTER
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scaleType(scaleType: ImageView.ScaleType): ScalingUtils.ScaleType {
|
|
||||||
return when (scaleType) {
|
|
||||||
ImageView.ScaleType.CENTER -> ScalingUtils.ScaleType.CENTER
|
|
||||||
ImageView.ScaleType.CENTER_CROP -> ScalingUtils.ScaleType.CENTER_CROP
|
|
||||||
ImageView.ScaleType.CENTER_INSIDE -> ScalingUtils.ScaleType.CENTER_INSIDE
|
|
||||||
ImageView.ScaleType.FIT_END -> ScalingUtils.ScaleType.FIT_END
|
|
||||||
ImageView.ScaleType.FIT_START -> ScalingUtils.ScaleType.FIT_START
|
|
||||||
ImageView.ScaleType.FIT_XY -> ScalingUtils.ScaleType.FIT_XY
|
|
||||||
ImageView.ScaleType.FIT_CENTER -> ScalingUtils.ScaleType.FIT_CENTER
|
|
||||||
else -> ScalingUtils.ScaleType.FIT_CENTER
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2019 tom5079
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.hitomi
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import android.webkit.WebView
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.transformWhile
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import xyz.quaver.pupil.*
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
|
|
||||||
const val protocol = "https:"
|
|
||||||
|
|
||||||
val evaluationContext = Dispatchers.Main + Job()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* kotlinx.serialization.json.Json object for global use
|
|
||||||
* properties should not be changed
|
|
||||||
*
|
|
||||||
* @see [https://kotlin.github.io/kotlinx.serialization/kotlinx-serialization-core/kotlinx-serialization-core/kotlinx.serialization.json/-json/index.html]
|
|
||||||
*/
|
|
||||||
val json = Json {
|
|
||||||
isLenient = true
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
allowSpecialFloatingPointValues = true
|
|
||||||
useArrayPolymorphism = true
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend inline fun <reified T> WebView.evaluate(script: String): T = coroutineScope {
|
|
||||||
var result: String? = null
|
|
||||||
|
|
||||||
while (result == null) {
|
|
||||||
try {
|
|
||||||
while (!oldWebView && !(webViewReady && !webViewFailed)) delay(100)
|
|
||||||
|
|
||||||
result = if (oldWebView)
|
|
||||||
"null"
|
|
||||||
else withContext(evaluationContext) {
|
|
||||||
suspendCoroutine { continuation ->
|
|
||||||
evaluateJavascript(script) {
|
|
||||||
continuation.resume(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
if (e.message != "reload") result = "null"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
json.decodeFromString(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
suspend inline fun <reified T> WebView.evaluatePromise(
|
|
||||||
script: String,
|
|
||||||
then: String = ".then(result => Callback.onResult(%uid, JSON.stringify(result))).catch(err => Callback.onError(%uid, String.raw`$script`, err.message, err.stack))"
|
|
||||||
): T = coroutineScope {
|
|
||||||
var result: String? = null
|
|
||||||
|
|
||||||
while (result == null) {
|
|
||||||
try {
|
|
||||||
while (!oldWebView && !(webViewReady && !webViewFailed)) delay(100)
|
|
||||||
|
|
||||||
result = if (oldWebView)
|
|
||||||
"null"
|
|
||||||
else withContext(evaluationContext) {
|
|
||||||
val uid = UUID.randomUUID().toString()
|
|
||||||
|
|
||||||
val flow: Flow<Pair<String, String?>> = webViewFlow.transformWhile { (currentUid, result) ->
|
|
||||||
if (currentUid == uid) {
|
|
||||||
emit(currentUid to result)
|
|
||||||
}
|
|
||||||
currentUid != uid
|
|
||||||
}
|
|
||||||
|
|
||||||
launch {
|
|
||||||
evaluateJavascript((script + then).replace("%uid", "'$uid'"), null)
|
|
||||||
}
|
|
||||||
|
|
||||||
flow.first().second
|
|
||||||
}
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
if (e.message != "reload") result = "null"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
json.decodeFromString(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("EXPERIMENTAL_API_USAGE")
|
|
||||||
suspend fun getGalleryInfo(galleryID: Int): GalleryInfo =
|
|
||||||
webView.evaluatePromise("get_gallery_info($galleryID)")
|
|
||||||
|
|
||||||
//common.js
|
|
||||||
const val domain = "ltn.hitomi.la"
|
|
||||||
|
|
||||||
val String?.js: String
|
|
||||||
get() = if (this == null) "null" else "'$this'"
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
suspend fun urlFromUrlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null, base: String? = null): String =
|
|
||||||
webView.evaluate(
|
|
||||||
"""
|
|
||||||
url_from_url_from_hash(
|
|
||||||
${galleryID.toString().js},
|
|
||||||
${Json.encodeToString(image)},
|
|
||||||
${dir.js}, ${ext.js}, ${base.js}
|
|
||||||
)
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean) : String {
|
|
||||||
return when {
|
|
||||||
noWebp ->
|
|
||||||
urlFromUrlFromHash(galleryID, image)
|
|
||||||
// image.hasavif != 0 ->
|
|
||||||
// urlFromUrlFromHash(galleryID, image, "avif", null, "a")
|
|
||||||
image.haswebp != 0 ->
|
|
||||||
urlFromUrlFromHash(galleryID, image, "webp", null, "a")
|
|
||||||
else ->
|
|
||||||
urlFromUrlFromHash(galleryID, image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2019 tom5079
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.hitomi
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import xyz.quaver.pupil.webView
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Gallery(
|
|
||||||
val related: List<Int>,
|
|
||||||
val langList: Map<String, String>,
|
|
||||||
val cover: String,
|
|
||||||
val title: String,
|
|
||||||
val artists: List<String>,
|
|
||||||
val groups: List<String>,
|
|
||||||
val type: String,
|
|
||||||
val language: String,
|
|
||||||
val series: List<String>,
|
|
||||||
val characters: List<String>,
|
|
||||||
val tags: List<String>,
|
|
||||||
val thumbnails: List<String>
|
|
||||||
)
|
|
||||||
suspend fun getGallery(galleryID: Int) : Gallery =
|
|
||||||
webView.evaluatePromise("get_gallery($galleryID)")
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2019 tom5079
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.hitomi
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import xyz.quaver.pupil.webView
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class GalleryBlock(
|
|
||||||
val id: Int,
|
|
||||||
val galleryUrl: String,
|
|
||||||
val thumbnails: List<String>,
|
|
||||||
val title: String,
|
|
||||||
val artists: List<String>,
|
|
||||||
val series: List<String>,
|
|
||||||
val type: String,
|
|
||||||
val language: String?,
|
|
||||||
val relatedTags: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun getGalleryBlock(galleryID: Int) : GalleryBlock =
|
|
||||||
webView.evaluatePromise("get_gallery_block($galleryID)")
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2019 tom5079
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.hitomi
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
fun getReferer(galleryID: Int) = "https://hitomi.la/reader/$galleryID.html"
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class GalleryInfo(
|
|
||||||
val language_localname: String? = null,
|
|
||||||
val language: String? = null,
|
|
||||||
val date: String? = null,
|
|
||||||
val files: List<GalleryFiles>,
|
|
||||||
val id: Int? = null,
|
|
||||||
val type: String? = null,
|
|
||||||
val title: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class GalleryFiles(
|
|
||||||
val width: Int,
|
|
||||||
val hash: String,
|
|
||||||
val haswebp: Int = 0,
|
|
||||||
val name: String,
|
|
||||||
val height: Int,
|
|
||||||
val hasavif: Int = 0,
|
|
||||||
val hasavifsmalltn: Int? = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
//Set header `Referer` to reader url to avoid 403 error
|
|
||||||
@Deprecated("", replaceWith = ReplaceWith("getGalleryInfo"))
|
|
||||||
suspend fun getReader(galleryID: Int) : GalleryInfo {
|
|
||||||
return getGalleryInfo(galleryID)
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2019 tom5079
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.hitomi
|
|
||||||
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
suspend fun doSearch(query: String, sortByPopularity: Boolean = false) : Set<Int> = coroutineScope {
|
|
||||||
val terms = query
|
|
||||||
.trim()
|
|
||||||
.replace(Regex("""^\?"""), "")
|
|
||||||
.lowercase()
|
|
||||||
.split(Regex("\\s+"))
|
|
||||||
.map {
|
|
||||||
it.replace('_', ' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
val positiveTerms = LinkedList<String>()
|
|
||||||
val negativeTerms = LinkedList<String>()
|
|
||||||
|
|
||||||
for (term in terms) {
|
|
||||||
if (term.matches(Regex("^-.+")))
|
|
||||||
negativeTerms.push(term.replace(Regex("^-"), ""))
|
|
||||||
else if (term.isNotBlank())
|
|
||||||
positiveTerms.push(term)
|
|
||||||
}
|
|
||||||
|
|
||||||
val positiveResults = positiveTerms.map {
|
|
||||||
async {
|
|
||||||
runCatching {
|
|
||||||
getGalleryIDsForQuery(it)
|
|
||||||
}.getOrElse { emptySet() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val negativeResults = negativeTerms.mapIndexed { index, it ->
|
|
||||||
async {
|
|
||||||
runCatching {
|
|
||||||
getGalleryIDsForQuery(it)
|
|
||||||
}.getOrElse { emptySet() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val results = when {
|
|
||||||
sortByPopularity -> getGalleryIDsFromNozomi(null, "popular", "all")
|
|
||||||
positiveTerms.isEmpty() -> getGalleryIDsFromNozomi(null, "index", "all")
|
|
||||||
else -> emptySet()
|
|
||||||
}.toMutableSet()
|
|
||||||
|
|
||||||
fun filterPositive(newResults: Set<Int>) {
|
|
||||||
when {
|
|
||||||
results.isEmpty() -> results.addAll(newResults)
|
|
||||||
else -> results.retainAll(newResults)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun filterNegative(newResults: Set<Int>) {
|
|
||||||
results.removeAll(newResults)
|
|
||||||
}
|
|
||||||
|
|
||||||
//positive results
|
|
||||||
positiveResults.forEach {
|
|
||||||
filterPositive(it.await())
|
|
||||||
}
|
|
||||||
|
|
||||||
//negative results
|
|
||||||
negativeResults.forEachIndexed { index, it ->
|
|
||||||
filterNegative(it.await())
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2019 tom5079
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.hitomi
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import xyz.quaver.pupil.webView
|
|
||||||
|
|
||||||
//searchlib.js
|
|
||||||
const val extension = ".html"
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
suspend fun getGalleryIDsForQuery(query: String) : Set<Int> =
|
|
||||||
webView.evaluatePromise("get_galleryids_for_query('$query')")
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Suggestion(val s: String, val t: Int, val u: String, val n: String)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
suspend fun getSuggestionsForQuery(query: String) : List<Suggestion> =
|
|
||||||
webView.evaluatePromise(
|
|
||||||
"get_suggestions_for_query('$query', ++search_serial)",
|
|
||||||
then = """
|
|
||||||
.then(r => {
|
|
||||||
let [results, results_serial] = r;
|
|
||||||
if (search_serial !== results_serial) {
|
|
||||||
Callback.onResult(%uid, '[]');
|
|
||||||
} else {
|
|
||||||
Callback.onResult(%uid, JSON.stringify(results));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
suspend fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : Set<Int> {
|
|
||||||
val jsArea = if (area == null) "null" else "'$area'"
|
|
||||||
|
|
||||||
return webView.evaluatePromise("""get_galleryids_from_nozomi($jsArea, '$tag', '$language')""")
|
|
||||||
}
|
|
||||||
110
app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt
Normal file
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
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
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
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
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,94 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pupil, Hitomi.la viewer for Android
|
|
||||||
* Copyright (C) 2020 tom5079
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.receiver
|
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.util.Preferences
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class UpdateBroadcastReceiver : BroadcastReceiver() {
|
|
||||||
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
context ?: return
|
|
||||||
|
|
||||||
when (intent?.action) {
|
|
||||||
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
|
|
||||||
|
|
||||||
// Validate download
|
|
||||||
val downloadID: Long = Preferences["update_download_id"]
|
|
||||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
|
|
||||||
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2) != downloadID)
|
|
||||||
return
|
|
||||||
|
|
||||||
// Get target uri
|
|
||||||
|
|
||||||
val query = DownloadManager.Query()
|
|
||||||
.setFilterById(downloadID)
|
|
||||||
|
|
||||||
val uri = downloadManager.query(query).use { cursor ->
|
|
||||||
if (cursor.moveToFirst()) {
|
|
||||||
cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))?.let {
|
|
||||||
val uri = Uri.parse(it)
|
|
||||||
|
|
||||||
when (uri.scheme) {
|
|
||||||
"file" ->
|
|
||||||
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"))
|
|
||||||
}, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_IMMUTABLE else 0)
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, "update")
|
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
|
||||||
.setContentTitle(context.getText(R.string.update_download_completed))
|
|
||||||
.setContentText(context.getText(R.string.update_download_completed_description))
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
notificationManager.notify(R.id.notification_id_update, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,431 +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 android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.app.TaskStackBuilder
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.Call
|
|
||||||
import okhttp3.Callback
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.ResponseBody
|
|
||||||
import okio.*
|
|
||||||
import xyz.quaver.pupil.*
|
|
||||||
import xyz.quaver.pupil.ui.ReaderActivity
|
|
||||||
import xyz.quaver.pupil.util.*
|
|
||||||
import xyz.quaver.pupil.util.downloader.Cache
|
|
||||||
import xyz.quaver.pupil.util.downloader.DownloadManager
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import kotlin.math.ceil
|
|
||||||
import kotlin.math.log10
|
|
||||||
|
|
||||||
private typealias ProgressListener = (DownloadService.Tag, Long, Long, Boolean) -> Unit
|
|
||||||
class DownloadService : Service() {
|
|
||||||
data class Tag(val galleryID: Int, val index: Int, val startId: Int? = null)
|
|
||||||
|
|
||||||
//region Notification
|
|
||||||
private val notificationManager by lazy {
|
|
||||||
NotificationManagerCompat.from(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val serviceNotification by lazy {
|
|
||||||
NotificationCompat.Builder(this, "downloader")
|
|
||||||
.setContentTitle(getString(R.string.downloader_running))
|
|
||||||
.setProgress(0, 0, false)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setOngoing(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val notification = ConcurrentHashMap<Int, NotificationCompat.Builder?>()
|
|
||||||
|
|
||||||
private fun initNotification(galleryID: Int) {
|
|
||||||
val intent = Intent(this, ReaderActivity::class.java)
|
|
||||||
.putExtra("galleryID", galleryID)
|
|
||||||
|
|
||||||
val pendingIntent = TaskStackBuilder.create(this).run {
|
|
||||||
addNextIntentWithParentStack(intent)
|
|
||||||
getPendingIntent(galleryID, PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
|
|
||||||
}
|
|
||||||
val action =
|
|
||||||
NotificationCompat.Action.Builder(0, getText(android.R.string.cancel),
|
|
||||||
PendingIntent.getService(
|
|
||||||
this,
|
|
||||||
R.id.notification_download_cancel_action.normalizeID(),
|
|
||||||
Intent(this, DownloadService::class.java)
|
|
||||||
.putExtra(KEY_COMMAND, COMMAND_CANCEL)
|
|
||||||
.putExtra(KEY_ID, galleryID),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0),
|
|
||||||
).build()
|
|
||||||
|
|
||||||
notification[galleryID] = NotificationCompat.Builder(this, "download").apply {
|
|
||||||
setContentTitle(getString(R.string.reader_loading))
|
|
||||||
setContentText(getString(R.string.reader_notification_text))
|
|
||||||
setSmallIcon(R.drawable.ic_notification)
|
|
||||||
setContentIntent(pendingIntent)
|
|
||||||
addAction(action)
|
|
||||||
setProgress(0, 0, true)
|
|
||||||
setOngoing(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
notify(galleryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
|
||||||
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 limit = 5
|
|
||||||
|
|
||||||
while (!response.isSuccessful) {
|
|
||||||
if (response.code() == 503) {
|
|
||||||
Thread.sleep(200)
|
|
||||||
} else if (--limit > 0)
|
|
||||||
break
|
|
||||||
|
|
||||||
response = chain.proceed(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
response.newBuilder()
|
|
||||||
.body(response.body()?.let {
|
|
||||||
ProgressResponseBody(request.tag(), it, progressListener)
|
|
||||||
}).build()
|
|
||||||
}
|
|
||||||
//endregion
|
|
||||||
|
|
||||||
//region Downloader
|
|
||||||
/**
|
|
||||||
* KEY
|
|
||||||
* primary galleryID
|
|
||||||
* secondary index
|
|
||||||
* PRIMARY VALUE
|
|
||||||
* MutableList -> Download in progress
|
|
||||||
* null -> Loading / Gallery doesn't exist
|
|
||||||
* SECONDARY VALUE
|
|
||||||
* 0 <= value < 100 -> Download in progress
|
|
||||||
* Float.POSITIVE_INFINITY -> Download completed
|
|
||||||
*/
|
|
||||||
val progress = ConcurrentHashMap<Int, MutableList<Float>>()
|
|
||||||
var priority = 0
|
|
||||||
|
|
||||||
fun isCompleted(galleryID: Int) = progress[galleryID]?.toList()?.all { it == Float.POSITIVE_INFINITY } == true
|
|
||||||
|
|
||||||
private val callback = object: Callback {
|
|
||||||
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
FirebaseCrashlytics.getInstance().recordException(e)
|
|
||||||
|
|
||||||
if (e.message?.contains("cancel", true) == false) {
|
|
||||||
val galleryID = (call.request().tag() as Tag).galleryID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
val (galleryID, index, startId) = call.request().tag() as Tag
|
|
||||||
val ext = call.request().url().encodedPath().split('.').last()
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
runCatching {
|
|
||||||
response.also {
|
|
||||||
if (it.code() != 200) throw IOException(
|
|
||||||
"$galleryID $index ${response.request().url()} CODE ${it.code()}"
|
|
||||||
)
|
|
||||||
}.body()?.use {
|
|
||||||
val padding = ceil(progress[galleryID]?.size?.let { log10(it.toFloat()) } ?: 0F).toInt()
|
|
||||||
|
|
||||||
Cache.getInstance(this@DownloadService, galleryID)
|
|
||||||
.putImage(index, "${index.toString().padStart(padding, '0')}.$ext", it.byteStream())
|
|
||||||
|
|
||||||
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) }
|
|
||||||
}
|
|
||||||
} ?: throw Exception("Response null")
|
|
||||||
}.onFailure {
|
|
||||||
FirebaseCrashlytics.getInstance().recordException(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel(startId: Int? = null) {
|
|
||||||
client.dispatcher().queuedCalls().filter {
|
|
||||||
it.request().tag() is Tag
|
|
||||||
}.forEach {
|
|
||||||
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
|
|
||||||
it.cancel()
|
|
||||||
}
|
|
||||||
client.dispatcher().runningCalls().filter {
|
|
||||||
it.request().tag() is Tag
|
|
||||||
}.forEach {
|
|
||||||
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
|
|
||||||
it.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.clear()
|
|
||||||
notification.clear()
|
|
||||||
notificationManager.cancelAll()
|
|
||||||
|
|
||||||
startId?.let { stopSelf(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel(galleryID: Int, startId: Int? = null) {
|
|
||||||
client.dispatcher().queuedCalls().filter {
|
|
||||||
(it.request().tag() as? Tag)?.galleryID == galleryID
|
|
||||||
}.forEach {
|
|
||||||
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
|
|
||||||
it.cancel()
|
|
||||||
}
|
|
||||||
client.dispatcher().runningCalls().filter {
|
|
||||||
(it.request().tag() as? Tag)?.galleryID == galleryID
|
|
||||||
}.forEach {
|
|
||||||
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
|
|
||||||
it.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.remove(galleryID)
|
|
||||||
notification.remove(galleryID)
|
|
||||||
notificationManager.cancel(galleryID)
|
|
||||||
|
|
||||||
startId?.let { stopSelf(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun delete(galleryID: Int, startId: Int? = null) = CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
cancel(galleryID)
|
|
||||||
DownloadManager.getInstance(this@DownloadService).deleteDownloadFolder(galleryID)
|
|
||||||
Cache.delete(this@DownloadService, galleryID)
|
|
||||||
|
|
||||||
startId?.let { stopSelf(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun download(galleryID: Int, priority: Boolean = false, startId: Int? = null): Job = CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
if (DownloadManager.getInstance(this@DownloadService).isDownloading(galleryID))
|
|
||||||
return@launch
|
|
||||||
|
|
||||||
cleanCache(this@DownloadService)
|
|
||||||
|
|
||||||
val cache = Cache.getInstance(this@DownloadService, galleryID)
|
|
||||||
|
|
||||||
initNotification(galleryID)
|
|
||||||
|
|
||||||
val galleryInfo = cache.getGalleryInfo()
|
|
||||||
|
|
||||||
// Gallery doesn't exist
|
|
||||||
if (galleryInfo == null) {
|
|
||||||
delete(galleryID)
|
|
||||||
progress[galleryID] = mutableListOf()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
histories.add(galleryID)
|
|
||||||
|
|
||||||
progress[galleryID] = MutableList(galleryInfo.files.size) { 0F }
|
|
||||||
|
|
||||||
cache.metadata.imageList?.let {
|
|
||||||
it.forEachIndexed { index, image ->
|
|
||||||
progress[galleryID]?.set(index, if (image != null) Float.POSITIVE_INFINITY else 0F)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCompleted(galleryID)) {
|
|
||||||
if (DownloadManager.getInstance(this@DownloadService)
|
|
||||||
.getDownloadFolder(galleryID) != null )
|
|
||||||
Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
|
|
||||||
|
|
||||||
notificationManager.cancel(galleryID)
|
|
||||||
startId?.let { stopSelf(it) }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
notification[galleryID]?.setContentTitle(galleryInfo.title?.ellipsize(30))
|
|
||||||
notify(galleryID)
|
|
||||||
|
|
||||||
val queued = mutableSetOf<Int>()
|
|
||||||
|
|
||||||
if (priority) {
|
|
||||||
client.dispatcher().queuedCalls().forEach {
|
|
||||||
val queuedID = (it.request().tag() as? Tag)?.galleryID ?: return@forEach
|
|
||||||
|
|
||||||
if (queued.add(queuedID))
|
|
||||||
cancel(queuedID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryInfo.getRequestBuilders().forEachIndexed { index, it ->
|
|
||||||
if (progress[galleryID]?.get(index)?.isInfinite() == false) {
|
|
||||||
val request = it.tag(Tag(galleryID, index, startId)).build()
|
|
||||||
client.newCall(request).enqueue(callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
queued.forEach { download(it) }
|
|
||||||
}
|
|
||||||
//endregion
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val KEY_COMMAND = "COMMAND" // String
|
|
||||||
const val KEY_ID = "ID" // Int
|
|
||||||
const val KEY_PRIORITY = "PRIORITY" // Boolean
|
|
||||||
|
|
||||||
const val COMMAND_DOWNLOAD = "DOWNLOAD"
|
|
||||||
const val COMMAND_CANCEL = "CANCEL"
|
|
||||||
const val COMMAND_DELETE = "DELETE"
|
|
||||||
|
|
||||||
private fun command(context: Context, extras: Intent.() -> Unit) {
|
|
||||||
ContextCompat.startForegroundService(context, Intent(context, DownloadService::class.java).apply(extras))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun download(context: Context, galleryID: Int, priority: Boolean = false) {
|
|
||||||
command(context) {
|
|
||||||
putExtra(KEY_COMMAND, COMMAND_DOWNLOAD)
|
|
||||||
putExtra(KEY_PRIORITY, priority)
|
|
||||||
putExtra(KEY_ID, galleryID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel(context: Context, galleryID: Int? = null) {
|
|
||||||
command(context) {
|
|
||||||
putExtra(KEY_COMMAND, COMMAND_CANCEL)
|
|
||||||
galleryID?.let { putExtra(KEY_ID, it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun delete(context: Context, galleryID: Int) {
|
|
||||||
command(context) {
|
|
||||||
putExtra(KEY_COMMAND, COMMAND_DELETE)
|
|
||||||
putExtra(KEY_ID, galleryID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.parcelize.IgnoredOnParcel
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
|
|
||||||
import xyz.quaver.pupil.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,280 +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 xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.databinding.LockActivityBinding
|
|
||||||
import xyz.quaver.pupil.ui.fragment.PINLockFragment
|
|
||||||
import xyz.quaver.pupil.ui.fragment.PatternLockFragment
|
|
||||||
import xyz.quaver.pupil.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 lateinit var binding: LockActivityBinding
|
|
||||||
|
|
||||||
private val patternLockFragment = PatternLockFragment().apply {
|
|
||||||
var lastPass = ""
|
|
||||||
onPatternDrawn = {
|
|
||||||
when(mode) {
|
|
||||||
null -> {
|
|
||||||
val result = lockManager.check(it)
|
|
||||||
|
|
||||||
if (result == true) {
|
|
||||||
lastUnlocked = System.currentTimeMillis()
|
|
||||||
setResult(Activity.RESULT_OK)
|
|
||||||
finish()
|
|
||||||
} else
|
|
||||||
binding.patternLockView.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
|
||||||
}
|
|
||||||
"add_lock" -> {
|
|
||||||
if (lastPass.isEmpty()) {
|
|
||||||
lastPass = it
|
|
||||||
|
|
||||||
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
|
|
||||||
} else {
|
|
||||||
if (lastPass == it) {
|
|
||||||
LockManager(context!!).add(Lock.generate(Lock.Type.PATTERN, it))
|
|
||||||
finish()
|
|
||||||
} else {
|
|
||||||
binding.patternLockView.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
|
||||||
lastPass = ""
|
|
||||||
|
|
||||||
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val pinLockFragment = PINLockFragment().apply {
|
|
||||||
var lastPass = ""
|
|
||||||
onPINEntered = {
|
|
||||||
when(mode) {
|
|
||||||
null -> {
|
|
||||||
val result = lockManager.check(it)
|
|
||||||
|
|
||||||
if (result == true) {
|
|
||||||
lastUnlocked = System.currentTimeMillis()
|
|
||||||
setResult(Activity.RESULT_OK)
|
|
||||||
finish()
|
|
||||||
} else {
|
|
||||||
binding.indicatorDots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
|
|
||||||
setAnimationListener(object: Animation.AnimationListener {
|
|
||||||
override fun onAnimationEnd(animation: Animation?) {
|
|
||||||
binding.pinLockView.resetPinLockView()
|
|
||||||
binding.pinLockView.isEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAnimationStart(animation: Animation?) {
|
|
||||||
binding.pinLockView.isEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAnimationRepeat(animation: Animation?) {
|
|
||||||
// Do Nothing
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"add_lock" -> {
|
|
||||||
if (lastPass.isEmpty()) {
|
|
||||||
lastPass = it
|
|
||||||
|
|
||||||
binding.pinLockView.resetPinLockView()
|
|
||||||
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
|
|
||||||
} else {
|
|
||||||
if (lastPass == it) {
|
|
||||||
LockManager(context!!).add(Lock.generate(Lock.Type.PIN, it))
|
|
||||||
finish()
|
|
||||||
} else {
|
|
||||||
binding.indicatorDots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
|
|
||||||
setAnimationListener(object: Animation.AnimationListener {
|
|
||||||
override fun onAnimationEnd(animation: Animation?) {
|
|
||||||
binding.pinLockView.resetPinLockView()
|
|
||||||
binding.pinLockView.isEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAnimationStart(animation: Animation?) {
|
|
||||||
binding.pinLockView.isEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAnimationRepeat(animation: Animation?) {
|
|
||||||
// Do Nothing
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
lastPass = ""
|
|
||||||
|
|
||||||
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showBiometricPrompt() {
|
|
||||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
|
||||||
.setTitle(getText(R.string.settings_lock_fingerprint_prompt))
|
|
||||||
.setSubtitle(getText(R.string.settings_lock_fingerprint_prompt_subtitle))
|
|
||||||
.setNegativeButtonText(getText(android.R.string.cancel))
|
|
||||||
.setConfirmationRequired(false)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val biometricPrompt = BiometricPrompt(this, ContextCompat.getMainExecutor(this),
|
|
||||||
object : BiometricPrompt.AuthenticationCallback() {
|
|
||||||
override fun onAuthenticationSucceeded(
|
|
||||||
result: BiometricPrompt.AuthenticationResult) {
|
|
||||||
super.onAuthenticationSucceeded(result)
|
|
||||||
lastUnlocked = System.currentTimeMillis()
|
|
||||||
setResult(RESULT_OK)
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Displays the "log in" prompt.
|
|
||||||
biometricPrompt.authenticate(promptInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
binding = LockActivityBinding.inflate(layoutInflater)
|
|
||||||
setContentView(binding.root)
|
|
||||||
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
binding.fingerprintBtn.apply {
|
|
||||||
isEnabled = true
|
|
||||||
setOnClickListener {
|
|
||||||
showBiometricPrompt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showBiometricPrompt()
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.patternBtn.apply {
|
|
||||||
isEnabled = lockManager.contains(Lock.Type.PATTERN)
|
|
||||||
setOnClickListener {
|
|
||||||
supportFragmentManager.beginTransaction().replace(
|
|
||||||
R.id.lock_content, patternLockFragment
|
|
||||||
).commit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.pinBtn.apply {
|
|
||||||
isEnabled = lockManager.contains(Lock.Type.PIN)
|
|
||||||
setOnClickListener {
|
|
||||||
supportFragmentManager.beginTransaction().replace(
|
|
||||||
R.id.lock_content, pinLockFragment
|
|
||||||
).commit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.passwordBtn.isEnabled = false
|
|
||||||
|
|
||||||
when (lockManager.locks!!.first().type) {
|
|
||||||
Lock.Type.PIN -> {
|
|
||||||
|
|
||||||
supportFragmentManager.beginTransaction().add(
|
|
||||||
R.id.lock_content, pinLockFragment
|
|
||||||
).commit()
|
|
||||||
}
|
|
||||||
Lock.Type.PATTERN -> {
|
|
||||||
supportFragmentManager.beginTransaction().add(
|
|
||||||
R.id.lock_content, patternLockFragment
|
|
||||||
).commit()
|
|
||||||
}
|
|
||||||
else -> return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"add_lock" -> {
|
|
||||||
binding.patternBtn.isEnabled = false
|
|
||||||
binding.pinBtn.isEnabled = false
|
|
||||||
binding.fingerprintBtn.isEnabled = false
|
|
||||||
binding.passwordBtn.isEnabled = false
|
|
||||||
|
|
||||||
when(intent.getStringExtra("type")!!) {
|
|
||||||
"pattern" -> {
|
|
||||||
binding.patternBtn.isEnabled = true
|
|
||||||
supportFragmentManager.beginTransaction().add(
|
|
||||||
R.id.lock_content, patternLockFragment
|
|
||||||
).commit()
|
|
||||||
}
|
|
||||||
"pin" -> {
|
|
||||||
binding.pinBtn.isEnabled = true
|
|
||||||
supportFragmentManager.beginTransaction().add(
|
|
||||||
R.id.lock_content, pinLockFragment
|
|
||||||
).commit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -18,860 +18,52 @@
|
|||||||
|
|
||||||
package xyz.quaver.pupil.ui
|
package xyz.quaver.pupil.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.InputType
|
import androidx.activity.ComponentActivity
|
||||||
import android.text.util.Linkify
|
import androidx.activity.compose.setContent
|
||||||
import android.util.Log
|
import androidx.activity.enableEdgeToEdge
|
||||||
import android.view.KeyEvent
|
import androidx.activity.viewModels
|
||||||
import android.view.MenuItem
|
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||||
import android.view.View
|
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||||
import android.view.animation.DecelerateInterpolator
|
import androidx.compose.runtime.getValue
|
||||||
import android.widget.EditText
|
import androidx.core.view.WindowCompat
|
||||||
import android.widget.TextView
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.core.content.ContextCompat
|
import com.google.accompanist.adaptive.calculateDisplayFeatures
|
||||||
import androidx.core.view.GravityCompat
|
import xyz.quaver.pupil.ui.composable.MainApp
|
||||||
import androidx.core.view.ViewCompat
|
import xyz.quaver.pupil.ui.theme.AppTheme
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import xyz.quaver.pupil.ui.viewmodel.MainViewModel
|
||||||
import androidx.webkit.WebViewCompat
|
|
||||||
import com.google.android.material.navigation.NavigationView
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import xyz.quaver.floatingsearchview.FloatingSearchView
|
|
||||||
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
|
|
||||||
import xyz.quaver.floatingsearchview.util.view.MenuView
|
|
||||||
import xyz.quaver.floatingsearchview.util.view.SearchInputView
|
|
||||||
import xyz.quaver.pupil.hitomi.doSearch
|
|
||||||
import xyz.quaver.pupil.hitomi.getGalleryIDsFromNozomi
|
|
||||||
import xyz.quaver.pupil.hitomi.getSuggestionsForQuery
|
|
||||||
import xyz.quaver.pupil.*
|
|
||||||
import xyz.quaver.pupil.adapters.GalleryBlockAdapter
|
|
||||||
import xyz.quaver.pupil.databinding.MainActivityBinding
|
|
||||||
import xyz.quaver.pupil.services.DownloadService
|
|
||||||
import xyz.quaver.pupil.types.*
|
|
||||||
import xyz.quaver.pupil.ui.dialog.DownloadLocationDialogFragment
|
|
||||||
import xyz.quaver.pupil.ui.dialog.GalleryDialog
|
|
||||||
import xyz.quaver.pupil.ui.view.MainView
|
|
||||||
import xyz.quaver.pupil.ui.view.ProgressCard
|
|
||||||
import xyz.quaver.pupil.util.ItemClickSupport
|
|
||||||
import xyz.quaver.pupil.util.Preferences
|
|
||||||
import xyz.quaver.pupil.util.checkUpdate
|
|
||||||
import xyz.quaver.pupil.util.downloader.Cache
|
|
||||||
import xyz.quaver.pupil.util.downloader.DownloadManager
|
|
||||||
import xyz.quaver.pupil.util.restore
|
|
||||||
import java.util.regex.Pattern
|
|
||||||
import kotlin.math.*
|
|
||||||
|
|
||||||
class MainActivity :
|
|
||||||
BaseActivity(),
|
|
||||||
NavigationView.OnNavigationItemSelectedListener
|
|
||||||
{
|
|
||||||
|
|
||||||
enum class Mode {
|
|
||||||
SEARCH,
|
|
||||||
HISTORY,
|
|
||||||
DOWNLOAD,
|
|
||||||
FAVORITE
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class SortMode {
|
|
||||||
NEWEST,
|
|
||||||
POPULAR
|
|
||||||
}
|
|
||||||
|
|
||||||
private val galleries = ArrayList<Int>()
|
|
||||||
|
|
||||||
private var query = ""
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
with(findViewById<SearchInputView>(R.id.search_bar_text)) {
|
|
||||||
if (text.toString() != value)
|
|
||||||
setText(query, TextView.BufferType.EDITABLE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private var queryStack = mutableListOf<String>()
|
|
||||||
|
|
||||||
private var mode = Mode.SEARCH
|
|
||||||
private var sortMode = SortMode.NEWEST
|
|
||||||
|
|
||||||
private var galleryIDs: Deferred<List<Int>>? = null
|
|
||||||
private var totalItems = 0
|
|
||||||
private var loadingJob: Job? = null
|
|
||||||
private var currentPage = 0
|
|
||||||
|
|
||||||
private lateinit var binding: MainActivityBinding
|
|
||||||
|
|
||||||
private var oldWebViewJob: Job? = null
|
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
binding = MainActivityBinding.inflate(layoutInflater)
|
|
||||||
setContentView(binding.root)
|
|
||||||
|
|
||||||
if (intent.action == Intent.ACTION_VIEW) {
|
val viewModel: MainViewModel by viewModels()
|
||||||
intent.dataString?.let { url ->
|
|
||||||
restore(url,
|
|
||||||
onFailure = {
|
|
||||||
Snackbar.make(binding.contents.recyclerview, R.string.settings_backup_failed, Snackbar.LENGTH_LONG).show()
|
|
||||||
}, onSuccess = {
|
|
||||||
Snackbar.make(binding.contents.recyclerview, getString(R.string.settings_restore_success, it.size), Snackbar.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Preferences["download_folder", ""].isEmpty())
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
DownloadLocationDialogFragment().show(supportFragmentManager, "Download Location Dialog")
|
|
||||||
|
|
||||||
oldWebViewJob = CoroutineScope(Dispatchers.Unconfined).launch {
|
setContent {
|
||||||
do {
|
AppTheme {
|
||||||
delay(1000)
|
val windowSize = calculateWindowSizeClass(this)
|
||||||
if (oldWebView) {
|
val displayFeatures = calculateDisplayFeatures(this)
|
||||||
AlertDialog.Builder(this@MainActivity)
|
|
||||||
.setTitle(android.R.string.dialog_alert_title)
|
val uiState by viewModel.searchState.collectAsStateWithLifecycle()
|
||||||
.setMessage(R.string.old_webview)
|
|
||||||
.setCancelable(false)
|
val navController = rememberNavController()
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
WebViewCompat.getCurrentWebViewPackage(this@MainActivity)?.packageName?.let { packageName ->
|
MainApp(
|
||||||
try {
|
windowSize = windowSize,
|
||||||
startActivity(
|
displayFeatures = displayFeatures,
|
||||||
Intent(
|
uiState = uiState,
|
||||||
Intent.ACTION_VIEW,
|
navController = navController,
|
||||||
Uri.parse("market://details?id=$packageName")
|
openGalleryDetails = viewModel::openGalleryDetails,
|
||||||
)
|
closeGalleryDetails = viewModel::closeGalleryDetails,
|
||||||
)
|
onQueryChange = viewModel::onQueryChange,
|
||||||
} catch (e: ActivityNotFoundException) {
|
loadSearchResult = viewModel::loadSearchResult
|
||||||
startActivity(
|
|
||||||
Intent(
|
|
||||||
Intent.ACTION_VIEW,
|
|
||||||
Uri.parse("https://play.google.com/store/apps/details?id=$packageName")
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.show()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} while (isActive)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Preferences["download_folder_ignore_warning", false] &&
|
|
||||||
ContextCompat.getExternalFilesDirs(this, null).filterNotNull().map { Uri.fromFile(it).toString() }
|
|
||||||
.contains(Preferences["download_folder", ""])
|
|
||||||
) {
|
|
||||||
AlertDialog.Builder(this)
|
|
||||||
.setTitle(R.string.warning)
|
|
||||||
.setMessage(R.string.unaccessible_download_folder)
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
DownloadLocationDialogFragment().show(supportFragmentManager, "Download Location Dialog")
|
|
||||||
}.setNegativeButton(R.string.ignore) { _, _ ->
|
|
||||||
Preferences["download_folder_ignore_warning"] = true
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
initView()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
|
|
||||||
checkUpdate(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalStdlibApi::class)
|
|
||||||
override fun onBackPressed() {
|
|
||||||
when {
|
|
||||||
binding.drawer.isDrawerOpen(GravityCompat.START) -> binding.drawer.closeDrawer(GravityCompat.START)
|
|
||||||
queryStack.removeLastOrNull() != null && queryStack.isNotEmpty() -> runOnUiThread {
|
|
||||||
query = queryStack.last()
|
|
||||||
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
else -> super.onBackPressed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
|
|
||||||
oldWebViewJob?.cancel()
|
|
||||||
|
|
||||||
(binding.contents.recyclerview.adapter as? GalleryBlockAdapter)?.updateAll = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
|
||||||
val perPage = Preferences["per_page", "25"].toInt()
|
|
||||||
val maxPage = ceil(totalItems / perPage.toDouble()).roundToInt()
|
|
||||||
|
|
||||||
return when(keyCode) {
|
|
||||||
KeyEvent.KEYCODE_VOLUME_UP -> {
|
|
||||||
if (currentPage > 0) {
|
|
||||||
runOnUiThread {
|
|
||||||
currentPage--
|
|
||||||
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
|
||||||
if (currentPage < maxPage) {
|
|
||||||
runOnUiThread {
|
|
||||||
currentPage++
|
|
||||||
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
else -> super.onKeyDown(keyCode, event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initView() {
|
|
||||||
binding.contents.recyclerview.addOnScrollListener(object: RecyclerView.OnScrollListener() {
|
|
||||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
||||||
// -height of the search view < translationY < 0
|
|
||||||
binding.contents.searchview.translationY =
|
|
||||||
min(
|
|
||||||
max(
|
|
||||||
binding.contents.searchview.translationY - dy,
|
|
||||||
-binding.contents.searchview.binding.querySection.root.height.toFloat()
|
|
||||||
), 0F)
|
|
||||||
|
|
||||||
if (dy > 0)
|
|
||||||
binding.contents.fab.hideMenuButton(true)
|
|
||||||
else if (dy < 0)
|
|
||||||
binding.contents.fab.showMenuButton(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
Linkify.addLinks(binding.contents.noresult, Pattern.compile(getString(R.string.https_text)), null, null, { _, _ -> getString(R.string.https) })
|
|
||||||
|
|
||||||
//NavigationView
|
|
||||||
binding.navView.setNavigationItemSelectedListener(this)
|
|
||||||
|
|
||||||
with(binding.contents.cancelFab) {
|
|
||||||
setImageResource(R.drawable.cancel)
|
|
||||||
setOnClickListener {
|
|
||||||
DownloadService.cancel(this@MainActivity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.contents.jumpFab) {
|
|
||||||
setImageResource(R.drawable.ic_jump)
|
|
||||||
setOnClickListener {
|
|
||||||
val perPage = Preferences["per_page", "25"].toInt()
|
|
||||||
val editText = EditText(context)
|
|
||||||
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setView(editText)
|
|
||||||
setTitle(R.string.main_jump_title)
|
|
||||||
setMessage(getString(
|
|
||||||
R.string.main_jump_message,
|
|
||||||
currentPage+1,
|
|
||||||
ceil(totalItems / perPage.toDouble()).roundToInt()
|
|
||||||
))
|
|
||||||
|
|
||||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
currentPage = (editText.text.toString().toIntOrNull() ?: return@setPositiveButton)-1
|
|
||||||
|
|
||||||
runOnUiThread {
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.contents.randomFab) {
|
|
||||||
setImageResource(R.drawable.shuffle_variant)
|
|
||||||
setOnClickListener {
|
|
||||||
runBlocking {
|
|
||||||
withTimeoutOrNull(100) {
|
|
||||||
galleryIDs?.await()
|
|
||||||
}
|
|
||||||
}.let {
|
|
||||||
if (it?.isEmpty() == false) {
|
|
||||||
val galleryID = it.random()
|
|
||||||
|
|
||||||
GalleryDialog(this@MainActivity, galleryID).apply {
|
|
||||||
onChipClickedHandler.add {
|
|
||||||
runOnUiThread {
|
|
||||||
query = it.toQuery()
|
|
||||||
currentPage = 0
|
|
||||||
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.contents.idFab) {
|
|
||||||
setImageResource(R.drawable.numeric)
|
|
||||||
setOnClickListener {
|
|
||||||
val editText = EditText(context).apply {
|
|
||||||
inputType = InputType.TYPE_CLASS_NUMBER
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setView(editText)
|
|
||||||
setTitle(R.string.main_open_gallery_by_id)
|
|
||||||
|
|
||||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
val galleryID = editText.text.toString().toIntOrNull() ?: return@setPositiveButton
|
|
||||||
|
|
||||||
GalleryDialog(this@MainActivity, galleryID).apply {
|
|
||||||
onChipClickedHandler.add {
|
|
||||||
runOnUiThread {
|
|
||||||
query = it.toQuery()
|
|
||||||
currentPage = 0
|
|
||||||
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.contents.view) {
|
|
||||||
setOnPageTurnListener(object: MainView.OnPageTurnListener {
|
|
||||||
override fun onPrev(page: Int) {
|
|
||||||
currentPage--
|
|
||||||
|
|
||||||
// disable pageturn until the contents are loaded
|
|
||||||
setCurrentPage(1, false)
|
|
||||||
|
|
||||||
ViewCompat.animate(binding.contents.searchview)
|
|
||||||
.setDuration(100)
|
|
||||||
.setInterpolator(DecelerateInterpolator())
|
|
||||||
.translationY(0F)
|
|
||||||
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNext(page: Int) {
|
|
||||||
currentPage++
|
|
||||||
|
|
||||||
// disable pageturn until the contents are loaded
|
|
||||||
setCurrentPage(1, false)
|
|
||||||
|
|
||||||
ViewCompat.animate(binding.contents.searchview)
|
|
||||||
.setDuration(100)
|
|
||||||
.setInterpolator(DecelerateInterpolator())
|
|
||||||
.translationY(0F)
|
|
||||||
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setupSearchBar()
|
|
||||||
setupRecyclerView()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
|
||||||
private fun setupRecyclerView() {
|
|
||||||
with(binding.contents.recyclerview) {
|
|
||||||
adapter = GalleryBlockAdapter(galleries).apply {
|
|
||||||
onChipClickedHandler.add {
|
|
||||||
runOnUiThread {
|
|
||||||
query = it.toQuery()
|
|
||||||
currentPage = 0
|
|
||||||
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onDownloadClickedHandler = { position ->
|
|
||||||
val galleryID = galleries[position]
|
|
||||||
|
|
||||||
if (DownloadManager.getInstance(context).isDownloading(galleryID)) { //download in progress
|
|
||||||
DownloadService.cancel(this@MainActivity, galleryID)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
DownloadManager.getInstance(context).addDownloadFolder(galleryID)
|
|
||||||
DownloadService.download(this@MainActivity, galleryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
closeAllItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
onDeleteClickedHandler = { position ->
|
|
||||||
val galleryID = galleries[position]
|
|
||||||
DownloadService.delete(this@MainActivity, galleryID)
|
|
||||||
|
|
||||||
histories.remove(galleryID)
|
|
||||||
|
|
||||||
if (this@MainActivity.mode != Mode.SEARCH)
|
|
||||||
runOnUiThread {
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
|
|
||||||
closeAllItems()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ItemClickSupport.addTo(this).apply {
|
|
||||||
onItemClickListener = listener@{ _, position, v ->
|
|
||||||
if (v !is ProgressCard)
|
|
||||||
return@listener
|
|
||||||
|
|
||||||
val intent = Intent(this@MainActivity, ReaderActivity::class.java)
|
|
||||||
intent.putExtra("galleryID", galleries[position])
|
|
||||||
|
|
||||||
//TODO: Maybe sprinkling some transitions will be nice :D
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
onItemLongClickListener = listener@{ _, position, v ->
|
|
||||||
if (v !is ProgressCard)
|
|
||||||
return@listener false
|
|
||||||
|
|
||||||
val galleryID = galleries.getOrNull(position) ?: return@listener true
|
|
||||||
|
|
||||||
GalleryDialog(this@MainActivity, galleryID).apply {
|
|
||||||
onChipClickedHandler.add {
|
|
||||||
runOnUiThread {
|
|
||||||
query = it.toQuery()
|
|
||||||
currentPage = 0
|
|
||||||
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isFavorite = false
|
|
||||||
private val defaultSuggestions: List<SearchSuggestion>
|
|
||||||
get() = when {
|
|
||||||
isFavorite -> {
|
|
||||||
favoriteTags.map {
|
|
||||||
TagSuggestion(it.tag, -1, "", it.area ?: "tag")
|
|
||||||
} + FavoriteHistorySwitch(getString(R.string.search_show_histories))
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
searchHistory.map {
|
|
||||||
Suggestion(it)
|
|
||||||
}.takeLast(10) + FavoriteHistorySwitch(getString(R.string.search_show_tags))
|
|
||||||
}
|
|
||||||
}.reversed()
|
|
||||||
|
|
||||||
private var suggestionJob : Job? = null
|
|
||||||
private fun setupSearchBar() {
|
|
||||||
with(binding.contents.searchview) {
|
|
||||||
onMenuStatusChangeListener = object: FloatingSearchView.OnMenuStatusChangeListener {
|
|
||||||
override fun onMenuOpened() {
|
|
||||||
(this@MainActivity.binding.contents.recyclerview.adapter as GalleryBlockAdapter).closeAllItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMenuClosed() {
|
|
||||||
//Do Nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
findViewById<MenuView>(R.id.menu_view).menuItems.firstOrNull {
|
|
||||||
(it as MenuItem).itemId == R.id.main_menu_thin
|
|
||||||
}?.let {
|
|
||||||
(it as MenuItem).isChecked = Preferences["thin"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onHistoryDeleteClickedListener = {
|
|
||||||
searchHistory.remove(it)
|
|
||||||
swapSuggestions(defaultSuggestions)
|
|
||||||
}
|
|
||||||
onFavoriteHistorySwitchClickListener = {
|
|
||||||
isFavorite = !isFavorite
|
|
||||||
swapSuggestions(defaultSuggestions)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMenuItemClickListener = {
|
|
||||||
onActionMenuItemSelected(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
onQueryChangeListener = lambda@{ _, query ->
|
|
||||||
this@MainActivity.query = query
|
|
||||||
|
|
||||||
suggestionJob?.cancel()
|
|
||||||
|
|
||||||
if (query.isEmpty() or query.endsWith(' ')) {
|
|
||||||
swapSuggestions(defaultSuggestions)
|
|
||||||
|
|
||||||
return@lambda
|
|
||||||
}
|
|
||||||
|
|
||||||
swapSuggestions(listOf(LoadingSuggestion(getText(R.string.reader_loading).toString())))
|
|
||||||
|
|
||||||
val currentQuery = query.split(" ").last()
|
|
||||||
.replace(Regex("^-"), "")
|
|
||||||
.replace('_', ' ')
|
|
||||||
|
|
||||||
suggestionJob = CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val suggestions = kotlin.runCatching {
|
|
||||||
getSuggestionsForQuery(currentQuery).map { TagSuggestion(it) }.toMutableList()
|
|
||||||
}.getOrElse { mutableListOf() }
|
|
||||||
|
|
||||||
suggestions.filter {
|
|
||||||
val tag = "${it.n}:${it.s.replace(Regex("\\s"), "_")}"
|
|
||||||
favoriteTags.contains(Tag.parse(tag))
|
|
||||||
}.reversed().forEach {
|
|
||||||
suggestions.remove(it)
|
|
||||||
suggestions.add(0, it)
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
swapSuggestions(if (suggestions.isNotEmpty()) suggestions else listOf(NoResultSuggestion(getText(R.string.main_no_result).toString())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onFocusChangeListener = object: FloatingSearchView.OnFocusChangeListener {
|
|
||||||
override fun onFocus() {
|
|
||||||
if (query.isEmpty() or query.endsWith(' '))
|
|
||||||
swapSuggestions(defaultSuggestions)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFocusCleared() {
|
|
||||||
suggestionJob?.cancel()
|
|
||||||
|
|
||||||
runOnUiThread {
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
currentPage = 0
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
attachNavigationDrawerToMenuButton(this@MainActivity.binding.drawer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onActionMenuItemSelected(item: MenuItem?) {
|
|
||||||
when(item?.itemId) {
|
|
||||||
R.id.main_menu_settings -> startActivity(Intent(this@MainActivity, SettingsActivity::class.java))
|
|
||||||
R.id.main_menu_thin -> {
|
|
||||||
val thin = !item.isChecked
|
|
||||||
|
|
||||||
item.isChecked = thin
|
|
||||||
binding.contents.recyclerview.apply {
|
|
||||||
(adapter as GalleryBlockAdapter).apply {
|
|
||||||
this.thin = thin
|
|
||||||
|
|
||||||
Preferences["thin"] = thin
|
|
||||||
}
|
|
||||||
|
|
||||||
adapter = adapter // Force to redraw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
R.id.main_menu_sort_newest -> {
|
|
||||||
sortMode = SortMode.NEWEST
|
|
||||||
item.isChecked = true
|
|
||||||
|
|
||||||
runOnUiThread {
|
|
||||||
currentPage = 0
|
|
||||||
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
R.id.main_menu_sort_popular -> {
|
|
||||||
sortMode = SortMode.POPULAR
|
|
||||||
item.isChecked = true
|
|
||||||
|
|
||||||
runOnUiThread {
|
|
||||||
currentPage = 0
|
|
||||||
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNavigationItemSelected(item: MenuItem): Boolean {
|
|
||||||
runOnUiThread {
|
|
||||||
binding.drawer.closeDrawers()
|
|
||||||
|
|
||||||
when(item.itemId) {
|
|
||||||
R.id.main_drawer_home -> {
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
currentPage = 0
|
|
||||||
query = ""
|
|
||||||
queryStack.clear()
|
|
||||||
mode = Mode.SEARCH
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
R.id.main_drawer_history -> {
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
currentPage = 0
|
|
||||||
query = ""
|
|
||||||
queryStack.clear()
|
|
||||||
mode = Mode.HISTORY
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
R.id.main_drawer_downloads -> {
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
currentPage = 0
|
|
||||||
query = ""
|
|
||||||
queryStack.clear()
|
|
||||||
mode = Mode.DOWNLOAD
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
R.id.main_drawer_favorite -> {
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
currentPage = 0
|
|
||||||
query = ""
|
|
||||||
queryStack.clear()
|
|
||||||
mode = Mode.FAVORITE
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
R.id.main_drawer_help -> {
|
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.help))))
|
|
||||||
}
|
|
||||||
R.id.main_drawer_github -> {
|
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.github))))
|
|
||||||
}
|
|
||||||
R.id.main_drawer_homepage -> {
|
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.home_page))))
|
|
||||||
}
|
|
||||||
R.id.main_drawer_email -> {
|
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.email))))
|
|
||||||
}
|
|
||||||
R.id.main_drawer_kakaotalk -> {
|
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.discord))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cancelFetch() {
|
|
||||||
galleryIDs?.cancel()
|
|
||||||
loadingJob?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearGalleries() = CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
galleries.clear()
|
|
||||||
|
|
||||||
with(binding.contents.recyclerview.adapter as GalleryBlockAdapter?) {
|
|
||||||
this ?: return@with
|
|
||||||
|
|
||||||
this.notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.contents.noresult.visibility = View.INVISIBLE
|
|
||||||
binding.contents.progressbar.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fetchGalleries(query: String, sortMode: SortMode) {
|
|
||||||
val defaultQuery: String = Preferences["default_query"]
|
|
||||||
|
|
||||||
if (query.isNotBlank())
|
|
||||||
searchHistory.add(query)
|
|
||||||
|
|
||||||
if (query != queryStack.lastOrNull()) {
|
|
||||||
queryStack.remove(query)
|
|
||||||
queryStack.add(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.isNotEmpty() && mode != Mode.SEARCH) {
|
|
||||||
Snackbar.make(binding.contents.recyclerview, R.string.search_all, Snackbar.LENGTH_SHORT).apply {
|
|
||||||
setAction(android.R.string.ok) {
|
|
||||||
cancelFetch()
|
|
||||||
clearGalleries()
|
|
||||||
currentPage = 0
|
|
||||||
mode = Mode.SEARCH
|
|
||||||
queryStack.clear()
|
|
||||||
fetchGalleries(query, sortMode)
|
|
||||||
loadBlocks()
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryIDs = null
|
|
||||||
|
|
||||||
if (galleryIDs?.isActive == true)
|
|
||||||
return
|
|
||||||
|
|
||||||
galleryIDs = CoroutineScope(Dispatchers.IO).async {
|
|
||||||
when(mode) {
|
|
||||||
Mode.SEARCH -> {
|
|
||||||
when {
|
|
||||||
query.isEmpty() and defaultQuery.isEmpty() -> {
|
|
||||||
when(sortMode) {
|
|
||||||
SortMode.POPULAR -> getGalleryIDsFromNozomi(null, "popular", "all")
|
|
||||||
else -> getGalleryIDsFromNozomi(null, "index", "all")
|
|
||||||
}.also {
|
|
||||||
totalItems = it.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> doSearch("$defaultQuery $query", sortMode == SortMode.POPULAR).also {
|
|
||||||
totalItems = it.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Mode.HISTORY -> {
|
|
||||||
when {
|
|
||||||
query.isEmpty() -> {
|
|
||||||
histories.reversed().also {
|
|
||||||
totalItems = it.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val result = doSearch(query).sorted()
|
|
||||||
histories.reversed().filter { result.binarySearch(it) >= 0 }.also {
|
|
||||||
totalItems = it.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Mode.DOWNLOAD -> {
|
|
||||||
val downloads = DownloadManager.getInstance(this@MainActivity).downloadFolderMap.keys.toList()
|
|
||||||
|
|
||||||
when {
|
|
||||||
query.isEmpty() -> downloads.reversed().also {
|
|
||||||
totalItems = it.size
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val result = doSearch(query).sorted()
|
|
||||||
downloads.reversed().filter { result.binarySearch(it) >= 0 }.also {
|
|
||||||
totalItems = it.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Mode.FAVORITE -> {
|
|
||||||
when {
|
|
||||||
query.isEmpty() -> favorites.reversed().also {
|
|
||||||
totalItems = it.size
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val result = doSearch(query).sorted()
|
|
||||||
favorites.reversed().filter { result.binarySearch(it) >= 0 }.also {
|
|
||||||
totalItems = it.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.toList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadBlocks() {
|
|
||||||
val perPage = Preferences["per_page", "25"].toInt()
|
|
||||||
|
|
||||||
loadingJob = CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val galleryIDs = try {
|
|
||||||
galleryIDs!!.await().also {
|
|
||||||
if (it.isEmpty())
|
|
||||||
throw Exception("No result")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e !is CancellationException)
|
|
||||||
FirebaseCrashlytics.getInstance().recordException(e)
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
binding.contents.noresult.visibility = View.VISIBLE
|
|
||||||
binding.contents.progressbar.hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
binding.contents.view.setCurrentPage(currentPage + 1, galleryIDs.size > (currentPage+1)*perPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryIDs.slice(currentPage*perPage until min(currentPage*perPage+perPage, galleryIDs.size)).chunked(5).let { chunks ->
|
|
||||||
for (chunk in chunks)
|
|
||||||
chunk.map { galleryID ->
|
|
||||||
async {
|
|
||||||
Cache.getInstance(this@MainActivity, galleryID).getGalleryBlock()?.let {
|
|
||||||
galleryID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.forEach {
|
|
||||||
it.await()?.also {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
binding.contents.progressbar.hide()
|
|
||||||
|
|
||||||
galleries.add(it)
|
|
||||||
binding.contents.recyclerview.adapter!!.notifyItemInserted(galleries.size - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,604 +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.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.adapters.ReaderAdapter
|
|
||||||
import xyz.quaver.pupil.databinding.NumberpickerDialogBinding
|
|
||||||
import xyz.quaver.pupil.databinding.ReaderActivityBinding
|
|
||||||
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
|
|
||||||
|
|
||||||
(binding.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
|
|
||||||
|
|
||||||
private lateinit var binding: ReaderActivityBinding
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
binding = ReaderActivityBinding.inflate(layoutInflater)
|
|
||||||
setContentView(binding.root)
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
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 -> {
|
|
||||||
// TODO: Switch to DialogFragment
|
|
||||||
val binding = NumberpickerDialogBinding.inflate(layoutInflater, binding.root, false)
|
|
||||||
|
|
||||||
with(binding.numberPicker) {
|
|
||||||
minValue = 1
|
|
||||||
maxValue = cache.metadata.galleryInfo?.files?.size ?: 0
|
|
||||||
value = currentPage
|
|
||||||
}
|
|
||||||
val dialog = AlertDialog.Builder(this).apply {
|
|
||||||
setView(binding.root)
|
|
||||||
}.create()
|
|
||||||
binding.okButton.setOnClickListener {
|
|
||||||
(this@ReaderActivity.binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(binding.numberPicker.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 -> {
|
|
||||||
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-2, 0)
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
|
||||||
(binding.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(binding.root, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE)
|
|
||||||
.show()
|
|
||||||
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.downloadProgressbar.max = binding.recyclerview.adapter?.itemCount ?: 0
|
|
||||||
binding.downloadProgressbar.progress =
|
|
||||||
downloader.progress[galleryID]?.count { it.isInfinite() } ?: 0
|
|
||||||
|
|
||||||
if (title == getString(R.string.reader_loading)) {
|
|
||||||
val galleryInfo = cache.metadata.galleryInfo
|
|
||||||
|
|
||||||
if (galleryInfo != null) {
|
|
||||||
with(binding.recyclerview.adapter as ReaderAdapter) {
|
|
||||||
this.galleryInfo = galleryInfo
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
title = galleryInfo.title
|
|
||||||
menu?.findItem(R.id.reader_menu_page_indicator)?.title =
|
|
||||||
"$currentPage/${galleryInfo.files.size}"
|
|
||||||
|
|
||||||
menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.hitomi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloader.isCompleted(galleryID)) { //Download finished
|
|
||||||
binding.downloadProgressbar.visibility = View.GONE
|
|
||||||
|
|
||||||
animateDownloadFAB(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initView() {
|
|
||||||
with(binding.recyclerview) {
|
|
||||||
adapter = ReaderAdapter(this@ReaderActivity, galleryID).apply {
|
|
||||||
onItemClickListener = {
|
|
||||||
if (isScroll) {
|
|
||||||
isScroll = false
|
|
||||||
isFullscreen = true
|
|
||||||
|
|
||||||
scrollMode(false)
|
|
||||||
fullscreen(true)
|
|
||||||
} else {
|
|
||||||
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage, 0) //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)
|
|
||||||
binding.fab.showMenuButton(true)
|
|
||||||
else if (dy > 0)
|
|
||||||
binding.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(binding.downloadFab) {
|
|
||||||
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(binding.retryFab) {
|
|
||||||
setImageResource(R.drawable.refresh)
|
|
||||||
setOnClickListener {
|
|
||||||
DownloadService.download(context, galleryID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.autoFab) {
|
|
||||||
setImageResource(R.drawable.eye_white)
|
|
||||||
setOnClickListener {
|
|
||||||
when {
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
|
|
||||||
toggleCamera()
|
|
||||||
}
|
|
||||||
Build.VERSION.SDK_INT >= 23 && shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
|
|
||||||
AlertDialog.Builder(this@ReaderActivity)
|
|
||||||
.setTitle(R.string.warning)
|
|
||||||
.setMessage(R.string.camera_denied)
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->}
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
else ->
|
|
||||||
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.fullscreenFab) {
|
|
||||||
setImageResource(R.drawable.ic_fullscreen)
|
|
||||||
setOnClickListener {
|
|
||||||
isFullscreen = true
|
|
||||||
fullscreen(isFullscreen)
|
|
||||||
|
|
||||||
binding.fab.close(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fullscreen(isFullscreen: Boolean) {
|
|
||||||
with(window.attributes) {
|
|
||||||
if (isFullscreen) {
|
|
||||||
flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN
|
|
||||||
supportActionBar?.hide()
|
|
||||||
binding.fab.visibility = View.INVISIBLE
|
|
||||||
binding.scroller.let {
|
|
||||||
it.handleWidth = resources.getDimensionPixelSize(R.dimen.thumb_height)
|
|
||||||
it.handleHeight = resources.getDimensionPixelSize(R.dimen.thumb_width)
|
|
||||||
it.handleDrawable = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.thumb_horizontal)
|
|
||||||
it.fastScrollDirection = RecyclerViewFastScroller.FastScrollDirection.HORIZONTAL
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
flags = flags and WindowManager.LayoutParams.FLAG_FULLSCREEN.inv()
|
|
||||||
supportActionBar?.show()
|
|
||||||
binding.fab.visibility = View.VISIBLE
|
|
||||||
binding.scroller.let {
|
|
||||||
it.handleWidth = resources.getDimensionPixelSize(R.dimen.thumb_width)
|
|
||||||
it.handleHeight = resources.getDimensionPixelSize(R.dimen.thumb_height)
|
|
||||||
it.handleDrawable = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.thumb)
|
|
||||||
it.fastScrollDirection = RecyclerViewFastScroller.FastScrollDirection.VERTICAL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.attributes = this
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.recyclerview.adapter = binding.recyclerview.adapter // Force to redraw
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scrollMode(isScroll: Boolean) {
|
|
||||||
if (isScroll) {
|
|
||||||
snapHelper.attachToRecyclerView(null)
|
|
||||||
binding.recyclerview.layoutManager = LinearLayoutManager(this)
|
|
||||||
} else {
|
|
||||||
snapHelper.attachToRecyclerView(binding.recyclerview)
|
|
||||||
binding.recyclerview.layoutManager = object: LinearLayoutManager(this, HORIZONTAL, Preferences["rtl", false]) {
|
|
||||||
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
|
|
||||||
extraLayoutSpace.fill(600)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun animateDownloadFAB(animate: Boolean) {
|
|
||||||
with(binding.downloadFab) {
|
|
||||||
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 ->
|
|
||||||
binding.eyeCard.dot.let {
|
|
||||||
it.visibility = View.VISIBLE
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
delay(50)
|
|
||||||
it.visibility = View.INVISIBLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (faces.size != 1)
|
|
||||||
ContextCompat.getDrawable(this, R.drawable.eye_off).let {
|
|
||||||
with(binding.eyeCard) {
|
|
||||||
leftEye.setImageDrawable(it)
|
|
||||||
rightEye.setImageDrawable(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
return@callback
|
|
||||||
}
|
|
||||||
|
|
||||||
val (left, right) = Pair(
|
|
||||||
faces[0].rightEyeOpenProbability?.let { it > 0.4 } == true,
|
|
||||||
faces[0].leftEyeOpenProbability?.let { it > 0.4 } == true
|
|
||||||
)
|
|
||||||
|
|
||||||
with(binding.eyeCard) {
|
|
||||||
leftEye.setImageDrawable(
|
|
||||||
ContextCompat.getDrawable(
|
|
||||||
leftEye.context,
|
|
||||||
if (left) R.drawable.eye else R.drawable.eye_closed
|
|
||||||
)
|
|
||||||
)
|
|
||||||
rightEye.setImageDrawable(
|
|
||||||
ContextCompat.getDrawable(
|
|
||||||
rightEye.context,
|
|
||||||
if (right) R.drawable.eye else R.drawable.eye_closed
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
when {
|
|
||||||
// Both closed / opened
|
|
||||||
!left.xor(right) -> {
|
|
||||||
eyeType = null
|
|
||||||
eyeTime = 0L
|
|
||||||
}
|
|
||||||
!left -> {
|
|
||||||
if (eyeType != Eye.LEFT) {
|
|
||||||
eyeType = Eye.LEFT
|
|
||||||
eyeTime = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
!right -> {
|
|
||||||
if (eyeType != Eye.RIGHT) {
|
|
||||||
eyeType = Eye.RIGHT
|
|
||||||
eyeTime = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eyeType != null && System.currentTimeMillis() - eyeTime > 100) {
|
|
||||||
(binding.recyclerview.layoutManager as LinearLayoutManager).let {
|
|
||||||
it.scrollToPositionWithOffset(when(eyeType!!) {
|
|
||||||
Eye.RIGHT -> {
|
|
||||||
if (it.reverseLayout) currentPage - 2 else currentPage
|
|
||||||
}
|
|
||||||
Eye.LEFT -> {
|
|
||||||
if (it.reverseLayout) currentPage else currentPage - 2
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
eyeTime = System.currentTimeMillis() + 500
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toggleCamera() {
|
|
||||||
val eyes = binding.eyeCard.root
|
|
||||||
when (camera) {
|
|
||||||
null -> {
|
|
||||||
binding.autoFab.labelText = getString(R.string.reader_fab_auto_cancel)
|
|
||||||
binding.autoFab.setImageResource(R.drawable.eye_off_white)
|
|
||||||
eyes.apply {
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
TranslateAnimation(0F, 0F, -100F, 0F).apply {
|
|
||||||
duration = 500
|
|
||||||
fillAfter = false
|
|
||||||
interpolator = OvershootInterpolator()
|
|
||||||
}.let { startAnimation(it) }
|
|
||||||
}
|
|
||||||
startCamera(this, cameraCallback)
|
|
||||||
cameraEnabled = true
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
binding.autoFab.labelText = getString(R.string.reader_fab_auto)
|
|
||||||
binding.autoFab.setImageResource(R.drawable.eye_white)
|
|
||||||
eyes.apply {
|
|
||||||
TranslateAnimation(0F, 0F, 0F, -100F).apply {
|
|
||||||
duration = 500
|
|
||||||
fillAfter = false
|
|
||||||
interpolator = AnticipateInterpolator()
|
|
||||||
setAnimationListener(object: Animation.AnimationListener {
|
|
||||||
override fun onAnimationStart(p0: Animation?) {}
|
|
||||||
override fun onAnimationRepeat(p0: Animation?) {}
|
|
||||||
|
|
||||||
override fun onAnimationEnd(p0: Animation?) {
|
|
||||||
eyes.visibility = View.GONE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}.let { startAnimation(it) }
|
|
||||||
}
|
|
||||||
closeCamera()
|
|
||||||
cameraEnabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
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
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
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
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,171 +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.app.Dialog
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.text.Editable
|
|
||||||
import android.text.TextWatcher
|
|
||||||
import android.widget.ArrayAdapter
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.fragment.app.DialogFragment
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.databinding.DefaultQueryDialogBinding
|
|
||||||
import xyz.quaver.pupil.types.Tags
|
|
||||||
import xyz.quaver.pupil.util.Preferences
|
|
||||||
|
|
||||||
class DefaultQueryDialog : DialogFragment() {
|
|
||||||
|
|
||||||
private val languages: Map<String, String> by lazy {
|
|
||||||
requireContext().resources.getStringArray(R.array.languages).map {
|
|
||||||
it.split("|").let { split ->
|
|
||||||
Pair(split[0], split[1])
|
|
||||||
}
|
|
||||||
}.toMap()
|
|
||||||
}
|
|
||||||
private val reverseLanguages: Map<String, String> by lazy {
|
|
||||||
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
|
|
||||||
|
|
||||||
private var _binding: DefaultQueryDialogBinding? = null
|
|
||||||
private val binding get() = _binding!!
|
|
||||||
|
|
||||||
private fun initView() {
|
|
||||||
val tags = Tags.parse(
|
|
||||||
Preferences["default_query"]
|
|
||||||
)
|
|
||||||
|
|
||||||
with(binding.languageSelector) {
|
|
||||||
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(binding.BLCheckbox) {
|
|
||||||
isChecked = tags.contains(excludeBL)
|
|
||||||
if (tags.contains(excludeBL))
|
|
||||||
tags.remove(excludeBL)
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.guroCheckbox) {
|
|
||||||
isChecked = excludeGuro.all { tags.contains(it) }
|
|
||||||
if (excludeGuro.all { tags.contains(it) })
|
|
||||||
excludeGuro.forEach {
|
|
||||||
tags.remove(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.loliCheckbox) {
|
|
||||||
isChecked = excludeLoli.all { tags.contains(it) }
|
|
||||||
if (excludeLoli.all { tags.contains(it) })
|
|
||||||
excludeLoli.forEach {
|
|
||||||
tags.remove(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.edittext) {
|
|
||||||
setText(tags.toString(), android.widget.TextView.BufferType.EDITABLE)
|
|
||||||
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())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
|
||||||
_binding = DefaultQueryDialogBinding.inflate(layoutInflater)
|
|
||||||
|
|
||||||
initView()
|
|
||||||
|
|
||||||
return AlertDialog.Builder(requireContext()).apply {
|
|
||||||
setTitle(R.string.default_query_dialog_title)
|
|
||||||
setView(binding.root)
|
|
||||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
val newTags = Tags.parse(binding.edittext.text.toString())
|
|
||||||
|
|
||||||
with(binding.languageSelector) {
|
|
||||||
if (selectedItemPosition != 0)
|
|
||||||
newTags.add("language:${reverseLanguages[selectedItem]}")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binding.BLCheckbox.isChecked)
|
|
||||||
newTags.add(excludeBL)
|
|
||||||
|
|
||||||
if (binding.guroCheckbox.isChecked)
|
|
||||||
excludeGuro.forEach { tag ->
|
|
||||||
newTags.add(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binding.loliCheckbox.isChecked)
|
|
||||||
excludeLoli.forEach { tag ->
|
|
||||||
newTags.add(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
onPositiveButtonClickListener?.invoke(newTags)
|
|
||||||
}
|
|
||||||
}.create()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
_binding = null
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pupil, Hitomi.la viewer for Android
|
|
||||||
* Copyright (C) 2020 tom5079
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil.ui.dialog
|
|
||||||
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.core.widget.addTextChangedListener
|
|
||||||
import androidx.fragment.app.DialogFragment
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.databinding.DownloadFolderNameDialogBinding
|
|
||||||
import xyz.quaver.pupil.util.Preferences
|
|
||||||
import xyz.quaver.pupil.util.downloader.Cache
|
|
||||||
import xyz.quaver.pupil.util.formatDownloadFolder
|
|
||||||
import xyz.quaver.pupil.util.formatDownloadFolderTest
|
|
||||||
import xyz.quaver.pupil.util.formatMap
|
|
||||||
|
|
||||||
class DownloadFolderNameDialogFragment : DialogFragment() {
|
|
||||||
|
|
||||||
private var _binding: DownloadFolderNameDialogBinding? = null
|
|
||||||
private val binding get() = _binding!!
|
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
|
||||||
_binding = DownloadFolderNameDialogBinding.inflate(layoutInflater)
|
|
||||||
|
|
||||||
initView()
|
|
||||||
|
|
||||||
return Dialog(requireContext()).apply {
|
|
||||||
setContentView(binding.root)
|
|
||||||
window?.attributes?.width = ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
_binding = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initView() {
|
|
||||||
val galleryID = Cache.instances.let { if (it.size == 0) 1199708 else it.keys.elementAt((0 until it.size).random()) }
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val galleryBlock = Cache.getInstance(requireContext(), galleryID).getGalleryBlock()
|
|
||||||
|
|
||||||
binding.message.text = getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolder() ?: "")
|
|
||||||
binding.edittext.addTextChangedListener {
|
|
||||||
binding.message.text = requireContext().getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolderTest(it.toString()) ?: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.edittext.setText(Preferences["download_folder_name", "[-id-] -title-"])
|
|
||||||
binding.okButton.setOnClickListener {
|
|
||||||
val newValue = binding.edittext.text.toString()
|
|
||||||
|
|
||||||
if ((newValue as? String)?.contains("/") != false) {
|
|
||||||
Snackbar.make(binding.root, R.string.settings_invalid_download_folder_name, Snackbar.LENGTH_SHORT).show()
|
|
||||||
return@setOnClickListener
|
|
||||||
}
|
|
||||||
|
|
||||||
Preferences["download_folder_name"] = binding.edittext.text.toString()
|
|
||||||
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,199 +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.app.Activity
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.fragment.app.DialogFragment
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import net.rdrei.android.dirchooser.DirectoryChooserActivity
|
|
||||||
import net.rdrei.android.dirchooser.DirectoryChooserConfig
|
|
||||||
import xyz.quaver.io.FileX
|
|
||||||
import xyz.quaver.io.util.toFile
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.databinding.DownloadLocationDialogBinding
|
|
||||||
import xyz.quaver.pupil.databinding.DownloadLocationItemBinding
|
|
||||||
import xyz.quaver.pupil.util.Preferences
|
|
||||||
import xyz.quaver.pupil.util.byteToString
|
|
||||||
import xyz.quaver.pupil.util.downloader.DownloadManager
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class DownloadLocationDialogFragment : DialogFragment() {
|
|
||||||
|
|
||||||
private var _binding: DownloadLocationDialogBinding? = null
|
|
||||||
private val binding get() = _binding!!
|
|
||||||
|
|
||||||
private val entries = mutableMapOf<File?, DownloadLocationItemBinding>()
|
|
||||||
|
|
||||||
private val requestDownloadFolderLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
if (it.resultCode == Activity.RESULT_OK) {
|
|
||||||
val context = context ?: return@registerForActivityResult
|
|
||||||
val dialog = dialog ?: return@registerForActivityResult
|
|
||||||
|
|
||||||
it.data?.data?.also { uri ->
|
|
||||||
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
|
|
||||||
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
|
||||||
|
|
||||||
if (kotlin.runCatching { FileX(context, uri).canWrite() }.getOrDefault(false)) {
|
|
||||||
entries[null]?.locationAvailable?.text = uri.toFile(context)?.canonicalPath
|
|
||||||
Preferences["download_folder"] = uri.toString()
|
|
||||||
} else {
|
|
||||||
Snackbar.make(
|
|
||||||
dialog.window!!.decorView.rootView,
|
|
||||||
R.string.settings_download_folder_not_writable,
|
|
||||||
Snackbar.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
|
|
||||||
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath
|
|
||||||
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
|
|
||||||
entries[key]!!.button.isChecked = true
|
|
||||||
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val downloadFolder = DownloadManager.getInstance(context ?: return@registerForActivityResult).downloadFolder.canonicalPath
|
|
||||||
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
|
|
||||||
if (key == null)
|
|
||||||
entries[key]!!.locationAvailable.text = downloadFolder
|
|
||||||
else {
|
|
||||||
entries[null]!!.button.isChecked = false
|
|
||||||
entries[key]!!.button.isChecked = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val requestDownloadFolderOldLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
val context = context ?: return@registerForActivityResult
|
|
||||||
val dialog = dialog ?: return@registerForActivityResult
|
|
||||||
|
|
||||||
if (it.resultCode == DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED) {
|
|
||||||
val directory = it.data?.getStringExtra(DirectoryChooserActivity.RESULT_SELECTED_DIR)!!
|
|
||||||
|
|
||||||
if (!File(directory).canWrite()) {
|
|
||||||
Snackbar.make(
|
|
||||||
dialog.window!!.decorView.rootView,
|
|
||||||
R.string.settings_download_folder_not_writable,
|
|
||||||
Snackbar.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
|
|
||||||
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath
|
|
||||||
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
|
|
||||||
entries[key]!!.button.isChecked = true
|
|
||||||
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
entries[null]?.locationAvailable?.text = directory
|
|
||||||
Preferences["download_folder"] = File(directory).toURI().toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initView() {
|
|
||||||
val externalFilesDirs = ContextCompat.getExternalFilesDirs(requireContext(), null)
|
|
||||||
|
|
||||||
externalFilesDirs.forEachIndexed { index, dir ->
|
|
||||||
dir ?: return@forEachIndexed
|
|
||||||
|
|
||||||
DownloadLocationItemBinding.inflate(layoutInflater, binding.root, true).apply {
|
|
||||||
locationType.text = requireContext().getString(when (index) {
|
|
||||||
0 -> R.string.settings_download_folder_internal
|
|
||||||
else -> R.string.settings_download_folder_removable
|
|
||||||
})
|
|
||||||
locationAvailable.text = requireContext().getString(
|
|
||||||
R.string.settings_download_folder_available,
|
|
||||||
byteToString(dir.freeSpace)
|
|
||||||
)
|
|
||||||
root.setOnClickListener {
|
|
||||||
entries.values.forEach { entry ->
|
|
||||||
entry.button.isChecked = false
|
|
||||||
}
|
|
||||||
button.performClick()
|
|
||||||
Preferences["download_folder"] = dir.toUri().toString()
|
|
||||||
}
|
|
||||||
entries[dir] = this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DownloadLocationItemBinding.inflate(layoutInflater, binding.root, true).apply {
|
|
||||||
locationType.text = requireContext().getString(R.string.settings_download_folder_custom)
|
|
||||||
root.setOnClickListener {
|
|
||||||
entries.values.forEach { entry ->
|
|
||||||
entry.button.isChecked = false
|
|
||||||
}
|
|
||||||
button.performClick()
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
|
||||||
putExtra("android.content.extra.SHOW_ADVANCED", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
requestDownloadFolderLauncher.launch(intent)
|
|
||||||
} else { // Can't use SAF on old Androids!
|
|
||||||
val config = DirectoryChooserConfig.builder()
|
|
||||||
.newDirectoryName("Pupil")
|
|
||||||
.allowNewDirectoryNameModification(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val intent = Intent(context, DirectoryChooserActivity::class.java).apply {
|
|
||||||
putExtra(DirectoryChooserActivity.EXTRA_CONFIG, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
requestDownloadFolderOldLauncher.launch(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entries[null] = this
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadFolder = DownloadManager.getInstance(requireContext()).downloadFolder.canonicalPath
|
|
||||||
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
|
|
||||||
entries[key]!!.button.isChecked = true
|
|
||||||
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
|
||||||
_binding = DownloadLocationDialogBinding.inflate(layoutInflater)
|
|
||||||
|
|
||||||
initView()
|
|
||||||
|
|
||||||
return AlertDialog.Builder(requireContext()).apply {
|
|
||||||
setTitle(R.string.settings_download_folder)
|
|
||||||
setView(binding.root)
|
|
||||||
setPositiveButton(requireContext().getText(android.R.string.ok)) { _, _ ->
|
|
||||||
if (Preferences["download_folder", ""].isEmpty())
|
|
||||||
Preferences["download_folder"] = context.getExternalFilesDir(null)?.toUri()?.toString() ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
isCancelable = false
|
|
||||||
}.create()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
_binding = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,255 +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.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.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import xyz.quaver.pupil.hitomi.Gallery
|
|
||||||
import xyz.quaver.pupil.hitomi.getGallery
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.adapters.GalleryBlockAdapter
|
|
||||||
import xyz.quaver.pupil.adapters.ThumbnailPageAdapter
|
|
||||||
import xyz.quaver.pupil.databinding.*
|
|
||||||
import xyz.quaver.pupil.favoriteTags
|
|
||||||
import xyz.quaver.pupil.types.Tag
|
|
||||||
import xyz.quaver.pupil.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))>()
|
|
||||||
|
|
||||||
private lateinit var binding: GalleryDialogBinding
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
binding = GalleryDialogBinding.inflate(layoutInflater)
|
|
||||||
setContentView(binding.root)
|
|
||||||
|
|
||||||
window?.attributes.apply {
|
|
||||||
this ?: return@apply
|
|
||||||
|
|
||||||
width = LayoutParams.MATCH_PARENT
|
|
||||||
height = LayoutParams.MATCH_PARENT
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.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)
|
|
||||||
|
|
||||||
launch (Dispatchers.Main) {
|
|
||||||
binding.progressbar.visibility = View.GONE
|
|
||||||
binding.title.text = gallery.title
|
|
||||||
binding.artist.text = gallery.artists.joinToString(", ") { it.wordCapitalize() }
|
|
||||||
|
|
||||||
with(binding.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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.cover.showImage(Uri.parse(gallery.cover))
|
|
||||||
|
|
||||||
addDetails(gallery)
|
|
||||||
addThumbnails(gallery)
|
|
||||||
addRelated(gallery)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Snackbar.make(binding.root, R.string.unable_to_connect, Snackbar.LENGTH_INDEFINITE).apply {
|
|
||||||
if (Locale.getDefault().language == "ko")
|
|
||||||
setAction(context.getText(R.string.https_text)) {
|
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(context.getString(R.string.https))))
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addDetails(gallery: Gallery) {
|
|
||||||
GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
|
|
||||||
type.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) ->
|
|
||||||
GalleryDialogTagsBinding.inflate(layoutInflater, contents, true).apply {
|
|
||||||
type.setText(title)
|
|
||||||
|
|
||||||
content.forEach { tag ->
|
|
||||||
tags.addView(
|
|
||||||
TagChip(context, tag).apply {
|
|
||||||
setOnClickListener {
|
|
||||||
onChipClickedHandler.forEach { handler ->
|
|
||||||
handler.invoke(tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addThumbnails(gallery: Gallery) {
|
|
||||||
GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
|
|
||||||
type.setText(R.string.gallery_thumbnails)
|
|
||||||
|
|
||||||
val pager = ViewPager2(context).apply {
|
|
||||||
adapter = ThumbnailPageAdapter(gallery.thumbnails)
|
|
||||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
contents.addView(
|
|
||||||
pager,
|
|
||||||
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO: Change to direct allocation
|
|
||||||
GalleryDialogDotindicatorBinding.inflate(layoutInflater, contents, true).apply {
|
|
||||||
dotindicator.setViewPager2(pager)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addRelated(gallery: Gallery) {
|
|
||||||
val galleries = mutableListOf<Int>()
|
|
||||||
|
|
||||||
val adapter = GalleryBlockAdapter(galleries).apply {
|
|
||||||
onChipClickedHandler.add { tag ->
|
|
||||||
this@GalleryDialog.onChipClickedHandler.forEach { handler ->
|
|
||||||
handler.invoke(tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
|
|
||||||
type.setText(R.string.gallery_related)
|
|
||||||
|
|
||||||
contents.addView(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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
gallery.related.forEach { galleryID ->
|
|
||||||
Cache.getInstance(context, galleryID).getGalleryBlock()?.let {
|
|
||||||
galleries.add(galleryID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
adapter.notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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.app.Dialog
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.AdapterView
|
|
||||||
import android.widget.ArrayAdapter
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.fragment.app.DialogFragment
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.client
|
|
||||||
import xyz.quaver.pupil.clientBuilder
|
|
||||||
import xyz.quaver.pupil.clientHolder
|
|
||||||
import xyz.quaver.pupil.databinding.ProxyDialogBinding
|
|
||||||
import xyz.quaver.pupil.util.Preferences
|
|
||||||
import xyz.quaver.pupil.util.ProxyInfo
|
|
||||||
import xyz.quaver.pupil.util.getProxyInfo
|
|
||||||
import xyz.quaver.pupil.util.proxyInfo
|
|
||||||
import java.net.Proxy
|
|
||||||
|
|
||||||
class ProxyDialogFragment : DialogFragment() {
|
|
||||||
|
|
||||||
private var _binding: ProxyDialogBinding? = null
|
|
||||||
private val binding get() = _binding!!
|
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
|
||||||
_binding = ProxyDialogBinding.inflate(layoutInflater)
|
|
||||||
|
|
||||||
initView()
|
|
||||||
|
|
||||||
return AlertDialog.Builder(requireContext()).apply {
|
|
||||||
setView(binding.root)
|
|
||||||
}.create()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initView() {
|
|
||||||
val proxyInfo = getProxyInfo()
|
|
||||||
|
|
||||||
val enabler = { enable: Boolean ->
|
|
||||||
binding.addr.isEnabled = enable
|
|
||||||
binding.port.isEnabled = enable
|
|
||||||
binding.username.isEnabled = enable
|
|
||||||
binding.password.isEnabled = enable
|
|
||||||
|
|
||||||
if (!enable) {
|
|
||||||
binding.addr.text = null
|
|
||||||
binding.port.text = null
|
|
||||||
binding.username.text = null
|
|
||||||
binding.password.text = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(binding.typeSelector) {
|
|
||||||
adapter = ArrayAdapter(
|
|
||||||
context,
|
|
||||||
android.R.layout.simple_spinner_dropdown_item,
|
|
||||||
context.resources.getStringArray(R.array.proxy_type)
|
|
||||||
)
|
|
||||||
|
|
||||||
setSelection(proxyInfo.type.ordinal)
|
|
||||||
|
|
||||||
onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
|
|
||||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
|
||||||
enabler.invoke(position != 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.addr.setText(proxyInfo.host)
|
|
||||||
binding.port.setText(proxyInfo.port?.toString())
|
|
||||||
binding.username.setText(proxyInfo.username)
|
|
||||||
binding.password.setText(proxyInfo.password)
|
|
||||||
|
|
||||||
enabler.invoke(proxyInfo.type != Proxy.Type.DIRECT)
|
|
||||||
|
|
||||||
binding.cancelButton.setOnClickListener {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.okButton.setOnClickListener {
|
|
||||||
val type = Proxy.Type.values()[binding.typeSelector.selectedItemPosition]
|
|
||||||
val addr = binding.addr.text?.toString()
|
|
||||||
val port = binding.port.text?.toString()?.toIntOrNull()
|
|
||||||
val username = binding.username.text?.toString()
|
|
||||||
val password = binding.password.text?.toString()
|
|
||||||
|
|
||||||
if (type != Proxy.Type.DIRECT) {
|
|
||||||
if (addr == null || addr.isEmpty())
|
|
||||||
binding.addr.error = requireContext().getText(R.string.proxy_dialog_error)
|
|
||||||
if (port == null)
|
|
||||||
binding.port.error = requireContext().getText(R.string.proxy_dialog_error)
|
|
||||||
|
|
||||||
if (addr == null || addr.isEmpty() || port == null)
|
|
||||||
return@setOnClickListener
|
|
||||||
}
|
|
||||||
|
|
||||||
ProxyInfo(type, addr, port, username, password).let {
|
|
||||||
Preferences["proxy"] = Json.encodeToString(it)
|
|
||||||
|
|
||||||
clientBuilder
|
|
||||||
.proxyInfo(it)
|
|
||||||
clientHolder = null
|
|
||||||
client
|
|
||||||
}
|
|
||||||
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,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,201 +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 {
|
|
||||||
when (it) {
|
|
||||||
is FileX -> it.deleteRecursively()
|
|
||||||
else -> it.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
job = launch {
|
|
||||||
var size = 0L
|
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
|
|
||||||
}
|
|
||||||
dir.walk().forEach {
|
|
||||||
size += it.length()
|
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
"clear_history" -> {
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setTitle(R.string.warning)
|
|
||||||
setMessage(R.string.settings_clear_history_alert_message)
|
|
||||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
histories.clear()
|
|
||||||
summary = context.getString(R.string.settings_clear_history_summary, histories.size)
|
|
||||||
}
|
|
||||||
setNegativeButton(android.R.string.cancel) { _, _ -> }
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initPreferences() {
|
|
||||||
val context = context ?: return
|
|
||||||
|
|
||||||
with(findPreference<Preference>("delete_cache")) {
|
|
||||||
this ?: return@with
|
|
||||||
|
|
||||||
val dir = File(context.cacheDir, "imageCache")
|
|
||||||
|
|
||||||
summary = context.getString(R.string.settings_storage_usage, byteToString(0))
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
var size = 0L
|
|
||||||
|
|
||||||
dir.walk().forEach {
|
|
||||||
size += it.length()
|
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onPreferenceClickListener = this@ManageStorageFragment
|
|
||||||
}
|
|
||||||
|
|
||||||
with(findPreference<Preference>("delete_downloads")) {
|
|
||||||
this ?: return@with
|
|
||||||
|
|
||||||
val dir = DownloadManager.getInstance(context).downloadFolder
|
|
||||||
|
|
||||||
summary = context.getString(R.string.settings_storage_usage, byteToString(0))
|
|
||||||
job?.cancel()
|
|
||||||
job = CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
var size = 0L
|
|
||||||
|
|
||||||
dir.walk().forEach {
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
|
|
||||||
}
|
|
||||||
|
|
||||||
size += it.length()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onPreferenceClickListener = this@ManageStorageFragment
|
|
||||||
}
|
|
||||||
|
|
||||||
with(findPreference<Preference>("clear_history")) {
|
|
||||||
this ?: return@with
|
|
||||||
|
|
||||||
summary = context.getString(R.string.settings_clear_history_summary, histories.size)
|
|
||||||
|
|
||||||
onPreferenceClickListener = this@ManageStorageFragment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
job?.cancel()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,57 +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 xyz.quaver.pupil.databinding.PinLockFragmentBinding
|
|
||||||
|
|
||||||
class PINLockFragment : Fragment() {
|
|
||||||
|
|
||||||
private var _binding: PinLockFragmentBinding? = null
|
|
||||||
val binding get() = _binding!!
|
|
||||||
|
|
||||||
var onPINEntered: ((String) -> Unit)? = null
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
|
||||||
_binding = PinLockFragmentBinding.inflate(inflater, container, false)
|
|
||||||
|
|
||||||
binding.pinLockView.attachIndicatorDots(binding.indicatorDots)
|
|
||||||
binding.pinLockView.setPinLockListener(object: PinLockListener {
|
|
||||||
override fun onComplete(p0: String?) {
|
|
||||||
onPINEntered?.invoke(p0 ?: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEmpty() {}
|
|
||||||
override fun onPinChange(p0: Int, p1: String?) {}
|
|
||||||
})
|
|
||||||
|
|
||||||
return binding.root
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
_binding = null
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,61 +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 xyz.quaver.pupil.databinding.PatternLockFragmentBinding
|
|
||||||
|
|
||||||
class PatternLockFragment : Fragment() {
|
|
||||||
|
|
||||||
private var _binding: PatternLockFragmentBinding? = null
|
|
||||||
val binding get() = _binding!!
|
|
||||||
|
|
||||||
var onPatternDrawn: ((String) -> Unit)? = null
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View {
|
|
||||||
_binding = PatternLockFragmentBinding.inflate(inflater, container, false)
|
|
||||||
binding.patternLockView.addPatternLockListener(object: PatternLockViewListener {
|
|
||||||
override fun onComplete(pattern: MutableList<PatternLockView.Dot>?) {
|
|
||||||
val password = PatternLockUtils.patternToMD5(binding.patternLockView, pattern)
|
|
||||||
onPatternDrawn?.invoke(password)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {}
|
|
||||||
override fun onProgress(progressPattern: MutableList<PatternLockView.Dot>?) {}
|
|
||||||
override fun onStarted() {}
|
|
||||||
})
|
|
||||||
return binding.root
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
_binding = null
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,313 +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.widget.Toast
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.preference.*
|
|
||||||
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
|
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.Dispatcher
|
|
||||||
import xyz.quaver.io.FileX
|
|
||||||
import xyz.quaver.io.util.getChild
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.client
|
|
||||||
import xyz.quaver.pupil.clientBuilder
|
|
||||||
import xyz.quaver.pupil.clientHolder
|
|
||||||
import xyz.quaver.pupil.types.SendLogException
|
|
||||||
import xyz.quaver.pupil.ui.LockActivity
|
|
||||||
import xyz.quaver.pupil.ui.SettingsActivity
|
|
||||||
import xyz.quaver.pupil.ui.dialog.*
|
|
||||||
import xyz.quaver.pupil.util.*
|
|
||||||
import xyz.quaver.pupil.util.downloader.DownloadManager
|
|
||||||
import java.util.*
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
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().apply {
|
|
||||||
onPositiveButtonClickListener = { newTags ->
|
|
||||||
Preferences["default_query"] = newTags.toString()
|
|
||||||
summary = newTags.toString()
|
|
||||||
}
|
|
||||||
}.show(parentFragmentManager, "Default Query Dialog")
|
|
||||||
}
|
|
||||||
"app_lock" -> {
|
|
||||||
val intent = Intent(requireContext(), LockActivity::class.java).apply {
|
|
||||||
putExtra("force", true)
|
|
||||||
}
|
|
||||||
lockLauncher.launch(intent)
|
|
||||||
}
|
|
||||||
"proxy" -> {
|
|
||||||
ProxyDialogFragment().show(parentFragmentManager, "Proxy Dialog")
|
|
||||||
}
|
|
||||||
"user_id" -> {
|
|
||||||
FirebaseCrashlytics.getInstance().recordException(SendLogException())
|
|
||||||
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
|
|
||||||
ClipData.newPlainText("user_id", Preferences.get<String>("user_id"))
|
|
||||||
)
|
|
||||||
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean {
|
|
||||||
with (preference) {
|
|
||||||
this ?: return false
|
|
||||||
|
|
||||||
when (key) {
|
|
||||||
"tag_translation" -> {
|
|
||||||
updateTranslations()
|
|
||||||
}
|
|
||||||
"nomedia" -> {
|
|
||||||
val create = (newValue as? Boolean) ?: return false
|
|
||||||
|
|
||||||
return kotlin.runCatching {
|
|
||||||
val nomedia = DownloadManager.getInstance(context).downloadFolder.getChild(".nomedia")
|
|
||||||
|
|
||||||
if (create)
|
|
||||||
nomedia.createNewFile()
|
|
||||||
else
|
|
||||||
nomedia.delete()
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
|
||||||
"dark_mode" -> {
|
|
||||||
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-"]
|
|
||||||
}
|
|
||||||
"max_concurrent_download" -> {
|
|
||||||
val newValue = Preferences.get<String>(key).toIntOrNull() ?: 0
|
|
||||||
|
|
||||||
if (newValue == 0)
|
|
||||||
clientBuilder.dispatcher(Dispatcher())
|
|
||||||
else
|
|
||||||
clientBuilder.dispatcher((Dispatcher(Executors.newFixedThreadPool(newValue))))
|
|
||||||
|
|
||||||
clientHolder = null
|
|
||||||
client
|
|
||||||
}
|
|
||||||
else -> return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
"proxy" -> {
|
|
||||||
summary = getProxyInfo().type.name
|
|
||||||
|
|
||||||
onPreferenceClickListener = this@SettingsFragment
|
|
||||||
}
|
|
||||||
"tag_translation" -> {
|
|
||||||
this as ListPreference
|
|
||||||
|
|
||||||
isEnabled = false
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
kotlin.runCatching {
|
|
||||||
val languages = getAvailableLanguages().distinct().toTypedArray()
|
|
||||||
|
|
||||||
entries = languages.map { Locale(it).let { loc -> loc.getDisplayLanguage(loc) } }.toTypedArray()
|
|
||||||
entryValues = languages
|
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
isEnabled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onPreferenceChangeListener = this@SettingsFragment
|
|
||||||
|
|
||||||
}
|
|
||||||
"dark_mode" -> {
|
|
||||||
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
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
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,216 +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.text.Editable
|
|
||||||
import android.text.TextWatcher
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.inputmethod.EditorInfo
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.content.res.ResourcesCompat
|
|
||||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
|
||||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
|
||||||
import xyz.quaver.floatingsearchview.FloatingSearchView
|
|
||||||
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
|
|
||||||
import xyz.quaver.floatingsearchview.util.view.SearchInputView
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.favoriteTags
|
|
||||||
import xyz.quaver.pupil.types.*
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class FloatingSearchView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
|
|
||||||
FloatingSearchView(context, attrs),
|
|
||||||
FloatingSearchView.OnSearchListener,
|
|
||||||
TextWatcher
|
|
||||||
{
|
|
||||||
private val searchInputView = findViewById<SearchInputView>(R.id.search_bar_text)
|
|
||||||
|
|
||||||
var onHistoryDeleteClickedListener: ((String) -> Unit)? = null
|
|
||||||
var onFavoriteHistorySwitchClickListener: (() -> Unit)? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
searchInputView.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or searchInputView.imeOptions
|
|
||||||
|
|
||||||
searchInputView.addTextChangedListener(this)
|
|
||||||
onSearchListener = this
|
|
||||||
onBindSuggestionCallback = { binding, item, itemPosition ->
|
|
||||||
onBindSuggestion(binding.root, binding.leftIcon, binding.body, item, itemPosition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun afterTextChanged(s: Editable?) {
|
|
||||||
s ?: return
|
|
||||||
|
|
||||||
if (s.any { it.isUpperCase() })
|
|
||||||
s.replace(0, s.length, s.toString().toLowerCase(Locale.getDefault()))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSuggestionClicked(searchSuggestion: SearchSuggestion?) {
|
|
||||||
when (searchSuggestion) {
|
|
||||||
is TagSuggestion -> {
|
|
||||||
val tag = "${searchSuggestion.n}:${searchSuggestion.s.replace(Regex("\\s"), "_")}"
|
|
||||||
with(searchInputView.text!!) {
|
|
||||||
delete(if (lastIndexOf(' ') == -1) 0 else lastIndexOf(' ') + 1, length)
|
|
||||||
|
|
||||||
if (!this.contains(tag))
|
|
||||||
append("$tag ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is Suggestion -> {
|
|
||||||
with(searchInputView.text!!) {
|
|
||||||
clear()
|
|
||||||
append(searchSuggestion.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is FavoriteHistorySwitch -> onFavoriteHistorySwitchClickListener?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSearchAction(currentQuery: String?) {}
|
|
||||||
|
|
||||||
fun onBindSuggestion(
|
|
||||||
suggestionView: View?,
|
|
||||||
leftIcon: ImageView?,
|
|
||||||
textView: TextView?,
|
|
||||||
item: SearchSuggestion?,
|
|
||||||
itemPosition: Int
|
|
||||||
) {
|
|
||||||
when(item) {
|
|
||||||
is TagSuggestion -> {
|
|
||||||
val tag = "${item.n}:${item.s}"
|
|
||||||
|
|
||||||
leftIcon?.setImageDrawable(
|
|
||||||
ResourcesCompat.getDrawable(
|
|
||||||
resources,
|
|
||||||
when(item.n) {
|
|
||||||
"female" -> R.drawable.gender_female
|
|
||||||
"male" -> R.drawable.gender_male
|
|
||||||
"language" -> R.drawable.translate
|
|
||||||
"group" -> R.drawable.account_group
|
|
||||||
"character" -> R.drawable.account_star
|
|
||||||
"series" -> R.drawable.book_open
|
|
||||||
"artist" -> R.drawable.brush
|
|
||||||
else -> R.drawable.tag
|
|
||||||
},
|
|
||||||
context.theme)
|
|
||||||
)
|
|
||||||
|
|
||||||
with(suggestionView?.findViewById<ImageView>(R.id.right_icon)) {
|
|
||||||
this ?: return@with
|
|
||||||
|
|
||||||
if (favoriteTags.contains(Tag.parse(tag)))
|
|
||||||
setImageResource(R.drawable.ic_star_filled)
|
|
||||||
else
|
|
||||||
setImageResource(R.drawable.ic_star_empty)
|
|
||||||
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
rotation = 0f
|
|
||||||
|
|
||||||
isEnabled = true
|
|
||||||
isClickable = true
|
|
||||||
|
|
||||||
setOnClickListener {
|
|
||||||
val tag = Tag.parse(tag)
|
|
||||||
|
|
||||||
if (favoriteTags.contains(tag)) {
|
|
||||||
setImageResource(R.drawable.ic_star_empty)
|
|
||||||
favoriteTags.remove(tag)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
setImageDrawable(
|
|
||||||
AnimatedVectorDrawableCompat.create(context,
|
|
||||||
R.drawable.avd_star
|
|
||||||
))
|
|
||||||
(drawable as Animatable).start()
|
|
||||||
|
|
||||||
favoriteTags.add(tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.t > 0) {
|
|
||||||
(suggestionView as? LinearLayout)?.let {
|
|
||||||
val count = it.findViewById<TextView>(R.id.count)
|
|
||||||
if (count == null)
|
|
||||||
it.addView(
|
|
||||||
LayoutInflater.from(context).inflate(R.layout.suggestion_count, suggestionView, false)
|
|
||||||
.apply {
|
|
||||||
this as TextView
|
|
||||||
|
|
||||||
text = item.t.toString()
|
|
||||||
}, 2
|
|
||||||
)
|
|
||||||
else
|
|
||||||
count.text = item.t.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is FavoriteHistorySwitch -> {
|
|
||||||
leftIcon?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.swap_horizontal, context.theme))
|
|
||||||
}
|
|
||||||
is Suggestion -> {
|
|
||||||
leftIcon?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.history, context.theme))
|
|
||||||
|
|
||||||
with(suggestionView?.findViewById<ImageView>(R.id.right_icon)) {
|
|
||||||
this ?: return@with
|
|
||||||
|
|
||||||
setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.delete, context.theme))
|
|
||||||
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
rotation = 0f
|
|
||||||
|
|
||||||
isEnabled = true
|
|
||||||
isClickable = true
|
|
||||||
|
|
||||||
setOnClickListener {
|
|
||||||
onHistoryDeleteClickedListener?.invoke(item.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is LoadingSuggestion -> {
|
|
||||||
leftIcon?.setImageDrawable(CircularProgressDrawable(context).also {
|
|
||||||
it.setStyle(CircularProgressDrawable.DEFAULT)
|
|
||||||
it.colorFilter = PorterDuffColorFilter(ContextCompat.getColor(context, R.color.colorAccent), PorterDuff.Mode.SRC_IN)
|
|
||||||
it.start()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
is NoResultSuggestion -> {
|
|
||||||
leftIcon?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.close, context.theme))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,462 +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.animation.ValueAnimator;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.res.Configuration;
|
|
||||||
import android.graphics.Canvas;
|
|
||||||
import android.graphics.Paint;
|
|
||||||
import android.graphics.Rect;
|
|
||||||
import android.os.Vibrator;
|
|
||||||
import android.util.AttributeSet;
|
|
||||||
import android.util.DisplayMetrics;
|
|
||||||
import android.view.Gravity;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.view.animation.DecelerateInterpolator;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate;
|
|
||||||
import androidx.appcompat.content.res.AppCompatResources;
|
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
import androidx.core.view.NestedScrollingChild;
|
|
||||||
import androidx.core.view.NestedScrollingChildHelper;
|
|
||||||
import androidx.core.view.NestedScrollingParent;
|
|
||||||
import androidx.core.view.NestedScrollingParentHelper;
|
|
||||||
import androidx.core.view.ViewCompat;
|
|
||||||
import androidx.core.widget.TextViewCompat;
|
|
||||||
|
|
||||||
import xyz.quaver.pupil.R;
|
|
||||||
|
|
||||||
@SuppressWarnings("NullableProblems")
|
|
||||||
public class MainView extends ViewGroup implements NestedScrollingChild, NestedScrollingParent {
|
|
||||||
|
|
||||||
private static final int PAGE_TURN_LAYOUT_SIZE = 48;
|
|
||||||
private static final int PAGE_TURN_ANIM_DURATION = 500;
|
|
||||||
private static final int PREV_OFFSET = 64;
|
|
||||||
private static final int RIPPLE_GIVE = 4;
|
|
||||||
|
|
||||||
private final float adjustedPageTurnLayoutSize;
|
|
||||||
private final float adjustedPrevOffset;
|
|
||||||
private final float adjustedRippleGive;
|
|
||||||
|
|
||||||
final private NestedScrollingParentHelper mNestedScrollingParentHelper;
|
|
||||||
final private NestedScrollingChildHelper mNestedScrollingChildHelper;
|
|
||||||
|
|
||||||
final private Vibrator mVibrator;
|
|
||||||
|
|
||||||
private View mTarget;
|
|
||||||
|
|
||||||
private TextView mPrev;
|
|
||||||
private TextView mNext;
|
|
||||||
|
|
||||||
private final Paint mRipplePaint = new Paint();
|
|
||||||
private final Rect mRippleBound = new Rect();
|
|
||||||
|
|
||||||
private int mRippleSize = 0;
|
|
||||||
private final int mRippleTargetSize;
|
|
||||||
private final ValueAnimator mRippleAnimator = new ValueAnimator();
|
|
||||||
|
|
||||||
private int mCurrentOverScroll = 0;
|
|
||||||
|
|
||||||
private int mCurrentPage = 1;
|
|
||||||
private boolean mShowPrev;
|
|
||||||
private boolean mShowNext;
|
|
||||||
|
|
||||||
private OnPageTurnListener mOnPageTurnListener;
|
|
||||||
|
|
||||||
public MainView(@NonNull Context context) {
|
|
||||||
this(context, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public MainView(@NonNull Context context, AttributeSet attr) {
|
|
||||||
this(context, attr, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public MainView(@NonNull Context context, AttributeSet attr, int defStyle) {
|
|
||||||
super(context, attr, defStyle);
|
|
||||||
|
|
||||||
setWillNotDraw(false);
|
|
||||||
|
|
||||||
DisplayMetrics metrics = getResources().getDisplayMetrics();
|
|
||||||
|
|
||||||
adjustedPageTurnLayoutSize = PAGE_TURN_LAYOUT_SIZE * metrics.density;
|
|
||||||
adjustedPrevOffset = PREV_OFFSET * metrics.density;
|
|
||||||
adjustedRippleGive = RIPPLE_GIVE * metrics.density;
|
|
||||||
|
|
||||||
mRippleTargetSize = metrics.widthPixels;
|
|
||||||
|
|
||||||
mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);
|
|
||||||
mNestedScrollingChildHelper = new NestedScrollingChildHelper(this);
|
|
||||||
|
|
||||||
mVibrator = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE);
|
|
||||||
|
|
||||||
mRippleAnimator.addUpdateListener(animation -> {
|
|
||||||
mRippleSize = (int) animation.getAnimatedValue();
|
|
||||||
invalidate();
|
|
||||||
});
|
|
||||||
mRippleAnimator.setDuration(PAGE_TURN_ANIM_DURATION);
|
|
||||||
|
|
||||||
initPageTurnView();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCurrentPage(int currentPage, boolean showNext) {
|
|
||||||
mCurrentPage = currentPage;
|
|
||||||
|
|
||||||
mShowPrev = currentPage > 1;
|
|
||||||
mShowNext = showNext;
|
|
||||||
|
|
||||||
mPrev.setText(getContext().getString(R.string.main_move_to_page, mCurrentPage-1));
|
|
||||||
mNext.setText(getContext().getString(R.string.main_move_to_page, mCurrentPage+1));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setOnPageTurnListener(OnPageTurnListener listener) {
|
|
||||||
mOnPageTurnListener = listener;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void initPageTurnView() {
|
|
||||||
TextView prev = new TextView(getContext());
|
|
||||||
TextView next = new TextView(getContext());
|
|
||||||
|
|
||||||
prev.setGravity(Gravity.CENTER_VERTICAL);
|
|
||||||
next.setGravity(Gravity.CENTER_VERTICAL);
|
|
||||||
|
|
||||||
prev.setCompoundDrawablesWithIntrinsicBounds(R.drawable.navigate_prev, 0, 0, 0);
|
|
||||||
next.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.navigate_next, 0);
|
|
||||||
|
|
||||||
TextViewCompat.setCompoundDrawableTintList(prev, AppCompatResources.getColorStateList(getContext(), R.color.colorAccent));
|
|
||||||
TextViewCompat.setCompoundDrawableTintList(next, AppCompatResources.getColorStateList(getContext(), R.color.colorAccent));
|
|
||||||
|
|
||||||
prev.setVisibility(View.INVISIBLE);
|
|
||||||
next.setVisibility(View.INVISIBLE);
|
|
||||||
|
|
||||||
mPrev = prev;
|
|
||||||
mNext = next;
|
|
||||||
|
|
||||||
addView(mPrev);
|
|
||||||
addView(mNext);
|
|
||||||
|
|
||||||
setCurrentPage(1, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ensureTarget() {
|
|
||||||
if (mTarget == null) {
|
|
||||||
for (int i = 0; i < getChildCount(); i++) {
|
|
||||||
View child = getChildAt(i);
|
|
||||||
|
|
||||||
if (!child.equals(mNext) && !child.equals(mPrev)) {
|
|
||||||
mTarget = child;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onLayout(boolean changed, int l, int t, int r, int b) {
|
|
||||||
final int width = getMeasuredWidth();
|
|
||||||
final int height = getMeasuredHeight();
|
|
||||||
|
|
||||||
if (getChildCount() == 0)
|
|
||||||
return;
|
|
||||||
if (mTarget == null)
|
|
||||||
ensureTarget();
|
|
||||||
if (mTarget == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
mTarget.layout(
|
|
||||||
getPaddingLeft(),
|
|
||||||
getPaddingTop(),
|
|
||||||
width - getPaddingRight(),
|
|
||||||
height - getPaddingBottom()
|
|
||||||
);
|
|
||||||
|
|
||||||
final int prevWidth = mPrev.getMeasuredWidth();
|
|
||||||
mPrev.layout(
|
|
||||||
width / 2 - prevWidth / 2,
|
|
||||||
getPaddingTop() + (int) adjustedPrevOffset,
|
|
||||||
width / 2 + prevWidth / 2,
|
|
||||||
getPaddingTop() + (int) adjustedPrevOffset + mPrev.getMeasuredHeight()
|
|
||||||
);
|
|
||||||
|
|
||||||
final int nextWidth = mNext.getMeasuredWidth();
|
|
||||||
mNext.layout(
|
|
||||||
width / 2 - nextWidth / 2,
|
|
||||||
height - getPaddingBottom() - mNext.getMeasuredHeight(),
|
|
||||||
width / 2 + nextWidth / 2,
|
|
||||||
height - getPaddingBottom()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
|
||||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
|
||||||
if (mTarget == null)
|
|
||||||
ensureTarget();
|
|
||||||
if (mTarget == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
mTarget.measure(
|
|
||||||
MeasureSpec.makeMeasureSpec(getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY),
|
|
||||||
MeasureSpec.makeMeasureSpec(getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)
|
|
||||||
);
|
|
||||||
|
|
||||||
mPrev.measure(
|
|
||||||
MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.AT_MOST),
|
|
||||||
MeasureSpec.makeMeasureSpec((int) adjustedPageTurnLayoutSize, MeasureSpec.EXACTLY)
|
|
||||||
);
|
|
||||||
|
|
||||||
mNext.measure(
|
|
||||||
MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.AT_MOST),
|
|
||||||
MeasureSpec.makeMeasureSpec((int) adjustedPageTurnLayoutSize, MeasureSpec.EXACTLY)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onDraw(Canvas canvas) {
|
|
||||||
super.onDraw(canvas);
|
|
||||||
|
|
||||||
if (mCurrentOverScroll == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (mCurrentOverScroll > 0) {
|
|
||||||
mRippleBound.set(
|
|
||||||
getPaddingLeft(),
|
|
||||||
(int) (getPaddingTop() - adjustedRippleGive),
|
|
||||||
getMeasuredWidth() - getPaddingRight(),
|
|
||||||
(int) (getPaddingTop() + adjustedPrevOffset + mPrev.getMeasuredHeight() + adjustedRippleGive)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mCurrentOverScroll < 0) {
|
|
||||||
final int height = getMeasuredHeight();
|
|
||||||
mRippleBound.set(
|
|
||||||
getPaddingLeft(),
|
|
||||||
(int) (height - getPaddingBottom() - mNext.getMeasuredHeight() - adjustedRippleGive),
|
|
||||||
getMeasuredWidth() - getPaddingRight(),
|
|
||||||
height - getPaddingBottom()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
mRipplePaint.reset();
|
|
||||||
mRipplePaint.setStyle(Paint.Style.FILL);
|
|
||||||
|
|
||||||
int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
|
|
||||||
|
|
||||||
switch (currentNightMode) {
|
|
||||||
case Configuration.UI_MODE_NIGHT_YES:
|
|
||||||
mRipplePaint.setColor(ContextCompat.getColor(getContext(), R.color.material_light_blue_700));
|
|
||||||
break;
|
|
||||||
case Configuration.UI_MODE_NIGHT_NO:
|
|
||||||
mRipplePaint.setColor(ContextCompat.getColor(getContext(), R.color.material_light_blue_300));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.drawCircle(
|
|
||||||
(mRippleBound.left + mRippleBound.right) / 2F,
|
|
||||||
mCurrentOverScroll > 0 ? mRippleBound.bottom : mRippleBound.top,
|
|
||||||
mRippleSize,
|
|
||||||
mRipplePaint
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onOverscroll(int overscroll) {
|
|
||||||
if (mTarget == null)
|
|
||||||
ensureTarget();
|
|
||||||
if (mTarget == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
mCurrentOverScroll = overscroll;
|
|
||||||
|
|
||||||
if (overscroll > 0) {
|
|
||||||
mPrev.setVisibility(View.VISIBLE);
|
|
||||||
mNext.setVisibility(View.INVISIBLE);
|
|
||||||
} else if (overscroll < 0) {
|
|
||||||
mPrev.setVisibility(View.INVISIBLE);
|
|
||||||
mNext.setVisibility(View.VISIBLE);
|
|
||||||
} else {
|
|
||||||
mPrev.setVisibility(View.INVISIBLE);
|
|
||||||
mNext.setVisibility(View.INVISIBLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.abs(overscroll) >= adjustedPageTurnLayoutSize) {
|
|
||||||
if (!mRippleAnimator.isStarted() && mRippleSize != mRippleTargetSize) {
|
|
||||||
mVibrator.vibrate(10);
|
|
||||||
|
|
||||||
mRippleAnimator.setIntValues(mRippleSize, mRippleTargetSize);
|
|
||||||
mRippleAnimator.start();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!mRippleAnimator.isStarted() && mRippleSize != 0) {
|
|
||||||
mRippleAnimator.setIntValues(mRippleSize, 0);
|
|
||||||
mRippleAnimator.start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
float clippedOverScrollTop = (overscroll > 0 ? 1 : -1) * Math.min(Math.abs(overscroll), adjustedPageTurnLayoutSize);
|
|
||||||
mTarget.setTranslationY(clippedOverScrollTop);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onOverscrollEnd(int overscroll) {
|
|
||||||
if (mTarget == null)
|
|
||||||
ensureTarget();
|
|
||||||
if (mTarget == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
mRippleAnimator.cancel();
|
|
||||||
mRippleAnimator.setIntValues(mRippleSize, 0);
|
|
||||||
mRippleAnimator.start();
|
|
||||||
|
|
||||||
mPrev.setVisibility(View.INVISIBLE);
|
|
||||||
mNext.setVisibility(View.INVISIBLE);
|
|
||||||
|
|
||||||
ViewCompat.animate(mTarget)
|
|
||||||
.setDuration(PAGE_TURN_ANIM_DURATION)
|
|
||||||
.setInterpolator(new DecelerateInterpolator())
|
|
||||||
.translationY(0);
|
|
||||||
|
|
||||||
if (Math.abs(overscroll) > adjustedPageTurnLayoutSize && mOnPageTurnListener != null) {
|
|
||||||
if (overscroll > 0)
|
|
||||||
mOnPageTurnListener.onPrev(mCurrentPage-1);
|
|
||||||
if (overscroll < 0)
|
|
||||||
mOnPageTurnListener.onNext(mCurrentPage+1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NestedScrollingParent
|
|
||||||
|
|
||||||
private int mTotalUnconsumed = 0;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
|
|
||||||
return isEnabled() && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNestedScrollAccepted(View child, View target, int axes) {
|
|
||||||
mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes);
|
|
||||||
startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL);
|
|
||||||
|
|
||||||
mTotalUnconsumed = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
|
|
||||||
if (mTotalUnconsumed != 0 && dy > 0 == mTotalUnconsumed > 0) {
|
|
||||||
if (Math.abs(dy) > Math.abs(mTotalUnconsumed)) {
|
|
||||||
consumed[1] = dy - mTotalUnconsumed;
|
|
||||||
mTotalUnconsumed = 0;
|
|
||||||
} else {
|
|
||||||
mTotalUnconsumed -= dy;
|
|
||||||
consumed[1] = dy;
|
|
||||||
}
|
|
||||||
|
|
||||||
onOverscroll(mTotalUnconsumed);
|
|
||||||
}
|
|
||||||
|
|
||||||
final int[] parentConsumed = new int[2];
|
|
||||||
if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) {
|
|
||||||
consumed[0] += parentConsumed[0];
|
|
||||||
consumed[1] += parentConsumed[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
|
|
||||||
final int[] mParentOffsetInWindow = new int[2];
|
|
||||||
dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, mParentOffsetInWindow);
|
|
||||||
|
|
||||||
final int dy = dyUnconsumed + mParentOffsetInWindow[1];
|
|
||||||
|
|
||||||
if (mTotalUnconsumed == 0 && ((dy < 0 && !mShowPrev) || (dy > 0 && !mShowNext)))
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (dy != 0) {
|
|
||||||
mTotalUnconsumed -= dy;
|
|
||||||
onOverscroll(mTotalUnconsumed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStopNestedScroll(View child) {
|
|
||||||
mNestedScrollingParentHelper.onStopNestedScroll(child);
|
|
||||||
|
|
||||||
if (Math.abs(mTotalUnconsumed) > 0) {
|
|
||||||
onOverscrollEnd(mTotalUnconsumed);
|
|
||||||
mTotalUnconsumed = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
stopNestedScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
// NestedScrollingChild
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setNestedScrollingEnabled(boolean enabled) {
|
|
||||||
mNestedScrollingChildHelper.setNestedScrollingEnabled(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isNestedScrollingEnabled() {
|
|
||||||
return mNestedScrollingChildHelper.isNestedScrollingEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean startNestedScroll(int axes) {
|
|
||||||
return mNestedScrollingChildHelper.startNestedScroll(axes);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void stopNestedScroll() {
|
|
||||||
mNestedScrollingChildHelper.stopNestedScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean hasNestedScrollingParent() {
|
|
||||||
return mNestedScrollingChildHelper.hasNestedScrollingParent();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow) {
|
|
||||||
return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow) {
|
|
||||||
return mNestedScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
|
|
||||||
return mNestedScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
|
|
||||||
return mNestedScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface OnPageTurnListener {
|
|
||||||
void onPrev(int page);
|
|
||||||
void onNext(int page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package xyz.quaver.pupil.ui.view
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.cardview.widget.CardView
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.graphics.drawable.DrawableCompat
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.databinding.ProgressCardViewBinding
|
|
||||||
|
|
||||||
class ProgressCard @JvmOverloads constructor(context: Context, attr: AttributeSet? = null, defStyle: Int = R.attr.cardViewStyle) : CardView(context, attr, defStyle) {
|
|
||||||
|
|
||||||
enum class Type {
|
|
||||||
LOADING,
|
|
||||||
CACHE,
|
|
||||||
DOWNLOAD
|
|
||||||
}
|
|
||||||
|
|
||||||
var type: Type = Type.LOADING
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
|
|
||||||
when (field) {
|
|
||||||
Type.LOADING -> R.color.colorAccent
|
|
||||||
Type.CACHE -> R.color.material_blue_700
|
|
||||||
Type.DOWNLOAD -> R.color.material_green_a700
|
|
||||||
}.let {
|
|
||||||
val color = ContextCompat.getColor(context, it)
|
|
||||||
DrawableCompat.setTint(binding.progressbar.progressDrawable, color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var progress: Int
|
|
||||||
get() = binding.progressbar.progress
|
|
||||||
set(value) {
|
|
||||||
binding.progressbar.progress = value
|
|
||||||
}
|
|
||||||
var max: Int
|
|
||||||
get() = binding.progressbar.max
|
|
||||||
set(value) {
|
|
||||||
binding.progressbar.max = value
|
|
||||||
|
|
||||||
binding.progressbar.visibility =
|
|
||||||
if (value == 0)
|
|
||||||
GONE
|
|
||||||
else
|
|
||||||
VISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
val binding = ProgressCardViewBinding.inflate(LayoutInflater.from(context), this)
|
|
||||||
|
|
||||||
init {
|
|
||||||
binding.content.setOnClickListener {
|
|
||||||
performClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.content.setOnLongClickListener {
|
|
||||||
performLongClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
|
|
||||||
if (childCount == 0)
|
|
||||||
super.addView(child, index, params)
|
|
||||||
else
|
|
||||||
binding.content.addView(child, index, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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,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.content.Context
|
|
||||||
import android.content.res.TypedArray
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.util.Log
|
|
||||||
import com.google.android.material.chip.Chip
|
|
||||||
import com.google.android.material.chip.ChipGroup
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.types.Tag
|
|
||||||
import xyz.quaver.pupil.types.Tags
|
|
||||||
|
|
||||||
class TagChipGroup @JvmOverloads constructor(context: Context, attr: AttributeSet? = null, attrStyle: Int = R.attr.chipGroupStyle, val tags: Tags = Tags()) : ChipGroup(context, attr, attrStyle), MutableSet<Tag> by tags {
|
|
||||||
|
|
||||||
object Defaults {
|
|
||||||
const val maxChipSize = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
var maxChipSize: Int = Defaults.maxChipSize
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val moreView = Chip(context).apply {
|
|
||||||
text = "…"
|
|
||||||
|
|
||||||
setEnsureMinTouchTargetSize(false)
|
|
||||||
|
|
||||||
setOnClickListener {
|
|
||||||
removeView(this)
|
|
||||||
|
|
||||||
for (i in maxChipSize until tags.size) {
|
|
||||||
val tag = tags.elementAt(i)
|
|
||||||
|
|
||||||
addView(TagChip(context, tag).apply {
|
|
||||||
setOnClickListener {
|
|
||||||
onClickListener?.invoke(tag)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var onClickListener: ((Tag) -> Unit)? = null
|
|
||||||
|
|
||||||
private fun applyAttributes(attr: TypedArray) {
|
|
||||||
maxChipSize = attr.getInt(R.styleable.TagChipGroup_maxTag, Defaults.maxChipSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var refreshJob: Job? = null
|
|
||||||
fun refresh() {
|
|
||||||
refreshJob?.cancel()
|
|
||||||
this.removeAllViews()
|
|
||||||
|
|
||||||
refreshJob = CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
tags.take(maxChipSize).map {
|
|
||||||
CoroutineScope(Dispatchers.Default).async {
|
|
||||||
TagChip(context, it).apply {
|
|
||||||
setOnClickListener {
|
|
||||||
onClickListener?.invoke(this.tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.forEach {
|
|
||||||
addView(it.await())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxChipSize > 0 && tags.size > maxChipSize)
|
|
||||||
addView(moreView)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
applyAttributes(context.obtainStyledAttributes(attr, R.styleable.TagChipGroup))
|
|
||||||
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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 com.google.firebase.crashlytics.FirebaseCrashlytics
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.serializer
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class SavedSet <T: Any> (private val file: File, private val any: T, private val set: MutableSet<T> = mutableSetOf()) : MutableSet<T> by set {
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
val serializer: KSerializer<List<T>>
|
|
||||||
get() = ListSerializer(serializer(any::class.java) as KSerializer<T>)
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (!file.exists()) {
|
|
||||||
file.parentFile?.mkdirs()
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun load() {
|
|
||||||
set.clear()
|
|
||||||
kotlin.runCatching {
|
|
||||||
Json.decodeFromString(serializer, file.readText())
|
|
||||||
}.onSuccess {
|
|
||||||
set.addAll(it)
|
|
||||||
}.onFailure {
|
|
||||||
FirebaseCrashlytics.getInstance().recordException(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
fun save() {
|
|
||||||
file.writeText(Json.encodeToString(serializer, set.toList()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun add(element: T): Boolean {
|
|
||||||
set.remove(element)
|
|
||||||
|
|
||||||
return set.add(element).also {
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun addAll(elements: Collection<T>): Boolean {
|
|
||||||
set.removeAll(elements)
|
|
||||||
|
|
||||||
return set.addAll(elements).also {
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun remove(element: T): Boolean {
|
|
||||||
return set.remove(element).also {
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun clear() {
|
|
||||||
set.clear()
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,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,285 +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 android.util.Log
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.Request
|
|
||||||
import xyz.quaver.io.FileX
|
|
||||||
import xyz.quaver.io.util.*
|
|
||||||
import xyz.quaver.pupil.client
|
|
||||||
import xyz.quaver.pupil.hitomi.GalleryBlock
|
|
||||||
import xyz.quaver.pupil.hitomi.GalleryInfo
|
|
||||||
import xyz.quaver.pupil.hitomi.getGalleryBlock
|
|
||||||
import xyz.quaver.pupil.hitomi.getGalleryInfo
|
|
||||||
import xyz.quaver.pupil.userAgent
|
|
||||||
import java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class OldGalleryBlock(
|
|
||||||
val code: String,
|
|
||||||
val id: Int,
|
|
||||||
val galleryUrl: String,
|
|
||||||
val thumbnails: List<String>,
|
|
||||||
val title: String,
|
|
||||||
val artists: List<String>,
|
|
||||||
val series: List<String>,
|
|
||||||
val type: String,
|
|
||||||
val language: String,
|
|
||||||
val relatedTags: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class OldReader(val code: String, val galleryInfo: GalleryInfo)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class OldMetadata(
|
|
||||||
var galleryBlock: OldGalleryBlock? = null,
|
|
||||||
var reader: OldReader? = null,
|
|
||||||
var imageList: MutableList<String?>? = null
|
|
||||||
) {
|
|
||||||
fun copy(): OldMetadata = OldMetadata(galleryBlock, reader, imageList?.let { MutableList(it.size) { i -> it[i] } })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Metadata(
|
|
||||||
var galleryBlock: GalleryBlock? = null,
|
|
||||||
var galleryInfo: GalleryInfo? = null,
|
|
||||||
var imageList: MutableList<String?>? = null
|
|
||||||
) {
|
|
||||||
constructor(old: OldMetadata) : this(old.galleryBlock?.let { galleryBlock -> GalleryBlock(
|
|
||||||
galleryBlock.id,
|
|
||||||
galleryBlock.galleryUrl,
|
|
||||||
galleryBlock.thumbnails,
|
|
||||||
galleryBlock.title,
|
|
||||||
galleryBlock.artists,
|
|
||||||
galleryBlock.series,
|
|
||||||
galleryBlock.type,
|
|
||||||
galleryBlock.language,
|
|
||||||
galleryBlock.relatedTags) },
|
|
||||||
old.reader?.galleryInfo,
|
|
||||||
old.imageList
|
|
||||||
)
|
|
||||||
|
|
||||||
fun copy(): Metadata = Metadata(galleryBlock, galleryInfo, imageList?.let { MutableList(it.size) { i -> it[i] } })
|
|
||||||
}
|
|
||||||
|
|
||||||
class Cache private constructor(context: Context, val galleryID: Int) : ContextWrapper(context) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val instances = ConcurrentHashMap<Int, Cache>()
|
|
||||||
|
|
||||||
fun getInstance(context: Context, galleryID: Int) =
|
|
||||||
instances[galleryID] ?: synchronized(this) {
|
|
||||||
instances[galleryID] ?: Cache(context, galleryID).also { instances.put(galleryID, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun delete(context: Context, galleryID: Int) {
|
|
||||||
File(context.cacheDir, "imageCache/$galleryID").deleteRecursively()
|
|
||||||
instances.remove(galleryID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
cacheFolder.mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata = kotlin.runCatching {
|
|
||||||
findFile(".metadata")?.readText()?.let { metadata ->
|
|
||||||
kotlin.runCatching {
|
|
||||||
Json.decodeFromString<Metadata>(metadata)
|
|
||||||
}.getOrElse {
|
|
||||||
Metadata(Json.decodeFromString<OldMetadata>(metadata))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.getOrNull() ?: Metadata()
|
|
||||||
|
|
||||||
val downloadFolder: FileX?
|
|
||||||
get() = DownloadManager.getInstance(this).getDownloadFolder(galleryID)
|
|
||||||
|
|
||||||
val cacheFolder: FileX
|
|
||||||
get() = FileX(this, cacheDir, "imageCache/$galleryID").also {
|
|
||||||
if (!it.exists())
|
|
||||||
it.mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun findFile(fileName: String): FileX? =
|
|
||||||
downloadFolder?.let { downloadFolder -> downloadFolder.getChild(fileName).let {
|
|
||||||
if (it.exists()) it else null
|
|
||||||
} } ?: cacheFolder.getChild(fileName).let {
|
|
||||||
if (it.exists()) it else null
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
fun setMetadata(change: (Metadata) -> Unit) {
|
|
||||||
change.invoke(metadata)
|
|
||||||
|
|
||||||
val file = cacheFolder.getChild(".metadata")
|
|
||||||
|
|
||||||
kotlin.runCatching {
|
|
||||||
if (!file.exists()) {
|
|
||||||
file.createNewFile()
|
|
||||||
}
|
|
||||||
file.writeText(Json.encodeToString(metadata))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getGalleryBlock(): GalleryBlock? {
|
|
||||||
return metadata.galleryBlock
|
|
||||||
?: withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
getGalleryBlock(galleryID).also {
|
|
||||||
setMetadata { metadata -> metadata.galleryBlock = it }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) { return@withContext null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
suspend fun getThumbnail(): Uri =
|
|
||||||
findFile(".thumbnail")?.uri
|
|
||||||
?: getGalleryBlock()?.thumbnails?.firstOrNull()?.let { withContext(Dispatchers.IO) {
|
|
||||||
kotlin.runCatching {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(it)
|
|
||||||
.header("Referer", "https://hitomi.la/")
|
|
||||||
.build()
|
|
||||||
|
|
||||||
client.newCall(request).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.bytes() }
|
|
||||||
}.getOrNull()?.let { thumbnail -> kotlin.runCatching {
|
|
||||||
cacheFolder.getChild(".thumbnail").also {
|
|
||||||
if (!it.exists())
|
|
||||||
it.createNewFile()
|
|
||||||
|
|
||||||
it.writeBytes(thumbnail)
|
|
||||||
}
|
|
||||||
}.getOrNull()?.uri }
|
|
||||||
} } ?: Uri.EMPTY
|
|
||||||
|
|
||||||
suspend fun getGalleryInfo(): GalleryInfo? {
|
|
||||||
|
|
||||||
return metadata.galleryInfo
|
|
||||||
?: withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
getGalleryInfo(galleryID).also {
|
|
||||||
setMetadata { metadata ->
|
|
||||||
metadata.galleryInfo = it
|
|
||||||
|
|
||||||
if (metadata.imageList == null)
|
|
||||||
metadata.imageList = MutableList(it.files.size) { null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getImage(index: Int): FileX? =
|
|
||||||
metadata.imageList?.getOrNull(index)?.let { findFile(it) }
|
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
fun putImage(index: Int, fileName: String, data: InputStream) {
|
|
||||||
val file = cacheFolder.getChild(fileName)
|
|
||||||
|
|
||||||
if (!file.exists())
|
|
||||||
file.createNewFile()
|
|
||||||
file.outputStream()?.use {
|
|
||||||
data.copyTo(it)
|
|
||||||
}
|
|
||||||
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,128 +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 kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.Call
|
|
||||||
import xyz.quaver.io.FileX
|
|
||||||
import xyz.quaver.io.util.*
|
|
||||||
import xyz.quaver.pupil.client
|
|
||||||
import xyz.quaver.pupil.services.DownloadService
|
|
||||||
import xyz.quaver.pupil.util.Preferences
|
|
||||||
import xyz.quaver.pupil.util.formatDownloadFolder
|
|
||||||
|
|
||||||
class DownloadManager private constructor(context: Context) : ContextWrapper(context) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
@Volatile private var instance: DownloadManager? = null
|
|
||||||
|
|
||||||
fun getInstance(context: Context) =
|
|
||||||
instance ?: synchronized(this) {
|
|
||||||
instance ?: DownloadManager(context).also { instance = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!)
|
|
||||||
|
|
||||||
val downloadFolder: FileX
|
|
||||||
get() = {
|
|
||||||
kotlin.runCatching {
|
|
||||||
FileX(this, Preferences.get<String>("download_folder"))
|
|
||||||
}.getOrElse {
|
|
||||||
Preferences["download_folder"] = defaultDownloadFolder.uri.toString()
|
|
||||||
defaultDownloadFolder
|
|
||||||
}
|
|
||||||
}.invoke()
|
|
||||||
|
|
||||||
private var prevDownloadFolder: FileX? = null
|
|
||||||
private var downloadFolderMapInstance: MutableMap<Int, String>? = null
|
|
||||||
val downloadFolderMap: MutableMap<Int, String>
|
|
||||||
@Synchronized
|
|
||||||
get() {
|
|
||||||
if (prevDownloadFolder != downloadFolder) {
|
|
||||||
prevDownloadFolder = downloadFolder
|
|
||||||
downloadFolderMapInstance = {
|
|
||||||
val file = downloadFolder.getChild(".download")
|
|
||||||
|
|
||||||
val data = if (file.exists())
|
|
||||||
kotlin.runCatching {
|
|
||||||
file.readText()?.let { Json.decodeFromString<MutableMap<Int, String>>(it) }
|
|
||||||
}.onFailure { file.delete() }.getOrNull()
|
|
||||||
else
|
|
||||||
null
|
|
||||||
|
|
||||||
data ?: {
|
|
||||||
file.createNewFile()
|
|
||||||
mutableMapOf<Int, String>()
|
|
||||||
}.invoke()
|
|
||||||
}.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
return downloadFolderMapInstance ?: mutableMapOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun isDownloading(galleryID: Int): Boolean {
|
|
||||||
val isThisGallery: (Call) -> Boolean = { !it.isCanceled && (it.request().tag() as? DownloadService.Tag)?.galleryID == galleryID }
|
|
||||||
|
|
||||||
return downloadFolderMap.containsKey(galleryID)
|
|
||||||
&& client.dispatcher().let { it.queuedCalls().any(isThisGallery) || it.runningCalls().any(isThisGallery) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun getDownloadFolder(galleryID: Int): FileX? =
|
|
||||||
downloadFolderMap[galleryID]?.let { downloadFolder.getChild(it) }
|
|
||||||
|
|
||||||
fun addDownloadFolder(galleryID: Int) = CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val name = Cache.getInstance(this@DownloadManager, galleryID).getGalleryBlock()
|
|
||||||
?.formatDownloadFolder() ?: return@launch
|
|
||||||
|
|
||||||
val folder = downloadFolder.getChild(name)
|
|
||||||
|
|
||||||
if (folder.exists()) return@launch
|
|
||||||
|
|
||||||
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,67 +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
|
|
||||||
|
|
||||||
synchronized(histories) {
|
|
||||||
(histories.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,138 +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 com.google.firebase.crashlytics.FirebaseCrashlytics
|
|
||||||
import kotlinx.serialization.json.*
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import xyz.quaver.pupil.hitomi.GalleryBlock
|
|
||||||
import xyz.quaver.pupil.hitomi.GalleryInfo
|
|
||||||
import xyz.quaver.pupil.hitomi.getReferer
|
|
||||||
import xyz.quaver.pupil.hitomi.imageUrlFromImage
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.collections.ArrayList
|
|
||||||
|
|
||||||
@OptIn(ExperimentalStdlibApi::class)
|
|
||||||
fun String.wordCapitalize() : String {
|
|
||||||
val result = ArrayList<String>()
|
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
for (word in this.split(" "))
|
|
||||||
result.add(word.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() })
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
suspend fun GalleryInfo.getRequestBuilders(): List<Request.Builder> {
|
|
||||||
val galleryID = this.id ?: 0
|
|
||||||
val lowQuality = Preferences["low_quality", true]
|
|
||||||
|
|
||||||
return this.files.map {
|
|
||||||
Request.Builder()
|
|
||||||
.url(
|
|
||||||
runCatching {
|
|
||||||
imageUrlFromImage(galleryID, it, !lowQuality)
|
|
||||||
}
|
|
||||||
.onFailure {
|
|
||||||
FirebaseCrashlytics.getInstance().recordException(it)
|
|
||||||
}
|
|
||||||
.getOrDefault("https://a/")
|
|
||||||
)
|
|
||||||
.header("Referer", "https://hitomi.la/")
|
|
||||||
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun String.ellipsize(n: Int): String =
|
|
||||||
if (this.length > n)
|
|
||||||
this.slice(0 until n) + "…"
|
|
||||||
else
|
|
||||||
this
|
|
||||||
|
|
||||||
operator fun JsonElement.get(index: Int) =
|
|
||||||
this.jsonArray[index]
|
|
||||||
|
|
||||||
operator fun JsonElement.get(tag: String) =
|
|
||||||
this.jsonObject[tag]
|
|
||||||
|
|
||||||
fun JsonElement.getOrNull(tag: String) = kotlin.runCatching {
|
|
||||||
this.jsonObject.getOrDefault(tag, null)
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
val JsonElement.content
|
|
||||||
get() = this.jsonPrimitive.contentOrNull
|
|
||||||
@@ -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,68 +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()
|
|
||||||
kotlin.runCatching {
|
|
||||||
translations = Json.decodeFromString<Map<String, String>>(client.newCall(
|
|
||||||
Request.Builder()
|
|
||||||
.url(contentURL + "${Preferences["tag_translation", ""].let { if (it.isEmpty()) Locale.getDefault().language else it }}.json")
|
|
||||||
.build()
|
|
||||||
).execute().also { if (it.code() != 200) return@launch }.body()?.use { it.string() } ?: return@launch).filterValues { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAvailableLanguages(): List<String> {
|
|
||||||
val languages = Locale.getISOLanguages()
|
|
||||||
|
|
||||||
val json = Json.parseToJsonElement(client.newCall(
|
|
||||||
Request.Builder()
|
|
||||||
.url(filesURL)
|
|
||||||
.build()
|
|
||||||
).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.string() } ?: return emptyList())
|
|
||||||
|
|
||||||
return listOf("en") + (json["tree"]?.jsonArray?.mapNotNull {
|
|
||||||
val name = it["path"]?.jsonPrimitive?.content?.takeWhile { c -> c != '.' }
|
|
||||||
|
|
||||||
languages.firstOrNull { code -> code.equals(name, ignoreCase = true) }
|
|
||||||
} ?: emptyList())
|
|
||||||
}
|
|
||||||
@@ -1,201 +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.app.DownloadManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.webkit.URLUtil
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.json.*
|
|
||||||
import okhttp3.Call
|
|
||||||
import okhttp3.Callback
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import ru.noties.markwon.Markwon
|
|
||||||
import xyz.quaver.pupil.BuildConfig
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.client
|
|
||||||
import xyz.quaver.pupil.favorites
|
|
||||||
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) { _, _ ->
|
|
||||||
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) }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 325 B |
Binary file not shown.
|
Before Width: | Height: | Size: 145 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user