Compare commits

...

32 Commits

Author SHA1 Message Date
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
45 changed files with 944 additions and 917 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,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="$USER_HOME$/.android/avd/Pixel_2_API_31.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2022-01-31T00:48:12.732208Z" />
</component>
</project>

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>

20
.idea/misc.xml generated
View File

@@ -1,20 +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/manage_storage_preferences.xml" value="0.2604166666666667" />
<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.23/Pupil-v5.2.23.apk?color=%234fc3f7&label=DOWNLOAD%20APP&style=for-the-badge)](https://github.com/tom5079/Pupil/releases/download/5.2.23/Pupil-v5.2.23.apk)
[![](https://img.shields.io/github/downloads/tom5079/Pupil/5.3.5/Pupil-v5.3.5.apk?color=%234fc3f7&label=DOWNLOAD%20APP&style=for-the-badge)](https://github.com/tom5079/Pupil/releases/download/5.3.5/Pupil-v5.3.5.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
compileSdkVersion 32
defaultConfig {
applicationId "xyz.quaver.pupil"
minSdkVersion 16
targetSdkVersion 31
targetSdkVersion 32
versionCode 69
versionName "5.2.24"
versionName "5.3.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
@@ -67,31 +67,31 @@ 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.6.1"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2"
implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.3.2"
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.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"
@@ -103,7 +103,7 @@ dependencies {
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-mlkit-face-detection:17.0.1"
implementation "com.github.clans:fab:1.6.4"
@@ -133,7 +133,7 @@ dependencies {
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

@@ -34,3 +34,4 @@
-keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment
-keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment
-keep class xyz.quaver.pupil.** { *; }
-keep class app.cash.zipline.** { *; }

View File

@@ -12,7 +12,7 @@
"filters": [],
"attributes": [],
"versionCode": 69,
"versionName": "5.2.23",
"versionName": "5.3.5",
"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

@@ -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,27 +27,25 @@ 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
@@ -79,152 +76,15 @@ 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 oldWebView = false
private var reloadJob: Job? = null
fun reloadWebView() {
if (reloadJob?.isActive == true) return
reloadJob = CoroutineScope(Dispatchers.IO).launch {
webViewReady = 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()
}.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 (
(!webViewReady && !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) 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("try { self_test() } catch (err) { 'err' }") {
val result: String = Json.decodeFromString(it)
oldWebView = result == "es2020_unsupported";
webViewReady = 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")
}
reloadWhenFailedOrUpdate()
}
lateinit var userAgent: String
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)
@@ -234,6 +94,7 @@ class Pupil : Application() {
else userID
}
FirebaseApp.initializeApp(this)
FirebaseCrashlytics.getInstance().setUserId(userID)
val proxyInfo = getProxyInfo()
@@ -244,7 +105,7 @@ class Pupil : Application() {
.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()

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 { withTimeout(60000) {
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) delay(1000)
return client.newCall(request).execute().also{ if (it.code() != 200) throw IOException("CODE ${it.code()}") }.body()?.use { it.string() } ?: throw IOException()
}
result = if (oldWebView)
"null"
else withContext(evaluationContext) {
suspendCoroutine { continuation ->
evaluateJavascript(script) {
continuation.resume(it)
}
}
fun URL.readBytes(settings: HeaderSetter? = null): ByteArray {
val request = Request.Builder()
.url(this).let {
settings?.invoke(it) ?: it
}.build()
}
} catch (e: CancellationException) {
if (e.message != "reload") result = "null"
}
}
json.decodeFromString(result)
} }
@OptIn(ExperimentalCoroutinesApi::class)
suspend inline fun <reified T> WebView.evaluatePromise(
script: String,
then: String = ".then(result => Callback.onResult(%uid, JSON.stringify(result))).catch(err => Callback.onError(%uid, String.raw`$script`, err.message, err.stack))"
): T = coroutineScope { withTimeout(60000) {
var result: String? = null
while (result == null) {
try {
while (!oldWebView && !webViewReady) delay(1000)
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,313 @@
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 okhttp3.Request
import xyz.quaver.pupil.client
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") }
@Serializable
data class Suggestion(val s: String, val t: Int, val u: String, val n: String)
@OptIn(ExperimentalSerializationApi::class)
suspend fun getSuggestionsForQuery(query: String) : List<Suggestion> =
webView.evaluatePromise(
"get_suggestions_for_query('$query', ++search_serial)",
then = """
.then(r => {
let [results, results_serial] = r;
if (search_serial !== results_serial) {
Callback.onResult(%uid, '[]');
} else {
Callback.onResult(%uid, JSON.stringify(results));
}
});
""".trimIndent()
)
@OptIn(ExperimentalSerializationApi::class)
suspend fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : Set<Int> {
val jsArea = if (area == null) "null" else "'$area'"
return webView.evaluatePromise("""get_galleryids_from_nozomi($jsArea, '$tag', '$language')""")
fun sha256(data: ByteArray) : ByteArray {
return MessageDigest.getInstance("SHA-256").digest(data)
}
@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
}
"language" -> {
area = null
language = tag
tag = "index"
}
}
return getGalleryIDsFromNozomi(area, 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 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 key = hashTerm(term)
val node = getNodeAtAddress(field, 0) ?: return emptyList()
val data = bSearch(field, key, node)
if (data != null)
return getSuggestionsFromData(field, data)
return emptyList()
}
}
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 = try {
URL(nozomiAddress).readBytes()
} catch (e: Exception) {
return emptySet()
}
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

@@ -220,28 +220,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,7 +324,8 @@ class DownloadService : Service() {
}
if (isCompleted(galleryID)) {
Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
if (DownloadManager.getInstance(this@DownloadService).getDownloadFolder(galleryID) != null)
Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
notificationManager.cancel(galleryID)
startId?.let { stopSelf(it) }

View File

@@ -19,14 +19,12 @@
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
@@ -38,7 +36,6 @@ import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat
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 +44,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
@@ -66,7 +63,10 @@ import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.restore
import java.util.regex.Pattern
import kotlin.math.*
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
class MainActivity :
BaseActivity(),
@@ -107,8 +107,6 @@ class MainActivity :
private lateinit var binding: MainActivityBinding
private var oldWebViewJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = MainActivityBinding.inflate(layoutInflater)
@@ -120,7 +118,7 @@ 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()
}
)
}
@@ -129,39 +127,6 @@ class MainActivity :
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 +169,6 @@ class MainActivity :
override fun onDestroy() {
super.onDestroy()
oldWebViewJob?.cancel()
(binding.contents.recyclerview.adapter as? GalleryBlockAdapter)?.updateAll = false
}

View File

@@ -19,21 +19,30 @@
package xyz.quaver.pupil.ui.fragment
import android.content.Intent
import android.content.res.Resources
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.Bundle
import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
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.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import okhttp3.*
import xyz.quaver.pupil.R
import xyz.quaver.pupil.client
import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.util.restore
import java.io.File
import java.io.IOException
import kotlin.math.roundToInt
class ManageFavoritesFragment : PreferenceFragmentCompat() {
@@ -47,21 +56,62 @@ class ManageFavoritesFragment : PreferenceFragmentCompat() {
val context = context ?: return
findPreference<Preference>("backup")?.setOnPreferenceClickListener {
val iconSize = (24 * Resources.getSystem().displayMetrics.density).roundToInt()
val strokeWidth = (3 * Resources.getSystem().displayMetrics.density)
val icon = object: CircularProgressDrawable(context) {
override fun getIntrinsicHeight(): Int {
return iconSize
}
override fun getIntrinsicWidth(): Int {
return iconSize
}
}
icon.strokeWidth = strokeWidth
icon.colorFilter = PorterDuffColorFilter(ContextCompat.getColor(context, R.color.colorAccent), PorterDuff.Mode.SRC_IN)
DrawableCompat.setTint(icon, ContextCompat.getColor(context, R.color.colorAccent))
icon.start()
it.icon = icon
val favorites = runCatching {
Json.parseToJsonElement(File(ContextCompat.getDataDir(context), "favorites.json").readText())
}.getOrNull()
val favoriteTags = kotlin.runCatching {
Json.parseToJsonElement(File(ContextCompat.getDataDir(context), "favorites_tags.json").readText())
}.getOrNull()
val request = Request.Builder()
.url(context.getString(R.string.backup_url))
.post(
FormBody.Builder()
.add("f:1", File(ContextCompat.getDataDir(context), "favorites.json").readText())
.add("f:1", buildJsonObject {
favorites?.let {
put("favorites", it)
}
favoriteTags?.let {
put("favorite_tags", it)
}
}.toString())
.build()
).build()
client.newCall(request).enqueue(object: Callback {
override fun onFailure(call: Call, e: IOException) {
val view = view ?: return
MainScope().launch {
it.icon = null
}
Snackbar.make(view, R.string.settings_backup_failed, Snackbar.LENGTH_LONG).show()
}
override fun onResponse(call: Call, response: Response) {
MainScope().launch {
it.icon = null
}
if (response.code() != 200) {
response.close()
return
@@ -93,7 +143,7 @@ class ManageFavoritesFragment : PreferenceFragmentCompat() {
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()
Snackbar.make(view, context.getString(R.string.settings_restore_success, it), Snackbar.LENGTH_LONG).show()
})
}.setNegativeButton(android.R.string.cancel) { _, _ ->
// Do Nothing

View File

@@ -43,6 +43,7 @@ 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
@@ -60,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")
@@ -118,10 +117,10 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
if (!metadataFile.exists()) return@forEach
val metadata = metadataFile.readText()?.let {
Json.decodeFromString<Metadata>(it)
json.decodeFromString<Metadata>(it)
} ?: return@forEach
val galleryID = metadata.galleryBlock?.id ?: metadata.galleryInfo?.id ?: return@forEach
val galleryID = metadata.galleryBlock?.id ?: metadata.galleryInfo?.id?.toIntOrNull() ?: return@forEach
downloadFolderMap[galleryID] = folder.name
}

View File

@@ -80,10 +80,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)
@@ -122,10 +120,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 +159,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,7 +296,7 @@ class SettingsFragment :
}
"oss" -> {
setOnPreferenceClickListener {
context?.startActivity(Intent(context, OssLicensesMenuActivity::class.java))
context.startActivity(Intent(context, OssLicensesMenuActivity::class.java))
true
}
}

View File

@@ -21,12 +21,9 @@ package xyz.quaver.pupil.util.downloader
import android.content.Context
import android.content.ContextWrapper
import android.net.Uri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
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
@@ -35,35 +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.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
) {
@@ -76,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
)
@@ -94,7 +110,6 @@ data class Metadata(
}
class Cache private constructor(context: Context, val galleryID: Int) : ContextWrapper(context) {
companion object {
val instances = ConcurrentHashMap<Int, Cache>()
@@ -119,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)
@@ -209,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 }
}
@@ -229,9 +243,6 @@ class Cache private constructor(context: Context, val galleryID: Int) : ContextW
return@launch
(lock[galleryID] ?: Mutex().also { lock[galleryID] = it }).withLock {
if (downloadFolder.exists()) downloadFolder.deleteRecursively()
downloadFolder.mkdir()
val cacheMetadata = cacheFolder.getChild(".metadata")
val downloadMetadata = downloadFolder.getChild(".metadata")

View File

@@ -25,9 +25,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import xyz.quaver.pupil.hitomi.GalleryBlock
import xyz.quaver.pupil.hitomi.GalleryInfo
import xyz.quaver.pupil.hitomi.getReferer
import xyz.quaver.pupil.hitomi.imageUrlFromImage
import xyz.quaver.pupil.userAgent
import java.util.*
import kotlin.collections.ArrayList
@@ -79,7 +77,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 +99,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,7 +112,6 @@ suspend fun GalleryInfo.getRequestBuilders(): List<Request.Builder> {
.getOrDefault("https://a/")
)
.header("Referer", "https://hitomi.la/")
.header("User-Agent", userAgent)
}
}

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
@@ -173,7 +170,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 +188,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

@@ -157,6 +157,5 @@
<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>
</resources>

View File

@@ -157,6 +157,5 @@
<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>
</resources>

View File

@@ -51,8 +51,6 @@
<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="main_drawer_home">Home</string>
<string name="main_drawer_history">History</string>
<string name="main_drawer_downloads">Downloads</string>

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

@@ -56,12 +56,6 @@
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,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.0'
classpath 'com.android.tools.build:gradle:7.1.3'
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"
@@ -15,7 +15,7 @@ buildscript {
// 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.android.gms:oss-licenses-plugin:0.10.5"
}
}
@@ -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/" }
}
}

0
gradlew vendored Executable file → Normal file
View File