Compare commits

...

62 Commits

Author SHA1 Message Date
tom5079
1bcbc5f42b tag suggestion 2024-11-18 19:58:22 -08:00
tom5079
290b7fb158 transfer wip 2024-06-30 13:40:57 -07:00
tom5079
9f103dcffe Merge pull request #152 from tom5079/master2
Fix ellipsize, WIP Transfer
2024-04-23 11:21:07 -07:00
tom5079
68ec919ae4 fixed downloading galleris with long title, [wip] transfer data 2024-04-17 23:25:36 -07:00
tom5079
b0e194898e wip 2024-04-15 18:19:49 -07:00
tom5079
03b88c5b4b wip 2024-04-11 08:44:56 -07:00
tom5079
a5d4cbfaec fix ellipsize 2024-04-11 08:44:56 -07:00
tom5079
19450f66a0 favorite scroll to top 2024-03-01 21:50:42 -08:00
tom5079
5b36fd9257 add certificate 2024-03-01 11:56:25 -08:00
tom5079
a3158d320b update README.md 2024-02-26 00:13:39 -08:00
tom5079
38494c9fbc add certificate 2024-02-26 00:13:00 -08:00
tom5079
114158cf73 fix backup file selection, support hasha link 2024-01-15 19:01:30 -08:00
tom5079
6d108dd7ff fix backup, notification for android 33+ 2024-01-14 14:30:17 -08:00
tom5079
f36b7f1dbe Update README.md 2022-07-20 09:05:47 -07:00
tom5079
0a22ebd8e9 Merge remote-tracking branch 'origin/master' 2022-07-20 09:04:35 -07:00
tom5079
3682eeaf94 Fix image not retrying 2022-07-20 09:04:23 -07:00
tom5079
7df2ae4ba7 Update README.md 2022-07-19 20:31:42 -07:00
tom5079
c9519ec681 Fix image not retrying 2022-07-19 20:29:39 -07:00
tom5079
b146ed684d Fix app crashing when recovering metadata is corrupt 2022-05-31 08:06:48 +09:00
tom5079
d2787c36d7 Update README.md 2022-04-24 20:39:17 +09:00
tom5079
3ff663114a 5.3.7 2022-04-24 20:39:01 +09:00
tom5079
573e62f310 5.3.7 2022-04-24 20:36:56 +09:00
tom5079
f9af670b82 Update README.md 2022-04-24 20:21:41 +09:00
tom5079
bf461475c6 Merge remote-tracking branch 'origin/master' 2022-04-24 20:20:55 +09:00
tom5079
bdea6e0cc1 Use System.currentTimeMillis() instead of Instant 2022-04-24 20:20:45 +09:00
tom5079
57f0ec4e5d Update README.md 2022-04-24 18:44:10 +09:00
tom5079
d663092363 v5.3.5 2022-04-24 18:27:56 +09:00
tom5079
edf6188e36 Merge pull request #126 from tom5079/Pupil-116
Pupil 116 Favorite tag backup
2022-04-24 18:08:13 +09:00
tom5079
f3f3395e68 Merge pull request #128 from tom5079/Pupil-127
Pupil-127 Use gg.js directly
2022-04-24 18:08:04 +09:00
tom5079
ac9dc347e3 Pupil-127 Use gg.js directly 2022-04-22 16:39:13 +09:00
tom5079
8721d85946 Show ProgressDrawable when backup 2022-04-21 17:41:06 +09:00
tom5079
a0bd1a8738 Pupil-116 Add favorite tags backup 2022-04-21 17:26:58 +09:00
tom5079
35fdf3e3b0 Update PreferenceFragments to comply with updated library 2022-04-21 16:49:13 +09:00
tom5079
aced8293f1 Dependency Update 2022-04-21 16:45:13 +09:00
tom5079
3f516faad8 AGP update 2022-04-21 16:42:34 +09:00
tom5079
824f7b9602 Merge remote-tracking branch 'origin/master' 2022-04-16 06:30:31 +09:00
tom5079
95aeeaa16f updated .gitignore 2022-04-16 06:30:10 +09:00
tom5079
63f08f0230 Fixed Downloaded folder gets deleted when opened with no network 2022-03-25 16:48:38 +09:00
tom5079
3b241fe857 Delete watchdiff.yml 2022-02-02 06:38:50 +09:00
tom5079
75bc104f43 Update watchdiff.yml 2022-02-02 05:57:55 +09:00
tom5079
30afd56324 Update README.md 2022-02-01 19:11:55 +09:00
tom5079
5ee1bb11a0 Merge remote-tracking branch 'origin/master' 2022-02-01 19:11:25 +09:00
tom5079
c1de45abce use webp by default 2022-02-01 19:10:54 +09:00
tom5079
8805033c8d Update README.md 2022-02-01 17:47:46 +09:00
tom5079
0ed59bb8a9 Merge remote-tracking branch 'origin/master' 2022-02-01 17:46:44 +09:00
tom5079
8163f2fd28 Bug fix 2022-02-01 17:46:35 +09:00
tom5079
521a65c9d2 Update README.md 2022-02-01 17:37:50 +09:00
tom5079
eb98424668 Bug fix 2022-02-01 17:36:44 +09:00
tom5079
961c731743 Merge remote-tracking branch 'origin/master' 2022-02-01 11:45:54 +09:00
tom5079
5188769fb6 Fuck hitomi 2022-02-01 11:45:45 +09:00
tom5079
8f27d9e30f Update README.md 2022-02-01 11:45:35 +09:00
tom5079
b58566999e Update README.md 2022-02-01 11:40:23 +09:00
tom5079
117d6dcd2b Fuck hitomi 2022-02-01 11:39:26 +09:00
tom5079
2608796929 Remove dumb code 2022-01-31 13:28:31 +09:00
tom5079
792f5b5a7f Fixed downloading after revisiting cached manga 2022-01-31 13:27:23 +09:00
tom5079
a77b1db749 Merge remote-tracking branch 'origin/master' 2022-01-31 13:18:28 +09:00
tom5079
9d984d92af Fixed downloading after revisiting cached manga 2022-01-31 13:17:44 +09:00
tom5079
e303f25991 Update README.md 2022-01-31 11:10:02 +09:00
tom5079
85973d2305 Merge remote-tracking branch 'origin/master' 2022-01-31 11:05:05 +09:00
tom5079
13f8d7b747 Added download database recovery 2022-01-31 11:04:52 +09:00
tom5079
e198860edb Update README.md 2022-01-31 07:39:58 +09:00
tom5079
fc8355467b Fixed autoupdate for Android 5 and 6 2022-01-31 01:46:22 +09:00
83 changed files with 3116 additions and 1117 deletions

View File

@@ -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
View File

@@ -1,20 +1,33 @@
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
#Github pages
/gh-pages
# Keystore files
*.jks
*.keystore
#Private files
**/google-services.json
**/credentials.json
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof

View File

@@ -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>

View File

@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
</component>
</project>

View File

@@ -1,6 +0,0 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value=" Pupil, Hitomi.la viewer for Android&#10; Copyright (C) &amp;#36;today.year tom5079&#10;&#10; This program is free software: you can redistribute it and/or modify&#10; it under the terms of the GNU General Public License as published by&#10; the Free Software Foundation, either version 3 of the License, or&#10; (at your option) any later version.&#10;&#10; This program is distributed in the hope that it will be useful,&#10; but WITHOUT ANY WARRANTY; without even the implied warranty of&#10; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the&#10; GNU General Public License for more details.&#10;&#10; You should have received a copy of the GNU General Public License&#10; along with this program. If not, see &lt;http://www.gnu.org/licenses/&gt;." />
<option name="myName" value="GPL" />
</copyright>
</component>

View File

@@ -1,7 +0,0 @@
<component name="CopyrightManager">
<settings>
<module2copyright>
<element module="Project Files" copyright="GPL" />
</module2copyright>
</settings>
</component>

View File

@@ -1,7 +0,0 @@
<component name="ProjectDictionaryState">
<dictionary name="tom50">
<words>
<w>hitomi</w>
</words>
</dictionary>
</component>

4
.idea/encodings.xml generated
View File

@@ -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
View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View File

@@ -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>

19
.idea/misc.xml generated
View File

@@ -1,19 +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" />
<entry key="app/src/main/res/xml/lock_preferences.xml" value="0.5119791666666667" />
<entry key="app/src/main/res/xml/root_preferences.xml" value="0.5119791666666667" />
</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>

View File

@@ -1,3 +0,0 @@
<component name="DependencyValidationManager">
<scope name="Pupil" pattern="file[app]:*/" />
</component>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -2,7 +2,7 @@
*Pupil, Hitomi.la viewer for Android*
![](https://img.shields.io/github/downloads/tom5079/Pupil/total)
[![](https://img.shields.io/github/downloads/tom5079/Pupil/5.2.21/Pupil-v5.2.21.apk?color=%234fc3f7&label=DOWNLOAD%20APP&style=for-the-badge)](https://github.com/tom5079/Pupil/releases/download/5.2.21/Pupil-v5.2.21.apk)
[![](https://img.shields.io/github/downloads/tom5079/Pupil/5.3.13/Pupil-v5.3.13.apk?color=%234fc3f7&label=DOWNLOAD%20APP&style=for-the-badge)](https://github.com/tom5079/Pupil/releases/download/5.3.13/Pupil-v5.3.13.apk)
[![](https://discordapp.com/api/guilds/610452916612104194/embed.png?style=banner2)](https://discord.gg/Stj4b5v)
# Features

View File

@@ -32,13 +32,13 @@ configurations {
}
android {
compileSdkVersion 31
defaultConfig {
applicationId "xyz.quaver.pupil"
minSdkVersion 16
targetSdkVersion 31
compileSdk 34
targetSdkVersion 34
versionCode 69
versionName "5.2.22"
versionName "5.3.15"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
@@ -67,43 +67,44 @@ android {
viewBinding true
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
jvmTarget = JavaVersion.VERSION_11.toString()
freeCompilerArgs += "-Xuse-experimental=kotlin.Experimental"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
}
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-coroutines-android:1.7.1"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2"
implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.3.2"
implementation "androidx.core:core-ktx:1.12.0"
implementation "androidx.appcompat:appcompat:1.4.1"
implementation "androidx.activity:activity-ktx:1.4.0"
implementation "androidx.fragment:fragment-ktx:1.4.0"
implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.fragment:fragment-ktx:1.4.1"
implementation "androidx.preference:preference-ktx:1.2.0"
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.constraintlayout:constraintlayout:2.1.3"
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.gridlayout:gridlayout:1.0.0"
implementation "androidx.biometric:biometric:1.1.0"
implementation "androidx.work:work-runtime-ktx:2.7.1"
implementation 'androidx.webkit:webkit:1.4.0'
implementation "com.daimajia.swipelayout:library:1.2.0@aar"
implementation "com.google.android.material:material:1.5.0"
implementation "com.google.android.material:material:1.11.0"
implementation platform('com.google.firebase:firebase-bom:29.0.3')
implementation platform('com.google.firebase:firebase-bom:32.7.0')
implementation "com.google.firebase:firebase-analytics-ktx"
implementation "com.google.firebase:firebase-crashlytics-ktx"
implementation "com.google.firebase:firebase-perf-ktx"
implementation "com.google.android.gms:play-services-oss-licenses:17.0.0"
implementation "com.google.android.gms:play-services-mlkit-face-detection:17.0.0"
implementation "com.google.android.gms:play-services-oss-licenses:17.0.1"
implementation "com.google.android.gms:play-services-mlkit-face-detection:17.1.0"
implementation "com.github.clans:fab:1.6.4"
@@ -116,6 +117,7 @@ dependencies {
//noinspection GradleDependency
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation "io.ktor:ktor-network:2.3.10"
implementation "com.tbuonomo.andrui:viewpagerdotsindicator:4.1.2"
@@ -127,13 +129,15 @@ dependencies {
implementation "ru.noties.markwon:core:3.1.0"
implementation "com.skyfishjy.ripplebackground:library:1.0.1"
implementation "org.jsoup:jsoup:1.14.3"
implementation "xyz.quaver:documentfilex:0.7.2"
implementation "xyz.quaver:floatingsearchview:1.1.7"
testImplementation "junit:junit:4.13.2"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1"
androidTestImplementation "androidx.test.ext:junit:1.1.3"
androidTestImplementation "androidx.test:rules:1.4.0"
androidTestImplementation "androidx.test:runner:1.4.0"

View File

@@ -33,4 +33,5 @@
}
-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.** { *; }
-keep class app.cash.zipline.** { *; }

View File

@@ -12,7 +12,7 @@
"filters": [],
"attributes": [],
"versionCode": 69,
"versionName": "5.2.22",
"versionName": "5.3.14",
"outputFile": "app-release.apk"
}
],

View File

@@ -20,18 +20,18 @@
package xyz.quaver.pupil
import android.os.Build
import android.util.Log
import android.webkit.*
import android.widget.Toast
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.*
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import xyz.quaver.pupil.hitomi.*
import java.util.*
import java.util.concurrent.TimeUnit
/**
* Instrumented test, which will execute on an Android device.
@@ -40,81 +40,144 @@ import xyz.quaver.pupil.hitomi.*
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
// @Before
// fun init() {
// val appContext = InstrumentationRegistry.getInstrumentation().targetContext
// }
@Before
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 {
withContext(Dispatchers.Main) {
initWebView(appContext)
chain.proceed(request)
}
}
}
@Test
fun test_getGalleryIDsFromNozomi() {
runBlocking {
val result = getGalleryIDsFromNozomi(null, "boten", "all")
fun test_empty() {
print(
"".trim()
.replace(Regex("""^\?"""), "")
.lowercase(Locale.getDefault())
.split(Regex("\\s+"))
.map {
it.replace('_', ' ')
})
}
@Test
fun test_nozomi() {
val nozomi = getGalleryIDsFromNozomi(null, "index", "all")
Log.d("PUPILD", "getGalleryIDsFromNozomi: ${result.size}")
}
Log.d("PUPILD", nozomi.size.toString())
}
@Test
fun test_getGalleryIDsForQuery() {
runBlocking {
val result = getGalleryIDsForQuery("female:crotch tattoo")
fun test_search() {
val ids = getGalleryIDsForQuery("language:korean").reversed()
Log.d("PUPILD", "getGalleryIDsForQuery: ${result.size}")
}
print(ids.size)
}
@Test
fun test_getSuggestionsForQuery() {
runBlocking {
val result = getSuggestionsForQuery("fem")
fun test_suggestions() {
val suggestions = getSuggestionsForQuery("language:g")
Log.d("PUPILD", "getSuggestionsForQuery: ${result.size}")
}
print(suggestions)
}
@Test
fun test_urlFromUrlFromHash() {
runBlocking {
val galleryInfo = getGalleryInfo(2102416)
val result = galleryInfo.files.map {
imageUrlFromImage(2102416, it, false)
}
Log.d("PUPILD", result.toString())
fun test_doSearch() {
val r = runBlocking {
doSearch("language:korean")
}
Log.d("PUPILD", r.take(10).toString())
}
// @Test
// fun test_getBlock() {
// val galleryBlock = getGalleryBlock(2097576)
//
// print(galleryBlock)
// }
//
// @Test
// fun test_getGallery() {
// val gallery = getGallery(2097751)
//
// print(gallery)
// }
@Test
fun test_getGalleryInfo() {
runBlocking {
val galleryInfo = getGalleryInfo(2102416)
val info = getGalleryInfo(1469394)
Log.d("PUPILD", galleryInfo.toString())
}
print(info)
}
@Test
fun test_getGallery() {
runBlocking {
val gallery = getGallery(2109479)
fun test_getReader() {
val reader = getGalleryInfo(2128654)
Log.d("PUPILD", gallery.toString())
}
Log.d("PUPILD", reader.toString())
}
@Test
fun test_getGalleryBlock() {
runBlocking {
val block = getGalleryBlock(2119310)
fun test_getImages() { runBlocking {
val galleryID = 2128654
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"))
// }
}

View File

@@ -8,9 +8,17 @@
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="32"
tools:ignore="CoarseFineLocation" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
@@ -45,7 +53,16 @@
</provider>
<service android:name=".services.DownloadService"
android:exported="false"/>
android:exported="false"
android:foregroundServiceType="dataSync" />
<service android:name=".services.TransferClientService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service android:name=".services.TransferServerService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".receiver.UpdateBroadcastReceiver"
@@ -61,165 +78,107 @@
android:configChanges="keyboardHidden|orientation|screenSize"
android:parentActivityName=".ui.MainActivity"
android:exported="true">
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/galleries"
android:scheme="http" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="*.hasha.in"/>
<data android:pathPrefix="/reader"/>
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/manga"
android:scheme="http" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="hitomi.la"/>
<data android:pathPrefix="/galleries"/>
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/doujinshi"
android:scheme="http" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="hitomi.la" />
<data android:pathPrefix="/manga" />
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/cg"
android:scheme="http" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="hitomi.la" />
<data android:pathPrefix="/doujinshi" />
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/reader"
android:scheme="http" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="hitomi.la" />
<data android:pathPrefix="/cg" />
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/galleries"
android:scheme="https" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="hitomi.la" />
<data android:pathPrefix="/imageset" />
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/manga"
android:scheme="https" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="hitomi.la" />
<data android:pathPrefix="/reader" />
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/doujinshi"
android:scheme="https" />
<data android:scheme="http" />
<data android:host="e-hentai.org" />
<data android:pathPrefix="/g" />
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android: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" />
<data android:scheme="https" />
<data android:host="e-hentai.org" />
<data android:pathPrefix="/g" />
</intent-filter>
</activity>
<activity
android:name=".ui.SettingsActivity"
android:label="@string/settings_title">
<tools:validation testUrl="http://ix.io/eer" />
</activity>
<activity
android:name=".ui.MainActivity"
@@ -232,19 +191,9 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="http"
android:host="ix.io"
android:pathPattern="/..*" />
</intent-filter>
</activity>
<activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" />
<activity android:name=".ui.TransferActivity" />
</application>
</manifest>

View File

@@ -18,7 +18,6 @@
package xyz.quaver.pupil
import android.annotation.SuppressLint
import android.app.Application
import android.app.Notification
import android.app.NotificationChannel
@@ -28,34 +27,38 @@ import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
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.FirebaseApp
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.*
import okhttp3.Dispatcher
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import xyz.quaver.io.FileX
import xyz.quaver.pupil.hitomi.evaluationContext
import xyz.quaver.pupil.types.JavascriptException
import xyz.quaver.pupil.hitomi.readText
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.*
import java.io.File
import java.net.URL
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.CertificateFactory
import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import kotlin.reflect.KClass
typealias PupilInterceptor = (Interceptor.Chain) -> Response
@@ -79,157 +82,45 @@ val client: OkHttpClient
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 getSSLContext(context: Context): SSLContext {
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
keyStore.load(null, null)
fun reloadWebView() {
if (reloadJob?.isActive == true) return
val certificateFactory = CertificateFactory.getInstance("X.509")
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 && !oldWebView) ||
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?) {
webView.evaluateJavascript("self_test()") {
val result: String = Json.decodeFromString(it)
oldWebView = result == "es2020_unsupported";
webViewReady = result == "OK";
webViewFailed = result != "OK";
}
}
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")
val certificate = context.resources.openRawResource(R.raw.isrgrootx1).use {
certificateFactory.generateCertificate(it)
}
reloadWhenFailedOrUpdate()
}
keyStore.setCertificateEntry("isrgrootx1", certificate)
lateinit var userAgent: String
val defaultTrustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
defaultTrustManagerFactory.init(null as KeyStore?)
defaultTrustManagerFactory.trustManagers.filterIsInstance(X509TrustManager::class.java).forEach { trustManager ->
trustManager.acceptedIssuers.forEach { acceptedIssuer ->
keyStore.setCertificateEntry(acceptedIssuer.subjectDN.name, acceptedIssuer)
}
}
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(keyStore)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustManagerFactory.trustManagers, SecureRandom())
return sslContext
}
class Pupil : Application() {
companion object {
lateinit var instance: Pupil
private set
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate() {
instance = this
initWebView(this)
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
preferences = PreferenceManager.getDefaultSharedPreferences(this)
@@ -239,17 +130,19 @@ class Pupil : Application() {
else userID
}
FirebaseApp.initializeApp(this)
FirebaseCrashlytics.getInstance().setUserId(userID)
val proxyInfo = getProxyInfo()
clientBuilder = OkHttpClient.Builder()
.connectTimeout(0, TimeUnit.SECONDS)
// .connectTimeout(0, TimeUnit.SECONDS)
.sslSocketFactory(getSSLContext(this).socketFactory)
.readTimeout(0, TimeUnit.SECONDS)
.proxyInfo(proxyInfo)
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.header("User-Agent", userAgent)
.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36")
.header("Referer", "https://hitomi.la/")
.build()
@@ -343,6 +236,13 @@ class Pupil : Application() {
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_SECRET
})
manager.createNotificationChannel(NotificationChannel("transfer", getString(R.string.channel_transfer), NotificationManager.IMPORTANCE_LOW).apply {
description = getString(R.string.channel_transfer_description)
enableLights(false)
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_SECRET
})
}
AppCompatDelegate.setDefaultNightMode(when (Preferences.get<Boolean>("dark_mode")) {

View File

@@ -0,0 +1,44 @@
package xyz.quaver.pupil.adapters
import android.net.wifi.p2p.WifiP2pDevice
import android.net.wifi.p2p.WifiP2pDeviceList
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import androidx.recyclerview.widget.RecyclerView
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.TransferPeerListItemBinding
class TransferPeersAdapter(
private val devices: Collection<WifiP2pDevice>,
private val onDeviceSelected: (WifiP2pDevice) -> Unit
): RecyclerView.Adapter<TransferPeersAdapter.ViewHolder>() {
class ViewHolder(val binding: TransferPeerListItemBinding): RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = TransferPeerListItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val device = devices.elementAt(position)
holder.binding.deviceName.text = device.deviceName
holder.binding.deviceAddress.text = device.deviceAddress
holder.binding.root.setOnClickListener {
onDeviceSelected(device)
}
}
override fun getItemCount(): Int {
return devices.size
}
}

View File

@@ -16,31 +16,86 @@
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.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Clock.System.now
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import xyz.quaver.pupil.*
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import xyz.quaver.pupil.client
import java.io.IOException
import java.net.URL
import java.util.concurrent.Executors
import kotlin.coroutines.resumeWithException
import kotlin.time.Duration.Companion.minutes
import kotlin.time.ExperimentalTime
const val protocol = "https:"
val evaluationContext = Dispatchers.Main + Job()
@Serializable
data class Artist(
val artist: String,
val url: String
)
@Serializable
data class Group(
val group: String,
val url: String
)
@Serializable
data class Parody(
val parody: String,
val url: String
)
@Serializable
data class Character(
val character: String,
val url: String
)
@Serializable
data class Tag(
val tag: String,
val url: String,
val female: String? = null,
val male: String? = null
)
@Serializable
data class Language(
val galleryid: String,
val url: String,
val language_localname: String,
val name: String
)
@Serializable
data class GalleryInfo(
val id: String,
val title: String,
val japanese_title: String? = null,
val language: String? = null,
val type: String,
val date: String,
val artists: List<Artist>? = null,
val groups: List<Group>? = null,
val parodys: List<Parody>? = null,
val tags: List<Tag>? = null,
val related: List<Int> = emptyList(),
val languages: List<Language> = emptyList(),
val characters: List<Character>? = null,
val scene_indexes: List<Int>? = emptyList(),
val files: List<GalleryFiles> = emptyList()
)
/**
* 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
@@ -48,99 +103,171 @@ val json = Json {
useArrayPolymorphism = true
}
suspend inline fun <reified T> WebView.evaluate(script: String): T = coroutineScope {
var result: String? = null
typealias HeaderSetter = (Request.Builder) -> Request.Builder
fun URL.readText(settings: HeaderSetter? = null): String {
val request = Request.Builder()
.url(this).let {
settings?.invoke(it) ?: it
}.build()
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)
return client.newCall(request).execute().also{ if (it.code() != 200) throw IOException("CODE ${it.code()}") }.body()?.use { it.string() } ?: throw IOException()
}
@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
fun URL.readBytes(settings: HeaderSetter? = null): ByteArray {
val request = Request.Builder()
.url(this).let {
settings?.invoke(it) ?: it
}.build()
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)
return client.newCall(request).execute().also { if (it.code() != 200) throw IOException("CODE ${it.code()}") }.body()?.use { it.bytes() } ?: throw IOException()
}
@Suppress("EXPERIMENTAL_API_USAGE")
suspend fun getGalleryInfo(galleryID: Int): GalleryInfo =
webView.evaluatePromise("get_gallery_info($galleryID)")
fun getGalleryInfo(galleryID: Int) =
json.decodeFromString<GalleryInfo>(
URL("$protocol//$domain/galleries/$galleryID.js").readText()
.replace("var galleryinfo = ", "")
)
//common.js
const val domain = "ltn.hitomi.la"
const val galleryblockextension = ".html"
const val galleryblockdir = "galleryblock"
const val nozomiextension = ".nozomi"
val String?.js: String
get() = if (this == null) "null" else "'$this'"
val evaluationContext = Dispatchers.Main + Job()
@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()
)
object gg {
private var lastRetrieval: Long? = null
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)
private val mutex = Mutex()
private var mDefault = 0
private val mMap = mutableMapOf<Int, Int>()
private var b = ""
@OptIn(ExperimentalTime::class, ExperimentalCoroutinesApi::class)
private suspend fun refresh() = withContext(Dispatchers.IO) {
mutex.withLock {
if (lastRetrieval == null || (lastRetrieval!! + 60000) < System.currentTimeMillis()) {
val ggjs: String = suspendCancellableCoroutine { continuation ->
val call = client.newCall(Request.Builder().url("https://ltn.hitomi.la/gg.js").build())
call.enqueue(object: Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isCancelled) return
continuation.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
if (!call.isCanceled) {
response.body()?.use {
continuation.resume(it.string()) {
call.cancel()
}
}
}
}
})
continuation.invokeOnCancellation {
call.cancel()
}
}
mDefault = Regex("var o = (\\d)").find(ggjs)!!.groupValues[1].toInt()
val o = Regex("o = (\\d); break;").find(ggjs)!!.groupValues[1].toInt()
mMap.clear()
Regex("case (\\d+):").findAll(ggjs).forEach {
val case = it.groupValues[1].toInt()
mMap[case] = o
}
b = Regex("b: '(.+)'").find(ggjs)!!.groupValues[1]
lastRetrieval = System.currentTimeMillis()
}
}
}
suspend fun m(g: Int): Int {
refresh()
return mMap[g] ?: mDefault
}
suspend fun b(): String {
refresh()
return b
}
fun s(h: String): String {
val m = Regex("(..)(.)$").find(h)
return m!!.groupValues.let { it[2]+it[1] }.toInt(16).toString(10)
}
}
suspend fun subdomainFromURL(url: String, base: String? = null) : String {
var retval = "b"
if (!base.isNullOrBlank())
retval = base
val b = 16
val r = Regex("""/[0-9a-f]{61}([0-9a-f]{2})([0-9a-f])""")
val m = r.find(url) ?: return "a"
val g = m.groupValues.let { it[2]+it[1] }.toIntOrNull(b)
if (g != null) {
retval = (97+ gg.m(g)).toChar().toString() + retval
}
return retval
}
suspend fun urlFromUrl(url: String, base: String? = null) : String {
return url.replace(Regex("""//..?\.hitomi\.la/"""), "//${subdomainFromURL(url, base)}.hitomi.la/")
}
suspend fun fullPathFromHash(hash: String) : String =
"${gg.b()}${gg.s(hash)}/$hash"
fun realFullPathFromHash(hash: String): String =
hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1/$hash")
suspend fun urlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null) : String {
val ext = ext ?: dir ?: image.name.takeLastWhile { it != '.' }
val dir = dir ?: "images"
return "https://a.hitomi.la/$dir/${fullPathFromHash(image.hash)}.$ext"
}
suspend fun urlFromUrlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null, base: String? = null) =
if (base == "tn")
urlFromUrl("https://a.hitomi.la/$dir/${realFullPathFromHash(image.hash)}.$ext", base)
else
urlFromUrl(urlFromHash(galleryID, image, dir, ext), base)
suspend fun rewriteTnPaths(html: String) {
html.replace(Regex("""//tn\.hitomi\.la/[^/]+/[0-9a-f]/[0-9a-f]{2}/[0-9a-f]{64}""")) { url ->
runBlocking {
urlFromUrl(url.value, "tn")
}
}
}
suspend fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean) : String {
return urlFromUrlFromHash(galleryID, image, "webp", null, "a")
// return when {
// noWebp ->
// urlFromUrlFromHash(galleryID, image)
//// image.hasavif != 0 ->
//// urlFromUrlFromHash(galleryID, image, "avif", null, "a")
// image.haswebp != 0 ->
// urlFromUrlFromHash(galleryID, image, "webp", null, "a")
// else ->
// urlFromUrlFromHash(galleryID, image)
// }
}

View File

@@ -17,14 +17,11 @@
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 langList: List<Pair<String, String>>,
val cover: String,
val title: String,
val artists: List<String>,
@@ -36,5 +33,22 @@ data class Gallery(
val tags: List<String>,
val thumbnails: List<String>
)
suspend fun getGallery(galleryID: Int) : Gallery =
webView.evaluatePromise("get_gallery($galleryID)")
suspend fun getGallery(galleryID: Int) : Gallery {
val info = getGalleryInfo(galleryID)
return Gallery(
info.related,
info.languages.map { it.name to it.galleryid },
urlFromUrlFromHash(galleryID, info.files.first(), "webpbigtn", "webp", "tn"),
info.title,
info.artists?.map { it.artist }.orEmpty(),
info.groups?.map { it.group }.orEmpty(),
info.type,
info.language.orEmpty(),
info.parodys?.map { it.parody }.orEmpty(),
info.characters?.map { it.character }.orEmpty(),
info.tags?.map { "${if (it.female.isNullOrEmpty()) "" else "female:"}${if (it.male.isNullOrEmpty()) "" else "male:"}${it.tag}" }.orEmpty(),
info.files.map { urlFromUrlFromHash(galleryID, it, "webpsmalltn", "webp", "tn") }
)
}

View File

@@ -17,7 +17,48 @@
package xyz.quaver.pupil.hitomi
import kotlinx.serialization.Serializable
import xyz.quaver.pupil.webView
import org.jsoup.Jsoup
import java.net.URL
import java.net.URLDecoder
import java.nio.ByteBuffer
import java.nio.ByteOrder
import javax.net.ssl.HttpsURLConnection
import kotlin.io.readText
//galleryblock.js
fun fetchNozomi(area: String? = null, tag: String = "index", language: String = "all", start: Int = -1, count: Int = -1) : Pair<List<Int>, Int> {
val url = when(area) {
null -> "$protocol//$domain/$tag-$language$nozomiextension"
else -> "$protocol//$domain/$area/$tag-$language$nozomiextension"
}
with(URL(url).openConnection() as HttpsURLConnection) {
requestMethod = "GET"
if (start != -1 && count != -1) {
val startByte = start*4
val endByte = (start+count)*4-1
setRequestProperty("Range", "bytes=$startByte-$endByte")
}
connect()
val totalItems = getHeaderField("Content-Range")
.replace(Regex("^[Bb]ytes \\d+-\\d+/"), "").toInt() / 4
val nozomi = ArrayList<Int>()
val arrayBuffer = ByteBuffer
.wrap(inputStream.readBytes())
.order(ByteOrder.BIG_ENDIAN)
while (arrayBuffer.hasRemaining())
nozomi.add(arrayBuffer.int)
return Pair(nozomi, totalItems)
}
}
@Serializable
data class GalleryBlock(
@@ -28,9 +69,24 @@ data class GalleryBlock(
val artists: List<String>,
val series: List<String>,
val type: String,
val language: String?,
val relatedTags: List<String>
val language: String,
val relatedTags: List<String>,
val groups: List<String> = emptyList()
)
suspend fun getGalleryBlock(galleryID: Int) : GalleryBlock =
webView.evaluatePromise("get_gallery_block($galleryID)")
suspend fun getGalleryBlock(galleryID: Int) : GalleryBlock {
val info = getGalleryInfo(galleryID)
return GalleryBlock(
galleryID,
"",
listOf(urlFromUrlFromHash(galleryID, info.files.first(), "webpbigtn", "webp", "tn")),
info.title,
info.artists?.map { it.artist }.orEmpty(),
info.parodys?.map { it.parody }.orEmpty(),
info.type,
info.language.orEmpty(),
info.tags?.map { "${if (it.female.isNullOrEmpty()) "" else "female:"}${if (it.male.isNullOrEmpty()) "" else "male:"}${it.tag}" }.orEmpty(),
info.groups?.map { it.group }.orEmpty()
)
}

View File

@@ -17,19 +17,8 @@
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
)
import xyz.quaver.pupil.hitomi.GalleryInfo
import xyz.quaver.pupil.hitomi.getGalleryInfo
@Serializable
data class GalleryFiles(
@@ -44,6 +33,6 @@ data class GalleryFiles(
//Set header `Referer` to reader url to avoid 403 error
@Deprecated("", replaceWith = ReplaceWith("getGalleryInfo"))
suspend fun getReader(galleryID: Int) : GalleryInfo {
fun getReader(galleryID: Int) : GalleryInfo {
return getGalleryInfo(galleryID)
}

View File

@@ -16,42 +16,339 @@
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
import kotlinx.serialization.json.jsonArray
import okhttp3.Request
import xyz.quaver.pupil.client
import xyz.quaver.pupil.util.content
import java.net.URL
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
import kotlin.math.min
//searchlib.js
const val separator = "-"
const val extension = ".html"
const val index_dir = "tagindex"
const val galleries_index_dir = "galleriesindex"
const val max_node_size = 464
const val B = 16
const val compressed_nozomi_prefix = "n"
@OptIn(ExperimentalSerializationApi::class)
suspend fun getGalleryIDsForQuery(query: String) : Set<Int> =
webView.evaluatePromise("get_galleryids_for_query('$query')")
val tag_index_version: String by lazy { getIndexVersion("tagindex") }
val galleries_index_version: String by lazy { getIndexVersion("galleriesindex") }
val tagIndexDomain = "tagindex.hitomi.la"
@Serializable
data class Suggestion(val s: String, val t: Int, val u: String, val n: String)
fun sha256(data: ByteArray) : ByteArray {
return MessageDigest.getInstance("SHA-256").digest(data)
}
@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));
@OptIn(ExperimentalUnsignedTypes::class)
fun hashTerm(term: String) : UByteArray {
return sha256(term.toByteArray()).toUByteArray().sliceArray(0 until 4)
}
fun sanitize(input: String) : String {
return input.replace(Regex("[/#]"), "")
}
fun getIndexVersion(name: String) =
URL("$protocol//$domain/$name/version?_=${System.currentTimeMillis()}").readText()
//search.js
fun getGalleryIDsForQuery(query: String) : Set<Int> {
query.replace("_", " ").let {
if (it.indexOf(':') > -1) {
val sides = it.split(":")
val ns = sides[0]
var tag = sides[1]
var area : String? = ns
var language = "all"
when (ns) {
"female", "male" -> {
area = "tag"
tag = it
}
});
""".trimIndent()
)
"language" -> {
area = null
language = tag
tag = "index"
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : Set<Int> {
val jsArea = if (area == null) "null" else "'$area'"
return getGalleryIDsFromNozomi(area, tag, language)
}
return webView.evaluatePromise("""get_galleryids_from_nozomi($jsArea, '$tag', '$language')""")
val key = hashTerm(it)
val field = "galleries"
val node = getNodeAtAddress(field, 0) ?: return emptySet()
val data = bSearch(field, key, node)
if (data != null)
return getGalleryIDsFromData(data)
return emptySet()
}
}
fun encodeSearchQueryForUrl(s: Char) =
when(s) {
' ' -> "_"
'/' -> "slash"
'.' -> "dot"
else -> s.toString()
}
fun getSuggestionsForQuery(query: String) : List<Suggestion> {
query.replace('_', ' ').let {
var field = "global"
var term = it
if (term.indexOf(':') > -1) {
val sides = it.split(':')
field = sides[0]
term = sides[1]
}
val chars = term.map(::encodeSearchQueryForUrl)
val url = "https://$tagIndexDomain/$field${if (chars.isNotEmpty()) "/${chars.joinToString("/")}" else ""}.json"
val request = Request.Builder()
.url(url)
.build()
val suggestions = json.parseToJsonElement(client.newCall(request).execute().body()?.use { body -> body.string() } ?: return emptyList())
return buildList {
suggestions.jsonArray.forEach { suggestionRaw ->
val suggestion = suggestionRaw.jsonArray
if (suggestion.size < 3) {
return@forEach
}
val ns = suggestion[2].content ?: ""
val tagname = sanitize(suggestion[0].content ?: return@forEach)
val url = when(ns) {
"female", "male" -> "/tag/$ns:$tagname${separator}1$extension"
"language" -> "/index-$tagname${separator}1$extension"
else -> "/$ns/$tagname${separator}all${separator}1$extension"
}
add(Suggestion(suggestion[0].content ?: "", suggestion[1].content?.toIntOrNull() ?: 0, url, ns))
}
}
}
}
data class Suggestion(val s: String, val t: Int, val u: String, val n: String)
fun getSuggestionsFromData(field: String, data: Pair<Long, Int>) : List<Suggestion> {
val url = "$protocol//$domain/$index_dir/$field.$tag_index_version.data"
val (offset, length) = data
if (length > 10000 || length <= 0)
throw Exception("length $length is too long")
val inbuf = getURLAtRange(url, offset.until(offset+length))
val suggestions = ArrayList<Suggestion>()
val buffer = ByteBuffer
.wrap(inbuf)
.order(ByteOrder.BIG_ENDIAN)
val numberOfSuggestions = buffer.int
if (numberOfSuggestions > 100 || numberOfSuggestions <= 0)
throw Exception("number of suggestions $numberOfSuggestions is too long")
for (i in 0.until(numberOfSuggestions)) {
var top = buffer.int
val ns = inbuf.sliceArray(buffer.position().until(buffer.position()+top)).toString(charset("UTF-8"))
buffer.position(buffer.position()+top)
top = buffer.int
val tag = inbuf.sliceArray(buffer.position().until(buffer.position()+top)).toString(charset("UTF-8"))
buffer.position(buffer.position()+top)
val count = buffer.int
val tagname = sanitize(tag)
val u =
when(ns) {
"female", "male" -> "/tag/$ns:$tagname${separator}1$extension"
"language" -> "/index-$tagname${separator}1$extension"
else -> "/$ns/$tagname${separator}all${separator}1$extension"
}
suggestions.add(Suggestion(tag, count, u, ns))
}
return suggestions
}
fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : Set<Int> {
val nozomiAddress =
when(area) {
null -> "$protocol//$domain/$compressed_nozomi_prefix/$tag-$language$nozomiextension"
else -> "$protocol//$domain/$compressed_nozomi_prefix/$area/$tag-$language$nozomiextension"
}
val bytes = URL(nozomiAddress).readBytes()
val nozomi = mutableSetOf<Int>()
val arrayBuffer = ByteBuffer
.wrap(bytes)
.order(ByteOrder.BIG_ENDIAN)
while (arrayBuffer.hasRemaining())
nozomi.add(arrayBuffer.int)
return nozomi
}
fun getGalleryIDsFromData(data: Pair<Long, Int>) : Set<Int> {
val url = "$protocol//$domain/$galleries_index_dir/galleries.$galleries_index_version.data"
val (offset, length) = data
if (length > 100000000 || length <= 0)
throw Exception("length $length is too long")
val inbuf = getURLAtRange(url, offset.until(offset+length))
val galleryIDs = mutableSetOf<Int>()
val buffer = ByteBuffer
.wrap(inbuf)
.order(ByteOrder.BIG_ENDIAN)
val numberOfGalleryIDs = buffer.int
val expectedLength = numberOfGalleryIDs*4+4
if (numberOfGalleryIDs > 10000000 || numberOfGalleryIDs <= 0)
throw Exception("number_of_galleryids $numberOfGalleryIDs is too long")
else if (inbuf.size != expectedLength)
throw Exception("inbuf.byteLength ${inbuf.size} != expected_length $expectedLength")
for (i in 0.until(numberOfGalleryIDs))
galleryIDs.add(buffer.int)
return galleryIDs
}
fun getNodeAtAddress(field: String, address: Long) : Node? {
val url =
when(field) {
"galleries" -> "$protocol//$domain/$galleries_index_dir/galleries.$galleries_index_version.index"
"languages" -> "$protocol//$domain/$galleries_index_dir/languages.$galleries_index_version.index"
"nozomiurl" -> "$protocol//$domain/$galleries_index_dir/nozomiurl.$galleries_index_version.index"
else -> "$protocol//$domain/$index_dir/$field.$tag_index_version.index"
}
val nodedata = getURLAtRange(url, address.until(address+ max_node_size))
return decodeNode(nodedata)
}
fun getURLAtRange(url: String, range: LongRange) : ByteArray {
val request = Request.Builder()
.url(url)
.header("Range", "bytes=${range.first}-${range.last}")
.build()
return client.newCall(request).execute().body()?.use { it.bytes() } ?: byteArrayOf()
}
@OptIn(ExperimentalUnsignedTypes::class)
data class Node(val keys: List<UByteArray>, val datas: List<Pair<Long, Int>>, val subNodeAddresses: List<Long>)
@OptIn(ExperimentalUnsignedTypes::class)
fun decodeNode(data: ByteArray) : Node {
val buffer = ByteBuffer
.wrap(data)
.order(ByteOrder.BIG_ENDIAN)
val uData = data.toUByteArray()
val numberOfKeys = buffer.int
val keys = ArrayList<UByteArray>()
for (i in 0.until(numberOfKeys)) {
val keySize = buffer.int
if (keySize == 0 || keySize > 32)
throw Exception("fatal: !keySize || keySize > 32")
keys.add(uData.sliceArray(buffer.position().until(buffer.position()+keySize)))
buffer.position(buffer.position()+keySize)
}
val numberOfDatas = buffer.int
val datas = ArrayList<Pair<Long, Int>>()
for (i in 0.until(numberOfDatas)) {
val offset = buffer.long
val length = buffer.int
datas.add(Pair(offset, length))
}
val numberOfSubNodeAddresses = B +1
val subNodeAddresses = ArrayList<Long>()
for (i in 0.until(numberOfSubNodeAddresses)) {
val subNodeAddress = buffer.long
subNodeAddresses.add(subNodeAddress)
}
return Node(keys, datas, subNodeAddresses)
}
@OptIn(ExperimentalUnsignedTypes::class)
fun bSearch(field: String, key: UByteArray, node: Node) : Pair<Long, Int>? {
fun compareArrayBuffers(dv1: UByteArray, dv2: UByteArray) : Int {
val top = min(dv1.size, dv2.size)
for (i in 0.until(top)) {
if (dv1[i] < dv2[i])
return -1
else if (dv1[i] > dv2[i])
return 1
}
return 0
}
fun locateKey(key: UByteArray, node: Node) : Pair<Boolean, Int> {
for (i in node.keys.indices) {
val cmpResult = compareArrayBuffers(key, node.keys[i])
if (cmpResult <= 0)
return Pair(cmpResult==0, i)
}
return Pair(false, node.keys.size)
}
fun isLeaf(node: Node) : Boolean {
for (subnode in node.subNodeAddresses)
if (subnode != 0L)
return false
return true
}
if (node.keys.isEmpty())
return null
val (there, where) = locateKey(key, node)
if (there)
return node.datas[where]
else if (isLeaf(node))
return null
val nextNode = getNodeAtAddress(field, node.subNodeAddresses[where]) ?: return null
return bSearch(field, key, nextNode)
}

View File

@@ -60,8 +60,10 @@ class UpdateBroadcastReceiver : BroadcastReceiver() {
when (uri.scheme) {
"file" ->
FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!))
else
uri
"content" -> uri
else -> null
}
@@ -74,7 +76,7 @@ class UpdateBroadcastReceiver : BroadcastReceiver() {
val notificationManager = NotificationManagerCompat.from(context)
val pendingIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW).apply {
val pendingIntent = PendingIntent.getActivity(context, System.currentTimeMillis().toInt(), Intent(Intent.ACTION_VIEW).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
}, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_IMMUTABLE else 0)

View File

@@ -0,0 +1,64 @@
package xyz.quaver.pupil.receiver
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.wifi.p2p.WifiP2pManager
import android.os.Build
import android.os.Parcelable
import android.util.Log
import androidx.core.app.ActivityCompat
import xyz.quaver.pupil.ui.ErrorType
import xyz.quaver.pupil.ui.TransferStep
import xyz.quaver.pupil.ui.TransferViewModel
private inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? = when {
Build.VERSION.SDK_INT >= 33 -> getParcelableExtra(key, T::class.java)
else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T
}
class WifiDirectBroadcastReceiver(
private val manager: WifiP2pManager,
private val channel: WifiP2pManager.Channel,
private val viewModel: TransferViewModel
): BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(context: Context?, intent: Intent?) {
context!!
when (intent?.action) {
WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)
Log.d("PUPILD", "Wifi P2P state changed: $state")
viewModel.setWifiP2pEnabled(state == WifiP2pManager.WIFI_P2P_STATE_ENABLED)
}
WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> {
Log.d("PUPILD", "Wifi P2P peers changed")
manager.requestPeers(channel) { peers ->
viewModel.setPeers(peers)
}
}
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
// Respond to new connection or disconnections
val networkInfo = intent.getParcelableExtraCompat<android.net.NetworkInfo>(WifiP2pManager.EXTRA_NETWORK_INFO)
Log.d("PUPILD", "Wifi P2P connection changed: $networkInfo ${networkInfo?.isConnected}")
if (networkInfo?.isConnected == true) {
manager.requestConnectionInfo(channel) { info ->
viewModel.setConnectionInfo(info)
}
} else {
viewModel.setConnectionInfo(null)
}
}
WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> {
// Respond to this device's wifi state changing
Log.d("PUPILD", "Wifi P2P this device changed")
viewModel.setThisDevice(intent.getParcelableExtraCompat(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE))
}
}
}
}

View File

@@ -23,6 +23,7 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
@@ -100,13 +101,15 @@ class DownloadService : Service() {
notify(galleryID)
}
@SuppressLint("RestrictedApi")
@SuppressLint("RestrictedApi", "MissingPermission")
private fun notify(galleryID: Int) {
val max = progress[galleryID]?.size ?: 0
val progress = progress[galleryID]?.count { it == Float.POSITIVE_INFINITY } ?: 0
val notification = notification[galleryID] ?: return
if (!checkNotificationEnabled(this)) return
if (isCompleted(galleryID)) {
notification
.setContentText(getString(R.string.reader_notification_complete))
@@ -168,19 +171,26 @@ class DownloadService : Service() {
private val interceptor: PupilInterceptor = { chain ->
val request = chain.request()
var response = chain.proceed(request)
var limit = 5
var response = kotlin.runCatching {
chain.proceed(request)
}.getOrNull()
var limit = 10
while (!response.isSuccessful) {
if (response.code() == 503) {
while (response?.isSuccessful != true) {
if (response?.code() == 503) {
Thread.sleep(200)
} else if (--limit > 0)
} else if (--limit < 0)
break
response = chain.proceed(request)
response = kotlin.runCatching {
chain.proceed(request)
}.getOrNull()
}
response.newBuilder()
if (response == null)
response = chain.proceed(request)
response!!.newBuilder()
.body(response.body()?.let {
ProgressResponseBody(request.tag(), it, progressListener)
}).build()
@@ -207,6 +217,7 @@ class DownloadService : Service() {
private val callback = object: Callback {
override fun onFailure(call: Call, e: IOException) {
Log.d("PUPILD", "ONFAILURE ${call.request().tag()}, ${e}")
FirebaseCrashlytics.getInstance().recordException(e)
if (e.message?.contains("cancel", true) == false) {
@@ -220,28 +231,23 @@ class DownloadService : Service() {
CoroutineScope(Dispatchers.IO).launch {
runCatching {
response.also {
if (it.code() != 200) throw IOException(
"$galleryID $index ${response.request().url()} CODE ${it.code()}"
val image = response.also { if (it.code() != 200) throw IOException( "$galleryID $index ${response.request().url()} CODE ${it.code()}" ) }.body()?.use { it.bytes() } ?: throw Exception("Response null")
val padding = ceil(progress[galleryID]?.size?.let { log10(it.toFloat()) } ?: 0F).toInt()
Cache.getInstance(this@DownloadService, galleryID)
.putImage(index, "${index.toString().padStart(padding, '0')}.$ext", image)
progress[galleryID]?.set(index, Float.POSITIVE_INFINITY)
notify(galleryID)
if (isCompleted(galleryID)) {
if (DownloadManager.getInstance(this@DownloadService)
.getDownloadFolder(galleryID) != null
)
}.body()?.use {
val padding = ceil(progress[galleryID]?.size?.let { log10(it.toFloat()) } ?: 0F).toInt()
Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
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")
startId?.let { stopSelf(it) }
}
}.onFailure {
FirebaseCrashlytics.getInstance().recordException(it)
}
@@ -329,8 +335,7 @@ class DownloadService : Service() {
}
if (isCompleted(galleryID)) {
if (DownloadManager.getInstance(this@DownloadService)
.getDownloadFolder(galleryID) != null )
if (DownloadManager.getInstance(this@DownloadService).getDownloadFolder(galleryID) != null)
Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
notificationManager.cancel(galleryID)
@@ -338,7 +343,7 @@ class DownloadService : Service() {
return@launch
}
notification[galleryID]?.setContentTitle(galleryInfo.title?.ellipsize(30))
notification[galleryID]?.setContentTitle(galleryInfo.title.ellipsize(32))
notify(galleryID)
val queued = mutableSetOf<Int>()
@@ -400,7 +405,11 @@ class DownloadService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(R.id.downloader_notification_id, serviceNotification.build())
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
startForeground(R.id.downloader_notification_id, serviceNotification.build())
} else {
startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
when (intent?.getStringExtra(KEY_COMMAND)) {
COMMAND_DOWNLOAD -> intent.getIntExtra(KEY_ID, -1).let { if (it > 0)
@@ -421,7 +430,11 @@ class DownloadService : Service() {
override fun onBind(p0: Intent?) = binder
override fun onCreate() {
startForeground(R.id.downloader_notification_id, serviceNotification.build())
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
startForeground(R.id.downloader_notification_id, serviceNotification.build())
} else {
startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
interceptors[Tag::class] = interceptor
}

View File

@@ -0,0 +1,128 @@
package xyz.quaver.pupil.services
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import xyz.quaver.pupil.R
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class TransferClientService : Service() {
private val selectorManager = SelectorManager(Dispatchers.IO)
private val channel = Channel<Pair<TransferPacket, Continuation<TransferPacket>>>()
private var job: Job? = null
private fun startForeground() = runCatching {
val notification = NotificationCompat.Builder(this, "transfer")
.setContentTitle("Pupil")
.setContentText("Transfer server is running")
.setSmallIcon(R.drawable.ic_notification)
.build()
ServiceCompat.startForeground(
this,
1,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else 0
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val address = intent?.getStringExtra("address") ?: run {
stopSelf(startId)
return START_STICKY
}
startForeground()
Log.d("PUPILD", "Starting service with address $address")
job?.cancel()
job = CoroutineScope(Dispatchers.IO).launch {
Log.d("PUPILD", "Connecting to $address")
val socket = aSocket(selectorManager).tcp().connect(address, 12221)
Log.d("PUPILD", "Connected to $address")
val readChannel = socket.openReadChannel()
val writeChannel = socket.openWriteChannel(autoFlush = true)
runCatching {
TransferPacket.Hello().writeToChannel(writeChannel)
val handshake = TransferPacket.readFromChannel(readChannel)
if (handshake !is TransferPacket.Hello || handshake.version != TRANSFER_PROTOCOL_VERSION) {
throw IllegalStateException("Invalid handshake")
}
while (true) {
val (packet, continuation) = channel.receive()
Log.d("PUPILD", "Sending packet $packet")
packet.writeToChannel(writeChannel)
val response = TransferPacket.readFromChannel(readChannel).also {
Log.d("PUPILD", "Received packet $it")
}
continuation.resume(response)
}
}.onFailure {
Log.d("PUPILD", "Connection closed with error $it")
channel.close()
socket.close()
stopSelf(startId)
}
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
job?.cancel()
}
inner class Binder: android.os.Binder() {
@OptIn(DelicateCoroutinesApi::class)
suspend fun sendPacket(packet: TransferPacket): Result<TransferPacket.ListResponse> = runCatching {
check(job != null) { "Service not running" }
check(!channel.isClosedForSend) { "Service not running" }
val response = suspendCoroutine { continuation ->
check (channel.trySend(packet to continuation).isSuccess) { "Service not running" }
}
check (response is TransferPacket.ListResponse) { "Invalid response" }
response
}
}
private val binder = Binder()
override fun onBind(intent: Intent?): IBinder = binder
}

View File

@@ -0,0 +1,94 @@
package xyz.quaver.pupil.services
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.ByteWriteChannel
const val TRANSFER_PROTOCOL_VERSION: UByte = 1u
enum class TransferType(val value: UByte) {
INVALID(255u),
HELLO(0u),
PING(1u),
PONG(2u),
LIST_REQUEST(3u),
LIST_RESPONSE(4u),
}
sealed interface TransferPacket {
val type: TransferType
suspend fun writeToChannel(channel: ByteWriteChannel)
data class Hello(val version: UByte = TRANSFER_PROTOCOL_VERSION): TransferPacket {
override val type = TransferType.HELLO
override suspend fun writeToChannel(channel: ByteWriteChannel) {
channel.writeByte(type.value.toByte())
channel.writeByte(version.toByte())
}
}
data object Ping: TransferPacket {
override val type = TransferType.PING
override suspend fun writeToChannel(channel: ByteWriteChannel) {
channel.writeByte(type.value.toByte())
}
}
data object Pong: TransferPacket {
override val type = TransferType.PONG
override suspend fun writeToChannel(channel: ByteWriteChannel) {
channel.writeByte(type.value.toByte())
}
}
data object ListRequest: TransferPacket {
override val type = TransferType.LIST_REQUEST
override suspend fun writeToChannel(channel: ByteWriteChannel) {
channel.writeByte(type.value.toByte())
}
}
data object Invalid: TransferPacket {
override val type = TransferType.INVALID
override suspend fun writeToChannel(channel: ByteWriteChannel) {
channel.writeByte(type.value.toByte())
}
}
data class ListResponse(
val favoritesCount: Int,
val historyCount: Int,
val downloadsCount: Int,
): TransferPacket {
override val type = TransferType.LIST_RESPONSE
override suspend fun writeToChannel(channel: ByteWriteChannel) {
channel.writeByte(type.value.toByte())
channel.writeInt(favoritesCount)
channel.writeInt(historyCount)
channel.writeInt(downloadsCount)
}
}
companion object {
suspend fun readFromChannel(channel: ByteReadChannel): TransferPacket {
return when(val type = channel.readByte().toUByte()) {
TransferType.HELLO.value -> {
val version = channel.readByte().toUByte()
Hello(version)
}
TransferType.PING.value -> Ping
TransferType.PONG.value -> Pong
TransferType.LIST_REQUEST.value -> ListRequest
TransferType.LIST_RESPONSE.value -> {
val favoritesCount = channel.readInt()
val historyCount = channel.readInt()
val downloadsCount = channel.readInt()
ListResponse(favoritesCount, historyCount, downloadsCount)
}
else -> throw IllegalArgumentException("Unknown packet type: $type")
}
}
}
}

View File

@@ -0,0 +1,123 @@
package xyz.quaver.pupil.services
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.ServerSocket
import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.histories
import xyz.quaver.pupil.util.downloader.DownloadManager
class TransferServerService : Service() {
private val selectorManager = SelectorManager(Dispatchers.IO)
private var serverSocket: ServerSocket? = null
private val job = Job()
private fun startForeground() = runCatching {
val notification = NotificationCompat.Builder(this, "transfer")
.setContentTitle("Pupil")
.setContentText("Transfer server is running")
.setSmallIcon(R.drawable.ic_notification)
.build()
ServiceCompat.startForeground(
this,
1,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else 0
)
}
private fun generateListResponse(): TransferPacket.ListResponse {
val favoritesCount = favorites.size
val historyCount = histories.size
val downloadsCount = DownloadManager.getInstance(this).downloadFolderMap.size
return TransferPacket.ListResponse(favoritesCount, historyCount, downloadsCount)
}
private suspend fun handleConnection(socket: Socket) {
val readChannel = socket.openReadChannel()
val writeChannel = socket.openWriteChannel(autoFlush = true)
runCatching {
while (true) {
val packet = TransferPacket.readFromChannel(readChannel)
Log.d("PUPILD", "Received packet $packet")
binder.channel.trySend(packet)
val response = when (packet) {
is TransferPacket.Hello -> TransferPacket.Hello()
is TransferPacket.Ping -> TransferPacket.Pong
is TransferPacket.ListRequest -> generateListResponse()
else -> TransferPacket.Invalid
}
response.writeToChannel(writeChannel)
}
}.onFailure {
socket.close()
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val address = intent?.getStringExtra("address") ?: run {
stopSelf(startId)
return START_STICKY
}
if (serverSocket != null) {
return START_STICKY
}
startForeground()
val serverSocket = aSocket(selectorManager).tcp().bind(address, 12221).also {
this@TransferServerService.serverSocket = it
}
CoroutineScope(Dispatchers.IO + job).launch {
while (true) {
Log.d("PUPILD", "Waiting for connection")
val socket = serverSocket.accept()
Log.d("PUPILD", "Accepted connection from ${socket.remoteAddress}")
launch { handleConnection(socket) }
}
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
serverSocket?.close()
}
inner class Binder: android.os.Binder() {
val channel = Channel<TransferPacket>()
}
private val binder = Binder()
override fun onBind(intent: Intent?): IBinder = binder
}

View File

@@ -19,26 +19,26 @@
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.text.InputType
import android.text.util.Linkify
import android.util.Log
import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import android.view.animation.DecelerateInterpolator
import android.widget.EditText
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.webkit.WebViewCompat
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import com.google.firebase.crashlytics.FirebaseCrashlytics
@@ -47,12 +47,12 @@ 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.hitomi.doSearch
import xyz.quaver.pupil.hitomi.getGalleryIDsFromNozomi
import xyz.quaver.pupil.hitomi.getSuggestionsForQuery
import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.types.*
import xyz.quaver.pupil.ui.dialog.DownloadLocationDialogFragment
@@ -64,9 +64,14 @@ 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.requestNotificationPermission
import xyz.quaver.pupil.util.restore
import xyz.quaver.pupil.util.showNotificationPermissionExplanationDialog
import java.util.regex.Pattern
import kotlin.math.*
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
class MainActivity :
BaseActivity(),
@@ -107,7 +112,11 @@ class MainActivity :
private lateinit var binding: MainActivityBinding
private var oldWebViewJob: Job? = null
private val requestNotificationPermssionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (!isGranted) {
showNotificationPermissionExplanationDialog(this)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -120,48 +129,17 @@ class MainActivity :
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()
Snackbar.make(binding.contents.recyclerview, getString(R.string.settings_restore_success, it), Snackbar.LENGTH_LONG).show()
}
)
}
}
requestNotificationPermission(this, requestNotificationPermssionLauncher, false) {}
if (Preferences["download_folder", ""].isEmpty())
DownloadLocationDialogFragment().show(supportFragmentManager, "Download Location Dialog")
oldWebViewJob = CoroutineScope(Dispatchers.Unconfined).launch {
do {
delay(1000)
if (oldWebView) {
AlertDialog.Builder(this@MainActivity)
.setTitle(android.R.string.dialog_alert_title)
.setMessage(R.string.old_webview)
.setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ ->
WebViewCompat.getCurrentWebViewPackage(this@MainActivity)?.packageName?.let { packageName ->
try {
startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse("market://details?id=$packageName")
)
)
} catch (e: ActivityNotFoundException) {
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", ""])
@@ -204,8 +182,6 @@ class MainActivity :
override fun onDestroy() {
super.onDestroy()
oldWebViewJob?.cancel()
(binding.contents.recyclerview.adapter as? GalleryBlockAdapter)?.updateAll = false
}
@@ -429,12 +405,17 @@ class MainActivity :
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)
requestNotificationPermission(
this@MainActivity,
requestNotificationPermssionLauncher
) {
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()
@@ -514,6 +495,20 @@ class MainActivity :
private var suggestionJob : Job? = null
private fun setupSearchBar() {
with(binding.contents.searchview) {
val scrollSuggestionToTop = {
with(binding.suggestionSection.suggestionsList) {
MainScope().launch {
withTimeout(1000) {
val layoutManager = layoutManager as LinearLayoutManager
while (layoutManager.findLastVisibleItemPosition() != adapter?.itemCount?.minus(1)) {
layoutManager.scrollToPosition(adapter?.itemCount?.minus(1) ?: 0)
delay(100)
}
}
}
}
}
onMenuStatusChangeListener = object: FloatingSearchView.OnMenuStatusChangeListener {
override fun onMenuOpened() {
(this@MainActivity.binding.contents.recyclerview.adapter as GalleryBlockAdapter).closeAllItems()
@@ -539,6 +534,7 @@ class MainActivity :
onFavoriteHistorySwitchClickListener = {
isFavorite = !isFavorite
swapSuggestions(defaultSuggestions)
scrollSuggestionToTop()
}
onMenuItemClickListener = {
@@ -552,6 +548,7 @@ class MainActivity :
if (query.isEmpty() or query.endsWith(' ')) {
swapSuggestions(defaultSuggestions)
scrollSuggestionToTop()
return@lambda
}
@@ -583,8 +580,10 @@ class MainActivity :
onFocusChangeListener = object: FloatingSearchView.OnFocusChangeListener {
override fun onFocus() {
if (query.isEmpty() or query.endsWith(' '))
if (query.isEmpty() or query.endsWith(' ')) {
swapSuggestions(defaultSuggestions)
scrollSuggestionToTop()
}
}
override fun onFocusCleared() {

View File

@@ -57,9 +57,12 @@ 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.checkNotificationEnabled
import xyz.quaver.pupil.util.closeCamera
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.requestNotificationPermission
import xyz.quaver.pupil.util.showNotificationPermissionExplanationDialog
import xyz.quaver.pupil.util.startCamera
class ReaderActivity : BaseActivity() {
@@ -117,6 +120,12 @@ class ReaderActivity : BaseActivity() {
private lateinit var binding: ReaderActivityBinding
private val requestNotificationPermssionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (!isGranted) {
showNotificationPermissionExplanationDialog(this)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ReaderActivityBinding.inflate(layoutInflater)
@@ -148,10 +157,11 @@ class ReaderActivity : BaseActivity() {
val uri = intent.data
val lastPathSegment = uri?.lastPathSegment
if (uri != null && lastPathSegment != null) {
galleryID = when (uri.host) {
galleryID = if (uri.host?.endsWith("hasha.in") == true) {
lastPathSegment?.toInt() ?: 0
} else 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
}
@@ -360,15 +370,20 @@ class ReaderActivity : BaseActivity() {
animateDownloadFAB(DownloadManager.getInstance(this@ReaderActivity).getDownloadFolder(galleryID) != null) //If download in progress, animate button
setOnClickListener {
val downloadManager = DownloadManager.getInstance(this@ReaderActivity)
requestNotificationPermission(
this@ReaderActivity,
requestNotificationPermssionLauncher
) {
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)
if (downloadManager.isDownloading(galleryID)) {
downloadManager.deleteDownloadFolder(galleryID)
animateDownloadFAB(false)
} else {
downloadManager.addDownloadFolder(galleryID)
DownloadService.download(context, galleryID, true)
animateDownloadFAB(true)
}
}
}
}

View File

@@ -0,0 +1,382 @@
package xyz.quaver.pupil.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.net.wifi.WpsInfo
import android.net.wifi.p2p.WifiP2pConfig
import android.net.wifi.p2p.WifiP2pDevice
import android.net.wifi.p2p.WifiP2pDeviceList
import android.net.wifi.p2p.WifiP2pInfo
import android.net.wifi.p2p.WifiP2pManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
import xyz.quaver.pupil.receiver.WifiDirectBroadcastReceiver
import xyz.quaver.pupil.services.TransferClientService
import xyz.quaver.pupil.services.TransferPacket
import xyz.quaver.pupil.services.TransferServerService
import xyz.quaver.pupil.ui.fragment.TransferConnectedFragment
import xyz.quaver.pupil.ui.fragment.TransferDirectionFragment
import xyz.quaver.pupil.ui.fragment.TransferPermissionFragment
import xyz.quaver.pupil.ui.fragment.TransferSelectDataFragment
import xyz.quaver.pupil.ui.fragment.TransferTargetFragment
import xyz.quaver.pupil.ui.fragment.TransferWaitForConnectionFragment
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class TransferActivity : AppCompatActivity(R.layout.transfer_activity) {
private val viewModel: TransferViewModel by viewModels()
private val intentFilter = IntentFilter().apply {
addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION)
}
private lateinit var manager: WifiP2pManager
private lateinit var channel: WifiP2pManager.Channel
private var receiver: BroadcastReceiver? = null
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
viewModel.setStep(TransferStep.TARGET)
} else {
viewModel.setStep(TransferStep.PERMISSION)
}
}
private var clientServiceBinder: TransferClientService.Binder? = null
private val clientServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
clientServiceBinder = service as TransferClientService.Binder
}
override fun onServiceDisconnected(name: ComponentName?) {
clientServiceBinder = null
}
}
private fun checkPermission(force: Boolean = false): Boolean {
val permissionRequired = if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) {
Manifest.permission.ACCESS_FINE_LOCATION
} else {
Manifest.permission.NEARBY_WIFI_DEVICES
}
val permissionGranted =
ActivityCompat.checkSelfPermission(this, permissionRequired) == PackageManager.PERMISSION_GRANTED
val shouldShowRationale =
ActivityCompat.shouldShowRequestPermissionRationale(this, permissionRequired)
if (!permissionGranted) {
if (shouldShowRationale && force) {
viewModel.setStep(TransferStep.PERMISSION)
} else {
requestPermissionLauncher.launch(permissionRequired)
}
return false
}
return true
}
private fun handleServerResponse(response: TransferPacket?) {
when (response) {
is TransferPacket.ListResponse -> {
Log.d("PUPILD", "Received list response $response")
}
else -> {
Log.d("PUPILD", "Received invalid response $response")
}
}
}
private suspend fun WifiP2pManager.disconnect() {
suspendCoroutine { continuation ->
removeGroup(channel, object : WifiP2pManager.ActionListener {
override fun onSuccess() {
continuation.resume(Unit)
}
override fun onFailure(reason: Int) {
continuation.resume(Unit)
}
})
}
suspendCoroutine { continuation ->
cancelConnect(channel, object: WifiP2pManager.ActionListener {
override fun onSuccess() {
continuation.resume(Unit)
}
override fun onFailure(reason: Int) {
continuation.resume(Unit)
}
})
}
}
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.hide()
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
manager = getSystemService(WIFI_P2P_SERVICE) as WifiP2pManager
channel = manager.initialize(this, mainLooper, null)
viewModel.peerToConnect.observe(this) { peer ->
if (peer == null) { return@observe }
if (!checkPermission()) { return@observe }
val config = WifiP2pConfig().apply {
deviceAddress = peer.deviceAddress
wps.setup = WpsInfo.PBC
}
manager.connect(channel, config, object: WifiP2pManager.ActionListener {
override fun onSuccess() { }
override fun onFailure(reason: Int) {
viewModel.connect(null)
}
})
}
lifecycleScope.launch {
viewModel.messageQueue.consumeEach {
clientServiceBinder?.sendPacket(it)?.getOrNull()?.let(::handleServerResponse)
}
}
lifecycleScope.launch {
viewModel.step.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collectLatest step@{ step ->
when (step) {
TransferStep.TARGET,
TransferStep.TARGET_FORCE -> {
if (!checkPermission(step == TransferStep.TARGET_FORCE)) {
return@step
}
manager.discoverPeers(channel, object: WifiP2pManager.ActionListener {
override fun onSuccess() { }
override fun onFailure(reason: Int) {
}
})
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferTargetFragment())
}
val hostAddress = viewModel.connectionInfo.filterNotNull().first {
it.groupFormed
}.groupOwnerAddress.hostAddress
val intent = Intent(this@TransferActivity, TransferClientService::class.java).also {
it.putExtra("address", hostAddress)
}
ContextCompat.startForegroundService(this@TransferActivity, intent)
bindService(intent, clientServiceConnection, BIND_AUTO_CREATE)
viewModel.setStep(TransferStep.SELECT_DATA)
}
TransferStep.DIRECTION -> {
manager.disconnect()
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferDirectionFragment())
}
}
TransferStep.PERMISSION -> {
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferPermissionFragment())
}
}
TransferStep.WAIT_FOR_CONNECTION -> {
Log.d("PUPILD", "wait for connection")
if (!checkPermission()) { return@step }
runCatching {
suspendCoroutine { continuation ->
manager.createGroup(channel, object: WifiP2pManager.ActionListener {
override fun onSuccess() {
continuation.resume(Unit)
}
override fun onFailure(reason: Int) {
continuation.resumeWithException(Exception("Failed to create group $reason"))
}
})
}
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferWaitForConnectionFragment())
}
val address = viewModel.connectionInfo.filterNotNull().first {
it.groupFormed && it.isGroupOwner
}.groupOwnerAddress.hostAddress
val intent = Intent(this@TransferActivity, TransferServerService::class.java).also {
it.putExtra("address", address)
}
ContextCompat.startForegroundService(this@TransferActivity, intent)
val binder: TransferServerService.Binder = suspendCoroutine { continuation ->
bindService(intent, object: ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
continuation.resume(service as TransferServerService.Binder)
}
override fun onServiceDisconnected(name: ComponentName?) { }
}, BIND_AUTO_CREATE)
}
binder.channel.receive()
viewModel.setStep(TransferStep.CONNECTED)
}.onFailure {
Log.e("PUPILD", "Failed to create group", it)
}
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferWaitForConnectionFragment())
}
}
TransferStep.CONNECTED -> {
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferConnectedFragment())
}
}
TransferStep.SELECT_DATA -> {
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferSelectDataFragment())
}
}
}
}
}
}
override fun onResume() {
super.onResume()
bindService(Intent(this, TransferClientService::class.java), clientServiceConnection, BIND_AUTO_CREATE)
WifiDirectBroadcastReceiver(manager, channel, viewModel).also {
receiver = it
registerReceiver(it, intentFilter)
}
}
override fun onPause() {
super.onPause()
unbindService(clientServiceConnection)
receiver?.let { unregisterReceiver(it) }
receiver = null
}
}
enum class TransferStep {
TARGET, TARGET_FORCE, DIRECTION, PERMISSION, WAIT_FOR_CONNECTION, CONNECTED, SELECT_DATA
}
enum class ErrorType {
}
class TransferViewModel : ViewModel() {
private val _step: MutableStateFlow<TransferStep> = MutableStateFlow(TransferStep.DIRECTION)
val step: StateFlow<TransferStep> = _step
private val _error = MutableLiveData<ErrorType?>(null)
val error: LiveData<ErrorType?> = _error
private val _wifiP2pEnabled: MutableLiveData<Boolean> = MutableLiveData(false)
val wifiP2pEnabled: LiveData<Boolean> = _wifiP2pEnabled
private val _thisDevice: MutableLiveData<WifiP2pDevice?> = MutableLiveData(null)
val thisDevice: LiveData<WifiP2pDevice?> = _thisDevice
private val _peers: MutableLiveData<WifiP2pDeviceList?> = MutableLiveData(null)
val peers: LiveData<WifiP2pDeviceList?> = _peers
private val _connectionInfo: MutableStateFlow<WifiP2pInfo?> = MutableStateFlow(null)
val connectionInfo: StateFlow<WifiP2pInfo?> = _connectionInfo
private val _peerToConnect: MutableLiveData<WifiP2pDevice?> = MutableLiveData(null)
val peerToConnect: LiveData<WifiP2pDevice?> = _peerToConnect
val messageQueue: Channel<TransferPacket> = Channel()
fun setStep(step: TransferStep) {
Log.d("PUPILD", "Set step: $step")
_step.value = step
}
fun setWifiP2pEnabled(enabled: Boolean) {
_wifiP2pEnabled.value = enabled
}
fun setThisDevice(device: WifiP2pDevice?) {
_thisDevice.value = device
}
fun setPeers(peers: WifiP2pDeviceList?) {
_peers.value = peers
}
fun setConnectionInfo(info: WifiP2pInfo?) {
_connectionInfo.value = info
}
fun setError(error: ErrorType?) {
_error.value = error
}
fun connect(device: WifiP2pDevice?) {
_peerToConnect.value = device
}
fun ping() {
messageQueue.trySend(TransferPacket.Ping)
}
fun list() {
messageQueue.trySend(TransferPacket.ListRequest)
}
}

View File

@@ -18,25 +18,72 @@
package xyz.quaver.pupil.ui.fragment
import android.app.Activity
import android.content.Intent
import android.content.res.Resources
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.Bundle
import android.util.Log
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.DrawableCompat
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import okhttp3.*
import xyz.quaver.io.FileX
import xyz.quaver.io.util.readText
import xyz.quaver.pupil.R
import xyz.quaver.pupil.client
import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.get
import xyz.quaver.pupil.util.restore
import java.io.File
import java.io.IOException
import kotlin.math.roundToInt
class ManageFavoritesFragment : PreferenceFragmentCompat() {
private val requestBackupFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) {
return@registerForActivityResult
}
val uri = result.data?.data ?: return@registerForActivityResult
val context = context ?: return@registerForActivityResult
val view = view ?: return@registerForActivityResult
val backupData = runCatching {
FileX(context, uri).readText()?.let { Json.parseToJsonElement(it) }
}.getOrNull() ?: run{
Snackbar.make(view, context.getString(R.string.error), Toast.LENGTH_LONG).show()
return@registerForActivityResult
}
val newFavorites = backupData["favorites"]?.let { Json.decodeFromJsonElement<List<Int>>(it) }.orEmpty()
val newFavoriteTags = backupData["favorite_tags"]?.let { Json.decodeFromJsonElement<List<Tag>>(it) }.orEmpty()
favorites.addAll(newFavorites)
favoriteTags.addAll(newFavoriteTags)
Snackbar.make(view, context.getString(R.string.settings_restore_success, newFavorites.size + newFavoriteTags.size), Snackbar.LENGTH_LONG).show()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.manage_favorites_preferences, rootKey)
@@ -47,57 +94,44 @@ class ManageFavoritesFragment : PreferenceFragmentCompat() {
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()
val favorites = runCatching {
Json.parseToJsonElement(File(ContextCompat.getDataDir(context), "favorites.json").readText())
}.getOrNull()
val favoriteTags = kotlin.runCatching {
Json.parseToJsonElement(File(ContextCompat.getDataDir(context), "favorites_tags.json").readText())
}.getOrNull()
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()
val favoriteJson = buildJsonObject {
favorites?.let {
put("favorites", it)
}
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)))
}
favoriteTags?.let {
put("favorite_tags", it)
}
})
}
val backupFile = File(context.filesDir, "pupil-backup.json").also {
it.writeText(favoriteJson.toString())
}
Intent(Intent.ACTION_SEND).apply {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", backupFile)
setDataAndType(uri, "application/json")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, uri)
}.let {
context.startActivity(Intent.createChooser(it, getString(R.string.settings_backup_share)))
}
true
}
findPreference<Preference>("restore")?.setOnPreferenceClickListener {
val editText = EditText(context).apply {
setText(context.getString(R.string.backup_url), TextView.BufferType.EDITABLE)
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
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()
requestBackupFileLauncher.launch(intent)
true
}

View File

@@ -18,22 +18,38 @@
package xyz.quaver.pupil.ui.fragment
import android.graphics.ColorFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import xyz.quaver.io.FileX
import xyz.quaver.io.SAFileX
import xyz.quaver.io.util.deleteRecursively
import xyz.quaver.io.util.getChild
import xyz.quaver.io.util.readText
import xyz.quaver.io.util.writeText
import xyz.quaver.pupil.R
import xyz.quaver.pupil.histories
import xyz.quaver.pupil.hitomi.json
import xyz.quaver.pupil.util.byteToString
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.downloader.Metadata
import java.io.File
import kotlin.math.roundToInt
class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClickListener {
@@ -45,12 +61,10 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
initPreferences()
}
override fun onPreferenceClick(preference: Preference?): Boolean {
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")
@@ -80,6 +94,48 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show()
}
"recover_downloads" -> {
val density = context.resources.displayMetrics.density
this.icon = object: CircularProgressDrawable(context) {
override fun getIntrinsicHeight() = (24*density).roundToInt()
override fun getIntrinsicWidth() = (24*density).roundToInt()
}.apply {
setStyle(CircularProgressDrawable.DEFAULT)
colorFilter = PorterDuffColorFilter(ContextCompat.getColor(context, R.color.colorAccent), PorterDuff.Mode.SRC_IN)
start()
}
val downloadManager = DownloadManager.getInstance(context)
val downloadFolderMap = downloadManager.downloadFolderMap
downloadFolderMap.clear()
downloadManager.downloadFolder.listFiles { file -> file.isDirectory }?.forEach { folder ->
val metadataFile = FileX(context, folder, ".metadata")
if (!metadataFile.exists()) return@forEach
val metadata = metadataFile.readText()?.let {
runCatching {
json.decodeFromString<Metadata>(it)
}.getOrNull()
} ?: return@forEach
val galleryID = metadata.galleryBlock?.id ?: metadata.galleryInfo?.id?.toIntOrNull() ?: return@forEach
downloadFolderMap[galleryID] = folder.name
}
downloadManager.downloadFolderMap.putAll(downloadFolderMap)
val downloads = FileX(context, downloadManager.downloadFolder, ".download")
if (!downloads.exists()) downloads.createNewFile()
downloads.writeText(Json.encodeToString(downloadFolderMap))
this.icon = null
Toast.makeText(context, android.R.string.ok, Toast.LENGTH_SHORT).show()
}
"delete_downloads" -> {
val dir = DownloadManager.getInstance(context).downloadFolder
@@ -191,6 +247,12 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
onPreferenceClickListener = this@ManageStorageFragment
}
with(findPreference<Preference>("recover_downloads")) {
this ?: return@with
onPreferenceClickListener = this@ManageStorageFragment
}
}
override fun onDestroy() {

View File

@@ -21,12 +21,14 @@ package xyz.quaver.pupil.ui.fragment
import android.app.Activity
import android.content.*
import android.os.Bundle
import android.util.Log
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 com.google.firebase.crashlytics.internal.model.CrashlyticsReport
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -40,6 +42,7 @@ 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.TransferActivity
import xyz.quaver.pupil.ui.dialog.*
import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.DownloadManager
@@ -80,10 +83,8 @@ class SettingsFragment :
}
}
override fun onPreferenceClick(preference: Preference?): Boolean {
override fun onPreferenceClick(preference: Preference): Boolean {
with (preference) {
this ?: return false
when (key) {
"app_version" -> {
checkUpdate(activity as SettingsActivity, true)
@@ -115,6 +116,9 @@ class SettingsFragment :
)
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
"transfer_data" -> {
activity?.startActivity(Intent(activity, TransferActivity::class.java))
}
else -> return false
}
}
@@ -122,10 +126,8 @@ class SettingsFragment :
return true
}
override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean {
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean {
with (preference) {
this ?: return false
when (key) {
"tag_translation" -> {
updateTranslations()
@@ -163,7 +165,7 @@ class SettingsFragment :
when (key) {
"proxy" -> {
summary = context?.let { getProxyInfo().type.name }
summary = context.let { getProxyInfo().type.name }
}
"download_folder" -> {
summary = FileX(context, Preferences.get<String>("download_folder")).canonicalPath
@@ -300,10 +302,13 @@ class SettingsFragment :
}
"oss" -> {
setOnPreferenceClickListener {
context?.startActivity(Intent(context, OssLicensesMenuActivity::class.java))
context.startActivity(Intent(context, OssLicensesMenuActivity::class.java))
true
}
}
"transfer_data" -> {
onPreferenceClickListener = this@SettingsFragment
}
}
}

View File

@@ -0,0 +1,10 @@
package xyz.quaver.pupil.ui.fragment
import androidx.fragment.app.Fragment
import xyz.quaver.pupil.R
class TransferConnectedFragment: Fragment(R.layout.transfer_connected_fragment) {
}

View File

@@ -0,0 +1,44 @@
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.TransferDirectionFragmentBinding
import xyz.quaver.pupil.ui.TransferStep
import xyz.quaver.pupil.ui.TransferViewModel
class TransferDirectionFragment : Fragment(R.layout.transfer_direction_fragment) {
private var _binding: TransferDirectionFragmentBinding? = null
private val binding get() = _binding!!
private val viewModel: TransferViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = TransferDirectionFragmentBinding.inflate(inflater, container, false)
binding.inButton.setOnClickListener {
viewModel.setStep(TransferStep.TARGET)
}
binding.outButton.setOnClickListener {
viewModel.setStep(TransferStep.WAIT_FOR_CONNECTION)
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,38 @@
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import xyz.quaver.pupil.databinding.TransferPermissionFragmentBinding
import xyz.quaver.pupil.ui.TransferStep
import xyz.quaver.pupil.ui.TransferViewModel
class TransferPermissionFragment: Fragment() {
private var _binding: TransferPermissionFragmentBinding? = null
private val binding get() = _binding!!
private val viewModel: TransferViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = TransferPermissionFragmentBinding.inflate(inflater, container, false)
binding.permissionsButton.setOnClickListener {
viewModel.setStep(TransferStep.TARGET_FORCE)
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,37 @@
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import xyz.quaver.pupil.databinding.TransferSelectDataFragmentBinding
import xyz.quaver.pupil.ui.TransferViewModel
class TransferSelectDataFragment: Fragment() {
private var _binding: TransferSelectDataFragmentBinding? = null
private val binding get() = _binding!!
private val viewModel: TransferViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = TransferSelectDataFragmentBinding.inflate(inflater, container, false)
binding.checkAll.setOnCheckedChangeListener { _, isChecked ->
viewModel.list()
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,69 @@
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.TransferPeersAdapter
import xyz.quaver.pupil.databinding.TransferTargetFragmentBinding
import xyz.quaver.pupil.ui.TransferStep
import xyz.quaver.pupil.ui.TransferViewModel
class TransferTargetFragment : Fragment() {
private var _binding: TransferTargetFragmentBinding? = null
private val binding get() = _binding!!
private val viewModel: TransferViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = TransferTargetFragmentBinding.inflate(inflater, container, false)
viewModel.thisDevice.observe(viewLifecycleOwner) { device ->
if (device == null) {
return@observe
}
if (device.status == 3) {
binding.ripple.startRippleAnimation()
binding.retryButton.visibility = View.INVISIBLE
} else {
binding.ripple.stopRippleAnimation()
binding.retryButton.visibility = View.VISIBLE
}
}
viewModel.peers.observe(viewLifecycleOwner) { peers ->
if (peers == null) {
return@observe
}
binding.deviceList.adapter = TransferPeersAdapter(peers.deviceList) {
viewModel.connect(it)
}
}
binding.ripple.startRippleAnimation()
binding.retryButton.setOnClickListener {
viewModel.setStep(TransferStep.TARGET)
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,32 @@
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import xyz.quaver.pupil.databinding.TransferWaitForConnectionFragmentBinding
class TransferWaitForConnectionFragment : Fragment() {
private var _binding: TransferWaitForConnectionFragmentBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = TransferWaitForConnectionFragmentBinding.inflate(layoutInflater)
binding.ripple.startRippleAnimation()
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -21,13 +21,9 @@ 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.*
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
@@ -36,36 +32,42 @@ 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 xyz.quaver.pupil.hitomi.*
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.util.concurrent.ConcurrentHashMap
@Serializable
data class OldGalleryBlock(
data class OldReader(
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>
val galleryInfo: OldGalleryInfo
)
@Serializable
data class OldReader(val code: String, val galleryInfo: GalleryInfo)
data class OldGalleryInfo(
val language_localname: String? = null,
val language: String? = null,
val date: String? = null,
val files: List<OldGalleryFiles>,
val id: Int? = null,
val type: String? = null,
val title: String? = null
)
@Serializable
data class OldGalleryFiles(
val width: Int,
val hash: String,
val haswebp: Int = 0,
val name: String,
val height: Int,
val hasavif: Int = 0,
val hasavifsmalltn: Int? = 0
)
@Serializable
data class OldMetadata(
var galleryBlock: OldGalleryBlock? = null,
var galleryBlock: GalleryBlock? = null,
var reader: OldReader? = null,
var imageList: MutableList<String?>? = null
) {
@@ -78,17 +80,29 @@ data class Metadata(
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,
constructor(old: OldMetadata) : this(
old.galleryBlock,
old.reader?.galleryInfo?.let { oldGalleryInfo ->
GalleryInfo(
oldGalleryInfo.id.toString(),
oldGalleryInfo.title ?: "",
null,
oldGalleryInfo.language,
oldGalleryInfo.type ?: "",
oldGalleryInfo.date ?: "",
files = oldGalleryInfo.files.map {
GalleryFiles(
it.width,
it.hash,
it.haswebp,
it.name,
it.height,
it.hasavif,
it.hasavifsmalltn
)
}
)
},
old.imageList
)
@@ -96,7 +110,6 @@ data class Metadata(
}
class Cache private constructor(context: Context, val galleryID: Int) : ContextWrapper(context) {
companion object {
val instances = ConcurrentHashMap<Int, Cache>()
@@ -121,10 +134,10 @@ class Cache private constructor(context: Context, val galleryID: Int) : ContextW
kotlin.runCatching {
Json.decodeFromString<Metadata>(metadata)
}.getOrElse {
Metadata(Json.decodeFromString<OldMetadata>(metadata))
Metadata(json.decodeFromString<OldMetadata>(metadata))
}
}
}.getOrNull() ?: Metadata()
}.onFailure { it.printStackTrace() }.getOrNull() ?: Metadata()
val downloadFolder: FileX?
get() = DownloadManager.getInstance(this).getDownloadFolder(galleryID)
@@ -211,14 +224,13 @@ class Cache private constructor(context: Context, val galleryID: Int) : ContextW
metadata.imageList?.getOrNull(index)?.let { findFile(it) }
@Suppress("BlockingMethodInNonBlockingContext")
fun putImage(index: Int, fileName: String, data: InputStream) {
suspend fun putImage(index: Int, fileName: String, data: ByteArray) = coroutineScope {
val file = cacheFolder.getChild(fileName)
if (!file.exists())
file.createNewFile()
file.outputStream()?.use {
data.copyTo(it)
}
file.writeBytes(data)
setMetadata { metadata -> metadata.imageList!![index] = fileName }
}

View File

@@ -20,6 +20,7 @@ package xyz.quaver.pupil.util.downloader
import android.content.Context
import android.content.ContextWrapper
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -28,7 +29,10 @@ 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.io.util.deleteRecursively
import xyz.quaver.io.util.getChild
import xyz.quaver.io.util.readText
import xyz.quaver.io.util.writeText
import xyz.quaver.pupil.client
import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.util.Preferences
@@ -48,14 +52,12 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
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()
get() = kotlin.runCatching {
FileX(this, Preferences.get<String>("download_folder"))
}.getOrElse {
Preferences["download_folder"] = defaultDownloadFolder.uri.toString()
defaultDownloadFolder
}
private var prevDownloadFolder: FileX? = null
private var downloadFolderMapInstance: MutableMap<Int, String>? = null
@@ -64,21 +66,19 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
get() {
if (prevDownloadFolder != downloadFolder) {
prevDownloadFolder = downloadFolder
downloadFolderMapInstance = {
downloadFolderMapInstance = run {
val file = downloadFolder.getChild(".download")
val data = if (file.exists())
kotlin.runCatching {
file.readText()?.let { Json.decodeFromString<MutableMap<Int, String>>(it) }
file.readText()?.let{ Json.decodeFromString<MutableMap<Int, String>>(it) }
}.onFailure { file.delete() }.getOrNull()
else
null
data ?: {
data ?: run {
file.createNewFile()
mutableMapOf<Int, String>()
}.invoke()
}.invoke()
mutableMapOf()
}
}
}
return downloadFolderMapInstance ?: mutableMapOf()
@@ -103,14 +103,13 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
val folder = downloadFolder.getChild(name)
if (folder.exists()) return@launch
folder.mkdir()
downloadFolderMap[galleryID] = folder.name
downloadFolderMap[galleryID] = name
downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() }
downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap))
if (folder.exists()) return@launch
folder.mkdir()
}
@Synchronized

View File

@@ -18,16 +18,25 @@
package xyz.quaver.pupil.util
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.serialization.json.*
import okhttp3.OkHttpClient
import okhttp3.Request
import xyz.quaver.pupil.R
import xyz.quaver.pupil.hitomi.GalleryBlock
import xyz.quaver.pupil.hitomi.GalleryInfo
import xyz.quaver.pupil.hitomi.getReferer
import xyz.quaver.pupil.hitomi.imageUrlFromImage
import xyz.quaver.pupil.userAgent
import java.util.*
import kotlin.collections.ArrayList
@@ -79,7 +88,8 @@ fun OkHttpClient.Builder.proxyInfo(proxyInfo: ProxyInfo) = this.apply {
val formatMap = mapOf<String, GalleryBlock.() -> (String)>(
"-id-" to { id.toString() },
"-title-" to { title },
"-artist-" to { artists.joinToString() }
"-artist-" to { if (artists.isNotEmpty()) artists.joinToString() else "N/A" },
"-group-" to { if (groups.isNotEmpty()) groups.joinToString() else "N/A" }
// TODO
)
/**
@@ -100,14 +110,12 @@ fun GalleryBlock.formatDownloadFolderTest(format: String): String =
}.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
suspend fun GalleryInfo.getRequestBuilders(): List<Request.Builder> {
val galleryID = this.id ?: 0
val lowQuality = Preferences["low_quality", true]
val galleryID = this.id.toIntOrNull() ?: 0
return this.files.map {
Request.Builder()
.url(
runCatching {
imageUrlFromImage(galleryID, it, !lowQuality)
imageUrlFromImage(galleryID, it, false)
}
.onFailure {
FirebaseCrashlytics.getInstance().recordException(it)
@@ -115,15 +123,35 @@ suspend fun GalleryInfo.getRequestBuilders(): List<Request.Builder> {
.getOrDefault("https://a/")
)
.header("Referer", "https://hitomi.la/")
.header("User-Agent", userAgent)
}
}
fun String.ellipsize(n: Int): String =
if (this.length > n)
this.slice(0 until n) + ""
else
this
fun byteCount(codePoint: Int): Int = when (codePoint) {
in 0 ..< 0x80 -> 1
in 0x80 ..< 0x800 -> 2
in 0x800 ..< 0x10000 -> 3
in 0x10000 ..< 0x110000 -> 4
else -> 0
}
fun String.ellipsize(n: Int): String = buildString {
var count = 0
var index = 0
val codePointLength = this@ellipsize.codePointCount(0, this@ellipsize.length)
while (index < codePointLength) {
val nextCodePoint = this@ellipsize.codePointAt(index)
val nextByte = byteCount(nextCodePoint)
if (count + nextByte > 124) {
append("")
break
}
appendCodePoint(nextCodePoint)
count += nextByte
index++
}
}
operator fun JsonElement.get(index: Int) =
this.jsonArray[index]
@@ -136,4 +164,31 @@ fun JsonElement.getOrNull(tag: String) = kotlin.runCatching {
}.getOrNull()
val JsonElement.content
get() = this.jsonPrimitive.contentOrNull
get() = this.jsonPrimitive.contentOrNull
fun checkNotificationEnabled(context: Context) =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
fun showNotificationPermissionExplanationDialog(context: Context) {
AlertDialog.Builder(context)
.setTitle(R.string.warning)
.setMessage(R.string.notification_denied)
.setPositiveButton(android.R.string.ok) { _, _ -> }
.show()
}
fun requestNotificationPermission(
activity: Activity,
requestPermissionLauncher: ActivityResultLauncher<String>,
showRationale: Boolean = true,
ifGranted: () -> Unit,
) {
when {
checkNotificationEnabled(activity) -> ifGranted()
showRationale && ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.POST_NOTIFICATIONS) ->
showNotificationPermissionExplanationDialog(activity)
else ->
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}

View File

@@ -27,17 +27,14 @@ 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 xyz.quaver.pupil.*
import xyz.quaver.pupil.types.Tag
import java.io.File
import java.io.IOException
import java.net.URL
@@ -138,6 +135,11 @@ fun checkUpdate(context: Context, force: Boolean = false) {
val msg = extractReleaseNote(update, Locale.getDefault())
setMessage(Markwon.create(context).toMarkdown(msg))
setPositiveButton(android.R.string.ok) { _, _ ->
if (!checkNotificationEnabled(context)) {
showNotificationPermissionExplanationDialog(context)
return@setPositiveButton
}
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
//Cancel any download queued before
@@ -173,7 +175,7 @@ fun checkUpdate(context: Context, force: Boolean = false) {
}
}
fun restore(url: String, onFailure: ((Throwable) -> Unit)? = null, onSuccess: ((List<Int>) -> Unit)? = null) {
fun restore(url: String, onFailure: ((Throwable) -> Unit)? = null, onSuccess: ((Int) -> Unit)? = null) {
if (!URLUtil.isValidUrl(url)) {
onFailure?.invoke(IllegalArgumentException())
return
@@ -191,9 +193,20 @@ fun restore(url: String, onFailure: ((Throwable) -> Unit)? = null, onSuccess: ((
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)
val data = Json.parseToJsonElement(response.also { if (it.code() != 200) throw IOException() }.body().use { it?.string() } ?: "[]")
when (data) {
is JsonArray -> favorites.addAll(data.map { it.jsonPrimitive.int })
is JsonObject -> {
val newFavorites = data["favorites"]?.let { Json.decodeFromJsonElement<List<Int>>(it) }.orEmpty()
val newFavoriteTags = data["favorite_tags"]?.let { Json.decodeFromJsonElement<List<Tag>>(it) }.orEmpty()
favorites.addAll(newFavorites)
favoriteTags.addAll(newFavoriteTags)
onSuccess?.invoke(favorites.size + favoriteTags.size)
}
else -> error("data is neither JsonArray or JsonObject")
}
}.onFailure { onFailure?.invoke(it) }
}

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4.5,10.5 L12,3m0,0 l7.5,7.5M12,3v18"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="@color/colorPrimaryDark"
android:strokeLineCap="round"/>
</vector>

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9,12.75 L11.25,15 15,9.75M21,12a9,9 0,1 1,-18 0,9 9,0 0,1 18,0Z"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="@color/colorPrimaryDark"
android:strokeLineCap="round"/>
</vector>

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,16.5v2.25A2.25,2.25 0,0 0,5.25 21h13.5A2.25,2.25 0,0 0,21 18.75V16.5M16.5,12 L12,16.5m0,0L7.5,12m4.5,4.5V3"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="@color/material_blue_700"
android:strokeLineCap="round"/>
</vector>

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M21,8.25c0,-2.485 -2.099,-4.5 -4.688,-4.5 -1.935,0 -3.597,1.126 -4.312,2.733 -0.715,-1.607 -2.377,-2.733 -4.313,-2.733C5.1,3.75 3,5.765 3,8.25c0,7.22 9,12 9,12s9,-4.78 9,-12Z"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="@color/material_pink_600"
android:strokeLineCap="round"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@color/material_orange_500"
android:pathData="M477,840q-142,0 -243.5,-95.5T121,509q-1,-12 7.5,-21t21.5,-9q12,0 20.5,8.5T181,509q11,115 95,193t201,78q127,0 215,-89t88,-216q0,-124 -89,-209.5T477,180q-68,0 -127.5,31T246,293h75q13,0 21.5,8.5T351,323q0,13 -8.5,21.5T321,353L172,353q-13,0 -21.5,-8.5T142,323v-148q0,-13 8.5,-21.5T172,145q13,0 21.5,8.5T202,175v76q52,-61 123.5,-96T477,120q75,0 141,28t115.5,76.5Q783,273 811.5,338T840,478q0,75 -28.5,141t-78,115Q684,783 618,811.5T477,840ZM511,466 L626,579q9,9 9,21.5t-9,21.5q-9,9 -21,9t-21,-9L460,500q-5,-5 -7,-10.5t-2,-11.5v-171q0,-13 8.5,-21.5T481,277q13,0 21.5,8.5T511,307v159Z"/>
</vector>

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.19,8.688a4.5,4.5 0,0 1,1.242 7.244l-4.5,4.5a4.5,4.5 0,0 1,-6.364 -6.364l1.757,-1.757m13.35,-0.622 l1.757,-1.757a4.5,4.5 0,0 0,-6.364 -6.364l-4.5,4.5a4.5,4.5 0,0 0,1.242 7.244"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="@color/colorPrimaryDark"
android:strokeLineCap="round"/>
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="false">
<shape android:shape="oval">
<solid android:color="#dddddd"/>
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="oval">
<solid android:color="#888888"/>
</shape>
</item>
</selector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:bottom="-8dp">
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="32dp"
android:bottomLeftRadius="0dp"
android:bottomRightRadius="0dp" />
<stroke android:width="4dp" android:color="#444444"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android" android:color="#9d9d9d">
<item android:id="@android:id/mask">
<shape android:shape="rectangle">
<solid android:color="#9d9d9d" />
<corners android:radius="16dp" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,9v3.75m-9.303,3.376c-0.866,1.5 0.217,3.374 1.948,3.374h14.71c1.73,0 2.813,-1.874 1.948,-3.374L13.949,3.378c-0.866,-1.5 -3.032,-1.5 -3.898,0L2.697,16.126ZM12,15.75h0.007v0.008H12v-0.008Z"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="@color/colorAccent"
android:strokeLineCap="round"/>
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragment_container_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="xyz.quaver.pupil.ui.fragment.TransferDirectionFragment" />

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="48dp"
app:srcCompat="@drawable/check"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Device connected"
android:textAlignment="center"
android:textSize="28sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@id/icon" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="80dp"
android:text="Transfer your data"
android:textAlignment="center"
android:textSize="28sp"
android:textStyle="bold"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/device"
android:layout_width="match_parent"
android:layout_height="300dp"
android:layout_marginHorizontal="64dp"
app:layout_constraintBottom_toBottomOf="parent"
android:background="@drawable/transfer_device"/>
<LinearLayout
android:id="@+id/out_button"
android:layout_width="100dp"
android:layout_height="100dp"
android:orientation="vertical"
android:gravity="center"
android:clickable="true"
android:focusable="true"
android:background="@drawable/transfer_ripple"
app:layout_constraintBottom_toTopOf="@id/device"
app:layout_constraintLeft_toLeftOf="@id/device"
app:layout_constraintRight_toRightOf="@id/device"
android:layout_marginBottom="32dp">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/out_arrow"
android:layout_width="48dp"
android:layout_height="48dp"
app:srcCompat="@drawable/arrow"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/out_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Send data"
android:textStyle="bold"
android:layout_marginTop="4dp"/>
</LinearLayout>
<LinearLayout
android:id="@+id/in_button"
android:layout_width="100dp"
android:layout_height="100dp"
android:orientation="vertical"
android:gravity="center"
android:clickable="true"
android:focusable="true"
android:background="@drawable/transfer_ripple"
app:layout_constraintTop_toTopOf="@id/device"
app:layout_constraintLeft_toLeftOf="@id/device"
app:layout_constraintRight_toRightOf="@id/device"
android:layout_marginTop="32dp">
<TextView
android:id="@+id/in_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Receive data"
android:textStyle="bold"
android:layout_marginBottom="4dp"/>
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/in_arrow"
android:layout_width="48dp"
android:layout_height="48dp"
app:srcCompat="@drawable/arrow"
android:layout_marginTop="4dp"
android:rotation="180"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:padding="16dp">
<TextView
android:id="@+id/device_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Gonsales Vorecek's Galaxy S22 Ultra"
android:textStyle="bold"
android:textSize="24sp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_marginBottom="4dp"/>
<TextView
android:id="@+id/device_address"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="26:7D:2D:3A:4F:5E"
android:textSize="16sp"
app:layout_constraintTop_toBottomOf="@id/device_name"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_marginTop="4dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="48dp"
app:srcCompat="@drawable/warning"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Permissions Missing"
android:textAlignment="center"
android:textSize="28sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@id/icon" />
<com.google.android.material.button.MaterialButton
android:id="@+id/permissions_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Grant Permissions"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_marginHorizontal="36dp"
android:layout_marginVertical="64dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,196 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="48dp"
app:srcCompat="@drawable/check"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Choose data to transfer"
android:textAlignment="center"
android:textSize="28sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@id/icon" />
<CheckBox
android:id="@+id/check_all"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="32dp"
android:layout_marginTop="32dp" />
<TextView
android:id="@+id/check_all_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="All"
app:layout_constraintTop_toBottomOf="@id/check_all"
app:layout_constraintLeft_toLeftOf="@id/check_all"
app:layout_constraintRight_toRightOf="@id/check_all"
android:layout_marginTop="-8dp"/>
<TextView
android:id="@+id/selected_count"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:text="3 items selected"
android:textStyle="bold"
android:textSize="24sp"
android:gravity="center_vertical"
app:layout_constraintTop_toTopOf="@id/check_all"
app:layout_constraintBottom_toTopOf="@id/selected_size"
app:layout_constraintStart_toEndOf="@id/check_all" />
<TextView
android:id="@+id/selected_size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="37.8 GB / About 28 minutes"
android:gravity="center_vertical"
app:layout_constraintTop_toTopOf="@id/check_all_label"
app:layout_constraintBottom_toBottomOf="@id/check_all_label"
app:layout_constraintStart_toStartOf="@id/selected_count" />
<View
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/dividerHorizontal"
app:layout_constraintTop_toBottomOf="@id/check_all_label"
android:layout_marginVertical="16dp"
android:layout_marginHorizontal="16dp"/>
<CheckBox
android:id="@+id/check_favorites"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/divider"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_marginStart="32dp"
android:layout_marginTop="32dp"/>
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/favorites_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="16dp"
app:srcCompat="@drawable/heart"
app:layout_constraintStart_toEndOf="@id/check_favorites"
app:layout_constraintTop_toTopOf="@id/check_favorites"
app:layout_constraintBottom_toBottomOf="@id/check_favorites"/>
<TextView
android:id="@+id/favorites_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Favorites"
android:textSize="28sp"
app:layout_constraintTop_toTopOf="@id/favorites_icon"
app:layout_constraintBottom_toTopOf="@id/favorites_count"
app:layout_constraintStart_toEndOf="@id/favorites_icon"
android:layout_marginStart="16dp" />
<TextView
android:id="@+id/favorites_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="275 items"
app:layout_constraintTop_toBottomOf="@id/favorites_label"
app:layout_constraintBottom_toBottomOf="@id/favorites_icon"
app:layout_constraintStart_toStartOf="@id/favorites_label" />
<CheckBox
android:id="@+id/check_history"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/check_favorites"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_marginStart="32dp"
android:layout_marginTop="32dp"/>
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/history_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="16dp"
app:srcCompat="@drawable/history_rounded"
app:layout_constraintStart_toEndOf="@id/check_history"
app:layout_constraintTop_toTopOf="@id/check_history"
app:layout_constraintBottom_toBottomOf="@id/check_history"/>
<TextView
android:id="@+id/history_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="History"
android:textSize="28sp"
app:layout_constraintTop_toTopOf="@id/history_icon"
app:layout_constraintBottom_toTopOf="@id/history_count"
app:layout_constraintStart_toEndOf="@id/history_icon"
android:layout_marginStart="16dp" />
<TextView
android:id="@+id/history_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="2375 items"
app:layout_constraintTop_toBottomOf="@id/history_label"
app:layout_constraintBottom_toBottomOf="@id/history_icon"
app:layout_constraintStart_toStartOf="@id/history_label" />
<CheckBox
android:id="@+id/check_downloads"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/check_history"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_marginStart="32dp"
android:layout_marginTop="32dp"/>
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/downloads_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="16dp"
app:srcCompat="@drawable/download"
app:layout_constraintStart_toEndOf="@id/check_downloads"
app:layout_constraintTop_toTopOf="@id/check_downloads"
app:layout_constraintBottom_toBottomOf="@id/check_downloads"/>
<TextView
android:id="@+id/downloads_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Downloads"
android:textSize="28sp"
app:layout_constraintTop_toTopOf="@id/downloads_icon"
app:layout_constraintBottom_toTopOf="@id/downloads_count"
app:layout_constraintStart_toEndOf="@id/downloads_icon"
android:layout_marginStart="16dp" />
<TextView
android:id="@+id/downloads_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="881 items"
app:layout_constraintTop_toBottomOf="@id/downloads_label"
app:layout_constraintBottom_toBottomOf="@id/downloads_icon"
app:layout_constraintStart_toStartOf="@id/downloads_label" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="48dp"
app:srcCompat="@drawable/link"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Connect to your device"
android:textAlignment="center"
android:textSize="28sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@id/icon" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/device_list"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:layout_marginHorizontal="32dp"
android:layout_marginVertical="32dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintBottom_toBottomOf="parent" />
<com.skyfishjy.library.RippleBackground
android:id="@+id/ripple"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:rb_color="@color/colorPrimaryDark"
app:rb_radius="32dp"
app:rb_type="strokeRipple"/>
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/retry_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Retry"
style="@style/Widget.AppCompat.Button.Borderless"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:visibility="invisible"
android:layout_margin="32dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="48dp"
app:srcCompat="@drawable/link"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Wait for connection"
android:textAlignment="center"
android:textSize="28sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@id/icon" />
<com.skyfishjy.library.RippleBackground
android:id="@+id/ripple"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="1"
app:layout_constraintTop_toTopOf="@id/barrier"
app:layout_constraintBottom_toBottomOf="@id/barrier"
app:rb_color="@color/colorPrimaryDark"
app:rb_radius="32dp"
app:rb_type="strokeRipple"/>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="device" />
<View
android:id="@+id/device"
android:layout_width="match_parent"
android:layout_height="300dp"
android:layout_marginHorizontal="64dp"
app:layout_constraintBottom_toBottomOf="parent"
android:background="@drawable/transfer_device"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,31 @@
-----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-----

View File

@@ -22,6 +22,7 @@
<string name="settings_clear_history_alert_message">履歴を削除しますか?</string>
<string name="settings_clear_history_summary">履歴数: %1$d</string>
<string name="main_drawer_history">履歴</string>
<string name="notification_denied">通知を無効にするとバックグラウンドダウンロード及びアプリのアップデート機能が使用不可になります。</string>
<string name="main_drawer_home">トップ</string>
<string name="update_release_note"># リリースノート(v%1$s)\n%2$s</string>
<string name="settings_security_mode_title">セキュリティーモード</string>
@@ -47,6 +48,7 @@
<string name="main_drawer_downloads">ダウンロード</string>
<string name="main_jump_title">ページ移動</string>
<string name="main_jump_message">現ページ番号: %1$d\nページ数: %2$d</string>
<string name="channel_transfer">転送</string>
<string name="unable_to_connect">hitomi.laに接続できません</string>
<string name="main_move_to_page">%1$dページへ移動</string>
<string name="settings_clear_downloads">ダウンロード削除</string>
@@ -157,5 +159,7 @@
<string name="settings_max_concurrent_download">並列ダウンロード</string>
<string name="unaccessible_download_folder">アンドロイド11以上では外部からのアプリ内部空間接近が不可能です。ダウンロードフォルダを変更しますか</string>
<string name="settings_networking">ネットワーク</string>
<string name="old_webview">WebViewのアップデートが必要です</string>
<string name="settings_recover_downloads">ダウンロードデータベースを再構築</string>
<string name="settings_transfer_data">他の機器にデータを転送</string>
<string name="channel_transfer_description">他の機器へのデータ転送の進捗度を表示</string>
</resources>

View File

@@ -21,6 +21,7 @@
<string name="settings_clear_history_alert_message">기록을 삭제하시겠습니까?</string>
<string name="settings_clear_history_summary">기록 %1$d개 저장됨</string>
<string name="main_drawer_history">기록</string>
<string name="notification_denied">백그라운드 다운로드를 위해서는 알림을 활성화할 필요가 있습니다. 알림을 비활성화하면 백그라운드 다운로드와 앱 업데이트 기능을 사용할 수 없습니다.</string>
<string name="main_drawer_home"></string>
<string name="update_release_note"># 릴리즈 노트(v%1$s)\n%2$s</string>
<string name="settings_security_mode_summary">최근 앱 목록 창에서 앱 화면을 보이지 않게 합니다</string>
@@ -46,6 +47,7 @@
<string name="main_drawer_downloads">다운로드</string>
<string name="main_jump_title">페이지 이동</string>
<string name="main_jump_message">현재 페이지: %1$d\n페이지 수: %2$d</string>
<string name="channel_transfer">전송</string>
<string name="unable_to_connect">hitomi.la에 연결할 수 없습니다</string>
<string name="main_move_to_page">%1$d 페이지로 이동</string>
<string name="settings_clear_downloads">다운로드 삭제</string>
@@ -157,5 +159,7 @@
<string name="settings_max_concurrent_download">병렬 다운로드</string>
<string name="unaccessible_download_folder">안드로이드 11 이상에서는 외부에서 현재 다운로드 폴더에 접근할 수 없습니다. 변경하시겠습니까?</string>
<string name="settings_networking">네트워크</string>
<string name="old_webview">WebView 업데이트가 필요합니다</string>
<string name="settings_recover_downloads">다운로드 데이터베이스 복구</string>
<string name="settings_transfer_data">다른 기기에 데이터 전송</string>
<string name="channel_transfer_description">다른 기기에 데이터 전송 시 상태 표시</string>
</resources>

View File

@@ -43,6 +43,9 @@
<string name="channel_update">Update</string>
<string name="channel_update_description">Shows update progress</string>
<string name="channel_transfer">Transfer</string>
<string name="channel_transfer_description">Shows progress of transferring data to another device</string>
<string name="unable_to_connect">Unable to connect to hitomi.la</string>
<string name="lock_corrupted">Lock file corrupted! Please re-install Pupil</string>
@@ -51,7 +54,7 @@
<string name="unaccessible_download_folder">From Android 11 and above, current Download folder cannot be accessed by outside apps. Would you like to change the download folder?</string>
<string name="old_webview">You are using an old version of WebView. Please update it on PlayStore</string>
<string name="notification_denied">Notification permission is required for background downloads. If you deny notifications from this app, in-app update and background download will be disabled.</string>
<string name="main_drawer_home">Home</string>
<string name="main_drawer_history">History</string>
@@ -152,6 +155,7 @@
<string name="settings_storage_usage_loading">Calculating storage usage…</string>
<string name="settings_clear_cache">Clear cache</string>
<string name="settings_clear_cache_alert_message">Deleting cache can affect image loading speed. Do you want to continue?</string>
<string name="settings_recover_downloads">Reconstruct download database</string>
<string name="settings_clear_downloads">Clear downloads</string>
<string name="settings_clear_downloads_alert_message">Delete all downloaded galleries.\nDo you want to continue?</string>
<string name="settings_clear_history">Clear history</string>
@@ -173,6 +177,7 @@
<string name="settings_nomedia_title">Hide image from gallery</string>
<string name="settings_low_quality">Low quality images</string>
<string name="settings_low_quality_summary">Load low quality images to improve load speed and data usage</string>
<string name="settings_transfer_data">Transfer data to another device</string>
<!-- SETTINGS/APP LOCK -->

View File

@@ -18,6 +18,7 @@
-->
<paths>
<external-path name="external" path="/"/>
<external-files-path name="files" path="/"/>
<external-path name="external" path="."/>
<external-files-path name="files" path="."/>
<files-path name="files" path="." />
</paths>

View File

@@ -27,6 +27,11 @@
app:key="delete_downloads"
app:title="@string/settings_clear_downloads"/>
<Preference
app:key="recover_downloads"
app:title="@string/settings_recover_downloads"
app:iconSpaceReserved="true"/>
<Preference
app:key="clear_history"
app:title="@string/settings_clear_history"/>

View File

@@ -18,6 +18,9 @@
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">ix.io</domain>
</domain-config>

View File

@@ -52,16 +52,14 @@
app:defaultValue="8"
app:useSimpleSummaryProvider="true"/>
<Preference
app:key="transfer_data"
app:title="@string/settings_transfer_data"/>
<SwitchPreferenceCompat
app:key="nomedia"
app:title="@string/settings_nomedia_title"/>
<SwitchPreferenceCompat
app:key="low_quality"
app:title="@string/settings_low_quality"
app:summary="@string/settings_low_quality_summary"
app:defaultValue="true"/>
</PreferenceCategory>
<PreferenceCategory

View File

@@ -26,15 +26,8 @@ package xyz.quaver.pupil
* See [testing documentation](http://d.android.com/tools/testing).
*/
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import xyz.quaver.pupil.hitomi.getGalleryInfo
import xyz.quaver.pupil.hitomi.imageUrlFromImage
import java.lang.reflect.ParameterizedType
import java.util.concurrent.TimeUnit
class ExampleUnitTest {
@Test

View File

@@ -6,16 +6,16 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.0'
classpath 'com.android.tools.build:gradle:7.3.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-android-extensions:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath "com.google.gms:google-services:4.3.10"
classpath "com.google.gms:google-services:4.3.15"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
classpath "com.google.firebase:firebase-crashlytics-gradle:2.8.1"
classpath "com.google.firebase:perf-plugin:1.4.1"
classpath "com.google.android.gms:oss-licenses-plugin:0.10.4"
classpath "com.google.firebase:firebase-crashlytics-gradle:2.9.9"
classpath "com.google.firebase:perf-plugin:1.4.2"
classpath "com.google.android.gms:oss-licenses-plugin:0.10.6"
}
}
@@ -26,6 +26,7 @@ allprojects {
jcenter()
maven { url "https://jitpack.io" }
maven { url "https://guardian.github.io/maven/repo-releases/" }
maven { url "https://oss.sonatype.org/content/repositories/snapshots/" }
}
}

View File

@@ -20,4 +20,4 @@ kotlin.code.style=official
android.enableJetifier=true
android.useAndroidX=true
kotlin_version=1.6.10
kotlin_version=1.9.0

View File

@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip

0
gradlew vendored Executable file → Normal file
View File