Compare commits

...

21 Commits

Author SHA1 Message Date
tom5079
e498efc493 Fixed Download location dialog keep popping up 2022-01-08 10:13:20 +09:00
tom5079
74bbc71741 Fixed thumbnail not loading 2022-01-08 10:06:45 +09:00
tom5079
502b4890e3 5.2.8 Fix for loading not finishing 2022-01-07 18:47:07 +09:00
tom5079
dfb60461e4 Merge remote-tracking branch 'origin/master' 2022-01-05 20:20:08 +09:00
tom5079
bd6bc418e6 5.2.8-BETA01 potential fix for loading not finishing 2022-01-05 20:19:00 +09:00
tom5079
a284143ce1 Update README.md 2022-01-04 23:18:16 +09:00
tom5079
1f1c782772 5.2.7 Fix app crashing on Android 12 2022-01-04 23:15:34 +09:00
tom5079
5c0f5fe333 5.2.6 Dependency update & Fixes the bug (The problem was kotlinx.serialization!!!) 2022-01-04 22:50:16 +09:00
tom5079
748e023fde 5.2.5 Added logging to fix app crashing 2022-01-04 20:30:45 +09:00
tom5079
30104bacd2 Update README.md 2022-01-04 20:16:41 +09:00
tom5079
f33d1a1bfa 5.2.4 Added logging to fix app crashing 2022-01-04 20:16:04 +09:00
tom5079
3c08331441 5.2.3 Added logging to fix app crashing 2022-01-04 19:57:00 +09:00
tom5079
3eaa38247b 5.2.2 Fixed app crashing 2022-01-04 19:10:58 +09:00
tom5079
304ce643f9 Update README.md 2022-01-03 17:15:56 +09:00
tom5079
b4ad994f95 Create watchdiff.yml 2022-01-03 15:36:00 +09:00
tom5079
03c5cfa791 Fixed image not loading 2022-01-03 14:46:22 +09:00
tom5079
e8056072b8 Merge remote-tracking branch 'origin/master' 2022-01-03 14:31:50 +09:00
tom5079
d134639a5f Update README.md 2022-01-03 11:45:55 +09:00
tom5079
b4745d76b8 Update README.md 2022-01-03 09:18:38 +09:00
tom5079
c5fd674020 User-Agent 2022-01-03 00:00:37 +09:00
tom5079
1b441f6aea Migrate to coroutine 2022-01-02 20:32:00 +09:00
27 changed files with 496 additions and 579 deletions

23
.github/workflows/watchdiff.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
# 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"

2
.idea/compiler.xml generated
View File

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

17
.idea/deploymentTargetDropDown.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?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-08T00:45:48.713777Z" />
</component>
</project>

2
.idea/gradle.xml generated
View File

@@ -7,6 +7,8 @@
<option name="testRunner" value="GRADLE" /> <option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" /> <option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="/usr/share/java/gradle" />
<option name="gradleJvm" value="11" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

9
.idea/misc.xml generated
View File

@@ -1,6 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK"> <component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="app/src/main/res/layout/reader_activity.xml" value="0.14351851851851852" />
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View File

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

View File

@@ -32,13 +32,13 @@ configurations {
} }
android { android {
compileSdkVersion 30 compileSdkVersion 31
defaultConfig { defaultConfig {
applicationId "xyz.quaver.pupil" applicationId "xyz.quaver.pupil"
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 30 targetSdkVersion 31
versionCode 69 versionCode 69
versionName "5.1.32" versionName "5.2.8"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
} }
@@ -79,30 +79,30 @@ android {
dependencies { dependencies {
implementation fileTree(dir: "libs", include: ["*.jar", "*.aar"]) implementation fileTree(dir: "libs", include: ["*.jar", "*.aar"])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.0" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2"
implementation "androidx.appcompat:appcompat:1.3.0" implementation "androidx.appcompat:appcompat:1.4.0"
implementation "androidx.activity:activity-ktx:1.3.0-beta01" implementation "androidx.activity:activity-ktx:1.4.0"
implementation "androidx.fragment:fragment-ktx:1.3.4" implementation "androidx.fragment:fragment-ktx:1.4.0"
implementation "androidx.preference:preference-ktx:1.1.1" implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.recyclerview:recyclerview:1.2.1" implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.constraintlayout:constraintlayout:2.0.4" implementation "androidx.constraintlayout:constraintlayout:2.1.2"
implementation "androidx.gridlayout:gridlayout:1.0.0" implementation "androidx.gridlayout:gridlayout:1.0.0"
implementation "androidx.biometric:biometric:1.1.0" implementation "androidx.biometric:biometric:1.1.0"
implementation "androidx.work:work-runtime-ktx:2.6.0-beta01" implementation "androidx.work:work-runtime-ktx:2.7.1"
implementation "com.daimajia.swipelayout:library:1.2.0@aar" implementation "com.daimajia.swipelayout:library:1.2.0@aar"
implementation "com.google.android.material:material:1.3.0" implementation "com.google.android.material:material:1.4.0"
implementation platform('com.google.firebase:firebase-bom:26.5.0') implementation platform('com.google.firebase:firebase-bom:29.0.3')
implementation "com.google.firebase:firebase-analytics-ktx" implementation "com.google.firebase:firebase-analytics-ktx"
implementation "com.google.firebase:firebase-crashlytics-ktx" implementation "com.google.firebase:firebase-crashlytics-ktx"
implementation "com.google.firebase:firebase-perf-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-oss-licenses:17.0.0"
implementation "com.google.android.gms:play-services-mlkit-face-detection:16.1.7" implementation "com.google.android.gms:play-services-mlkit-face-detection:16.2.1"
implementation "com.github.clans:fab:1.6.4" implementation "com.github.clans:fab:1.6.4"
@@ -129,12 +129,13 @@ dependencies {
implementation "com.google.guava:guava:31.0.1-android" implementation "com.google.guava:guava:31.0.1-android"
implementation "xyz.quaver:documentfilex:0.7.1" implementation "xyz.quaver:documentfilex:0.7.2"
implementation "xyz.quaver:floatingsearchview:1.1.7" implementation "xyz.quaver:floatingsearchview:1.1.7"
testImplementation "junit:junit:4.13.1" testImplementation "junit:junit:4.13.2"
androidTestImplementation "androidx.test.ext:junit:1.1.2" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0"
androidTestImplementation "androidx.test:rules:1.3.0" androidTestImplementation "androidx.test.ext:junit:1.1.3"
androidTestImplementation "androidx.test:runner:1.3.0" androidTestImplementation "androidx.test:rules:1.4.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0" androidTestImplementation "androidx.test:runner:1.4.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
} }

View File

@@ -1,5 +1,5 @@
{ {
"version": 2, "version": 3,
"artifactType": { "artifactType": {
"type": "APK", "type": "APK",
"kind": "Directory" "kind": "Directory"
@@ -10,9 +10,11 @@
{ {
"type": "SINGLE", "type": "SINGLE",
"filters": [], "filters": [],
"attributes": [],
"versionCode": 69, "versionCode": 69,
"versionName": "5.1.32", "versionName": "5.2.8",
"outputFile": "app-release.apk" "outputFile": "app-release.apk"
} }
] ],
"elementType": "File"
} }

View File

@@ -21,15 +21,14 @@
package xyz.quaver.pupil package xyz.quaver.pupil
import android.util.Log import android.util.Log
import android.webkit.WebView import android.webkit.*
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.MainScope import kotlinx.coroutines.*
import kotlinx.coroutines.launch import org.junit.Before
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import xyz.quaver.pupil.hitomi.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
@@ -38,22 +37,95 @@ import org.junit.runner.RunWith
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest { class ExampleInstrumentedTest {
@Before
@Test fun init() {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
runBlocking { runBlocking {
MainScope().launch { withContext(Dispatchers.Main) {
val webView = WebView(appContext).apply { webView = WebView(appContext).apply {
settings.javaScriptEnabled = true settings.javaScriptEnabled = true
addJavascriptInterface(object {
@JavascriptInterface
fun onResult(uid: String, result: String) {
_webViewFlow.tryEmit(uid to result)
}
}, "Callback")
loadDataWithBaseURL(
"https://hitomi.la/",
"""
<script src="https://ltn.hitomi.la/jquery.min.js"></script>
<script src="https://ltn.hitomi.la/common.js"></script>
<script src="https://ltn.hitomi.la/search.js"></script>
<script src="https://ltn.hitomi.la/searchlib.js"></script>
<script src="https://ltn.hitomi.la/results.js></script>
""".trimIndent(),
"text/html",
null,
null
)
} }
webView.evaluateJavascript("3") { }
Log.d("PUPILD", it) }
} }
Log.d("PUPILD", "SYNC?")
}.join() @Test
fun test_getGalleryIDsFromNozomi() {
runBlocking {
val result = getGalleryIDsFromNozomi(null, "index", "all")
Log.d("PUPILD", "getGalleryIDsFromNozomi: ${result.size}")
}
}
@Test
fun test_getGalleryIDsForQuery() {
runBlocking {
val result = getGalleryIDsForQuery("female:crotch tattoo")
Log.d("PUPILD", "getGalleryIDsForQuery: ${result.size}")
}
}
@Test
fun test_getSuggestionsForQuery() {
runBlocking {
val result = getSuggestionsForQuery("fem")
Log.d("PUPILD", "getSuggestionsForQuery: ${result.size}")
}
}
@Test
fun test_urlFromUrlFromHash() {
runBlocking {
val galleryInfo = getGalleryInfo(2102416)
val result = galleryInfo.files.map {
imageUrlFromImage(2102416, it, false)
}
Log.d("PUPILD", result.toString())
}
}
@Test
fun test_getGalleryInfo() {
runBlocking {
val galleryInfo = getGalleryInfo(2102416)
Log.d("PUPILD", galleryInfo.toString())
}
}
@Test
fun test_getGalleryBlock() {
runBlocking {
val block = getGalleryBlock(2102731)
Log.d("PUPILD", block.toString())
} }
} }
} }

View File

@@ -59,7 +59,8 @@
<activity <activity
android:name=".ui.ReaderActivity" android:name=".ui.ReaderActivity"
android:configChanges="keyboardHidden|orientation|screenSize" android:configChanges="keyboardHidden|orientation|screenSize"
android:parentActivityName=".ui.MainActivity"> android:parentActivityName=".ui.MainActivity"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@@ -223,7 +224,8 @@
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize" android:configChanges="keyboardHidden|orientation|screenSize"
android:theme="@style/NoActionBarAppTheme"> android:theme="@style/NoActionBarAppTheme"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

View File

@@ -25,9 +25,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ApplicationInfo
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.webkit.WebView import android.util.Log
import android.webkit.*
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@@ -37,14 +40,17 @@ import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import com.google.android.gms.common.GooglePlayServicesRepairableException import com.google.android.gms.common.GooglePlayServicesRepairableException
import com.google.android.gms.security.ProviderInstaller import com.google.android.gms.security.ProviderInstaller
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import okhttp3.Dispatcher import kotlinx.coroutines.*
import okhttp3.Interceptor import kotlinx.coroutines.channels.BufferOverflow
import okhttp3.OkHttpClient import kotlinx.coroutines.flow.MutableSharedFlow
import okhttp3.Response import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.*
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.pupil.hitomi.evaluationContext
import xyz.quaver.pupil.types.Tag import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.* import xyz.quaver.pupil.util.*
import java.io.File import java.io.File
import java.net.URL
import java.util.* import java.util.*
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -73,6 +79,76 @@ val client: OkHttpClient
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
lateinit var webView: WebView lateinit var webView: WebView
val _webViewFlow = MutableSharedFlow<Pair<String, String?>>(
extraBufferCapacity = 2,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val webViewFlow = _webViewFlow.asSharedFlow()
var webViewReady = false
private set
var webViewFailed = false
private set
private var reloadJob: Job? = null
fun reloadWebView() {
if (reloadJob?.isActive == true) return
reloadJob = CoroutineScope(Dispatchers.IO).launch {
webViewReady = false
webViewFailed = false
evaluationContext.cancelChildren()
runCatching {
URL(
if (isDebugBuild)
"https://tom5079.github.io/Pupil/hitomi-dev.html"
else
"https://tom5079.github.io/Pupil/hitomi.html"
).readText()
}.onFailure {
webViewFailed = true
}.getOrNull()?.let { html ->
launch(Dispatchers.Main) {
webView.loadDataWithBaseURL(
"https://hitomi.la/",
html,
"text/html",
null,
null
)
}
}
}
}
private var htmlVersion: String = ""
fun reloadWhenFailedOrUpdate() = CoroutineScope(Dispatchers.Default).launch {
while (true) {
if (
webViewFailed ||
runCatching {
URL(
if (isDebugBuild)
"https://tom5079.github.io/Pupil/hitomi-dev.html.ver"
else
"https://tom5079.github.io/Pupil/hitomi.html.ver"
).readText()
}.getOrNull().let { version ->
(!version.isNullOrEmpty() && version != htmlVersion).also {
if (it) htmlVersion = version!!
}
}
) {
reloadWebView()
}
delay(if (webViewReady && !webViewFailed) 10000 else 1000)
}
}
var isDebugBuild: Boolean = false
lateinit var userAgent: String
class Pupil : Application() { class Pupil : Application() {
@@ -84,13 +160,64 @@ class Pupil : Application() {
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override fun onCreate() { override fun onCreate() {
instance = this instance = this
isDebugBuild = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
if (isDebugBuild) WebView.setWebContentsDebuggingEnabled(true)
webView = WebView(this).apply { webView = WebView(this).apply {
settings.javaScriptEnabled = true with (settings) {
javaScriptEnabled = true
domStorageEnabled = true
}
loadData("""<script src="https://ltn.hitomi.la/gg.js"></script>""", "text/html", null) userAgent = settings.userAgentString
webViewClient = object: WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
webViewReady = true
}
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) {
_webViewFlow.tryEmit(uid to result)
}
@JavascriptInterface
fun onError(uid: String, message: String) {
_webViewFlow.tryEmit(uid to null)
Toast.makeText(this@Pupil, message, Toast.LENGTH_LONG).show()
FirebaseCrashlytics.getInstance().recordException(
Exception(message)
)
}
}, "Callback")
} }
reloadWhenFailedOrUpdate()
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
preferences = PreferenceManager.getDefaultSharedPreferences(this) preferences = PreferenceManager.getDefaultSharedPreferences(this)
@@ -109,7 +236,10 @@ class Pupil : Application() {
.readTimeout(0, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS)
.proxyInfo(proxyInfo) .proxyInfo(proxyInfo)
.addInterceptor { chain -> .addInterceptor { chain ->
val request = chain.request() val request = chain.request().newBuilder()
.header("User-Agent", userAgent)
.build()
val tag = request.tag() ?: return@addInterceptor chain.proceed(request) val tag = request.tag() ?: return@addInterceptor chain.proceed(request)
interceptors[tag::class]?.invoke(chain) ?: chain.proceed(request) interceptors[tag::class]?.invoke(chain) ?: chain.proceed(request)
@@ -122,7 +252,7 @@ class Pupil : Application() {
try { try {
Preferences.get<String>("download_folder").also { Preferences.get<String>("download_folder").also {
if (it.startsWith("content") && Build.VERSION.SDK_INT > 19) if (it.startsWith("content://"))
contentResolver.takePersistableUriPermission( contentResolver.takePersistableUriPermission(
Uri.parse(it), Uri.parse(it),
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION

View File

@@ -53,13 +53,4 @@ fun URL.readText(settings: HeaderSetter? = null): String {
}.build() }.build()
return client.newCall(request).execute().also{ if (it.code() != 200) throw IOException() }.body()?.use { it.string() } ?: throw IOException() return client.newCall(request).execute().also{ if (it.code() != 200) throw IOException() }.body()?.use { it.string() } ?: throw IOException()
}
fun URL.readBytes(settings: HeaderSetter? = null): ByteArray {
val request = Request.Builder()
.url(this).let {
settings?.invoke(it) ?: it
}.build()
return client.newCall(request).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.bytes() } ?: throw IOException()
} }

View File

@@ -16,145 +16,117 @@
package xyz.quaver.pupil.hitomi package xyz.quaver.pupil.hitomi
import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.widget.Toast
import com.google.common.collect.ConcurrentHashMultiset
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.transformWhile
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import xyz.quaver.json import xyz.quaver.json
import xyz.quaver.pupil.Pupil import xyz.quaver.pupil.*
import xyz.quaver.pupil.webView import java.util.*
import xyz.quaver.readText import java.util.concurrent.ConcurrentHashMap
import java.net.URL import kotlin.coroutines.resume
import java.nio.charset.Charset import kotlin.coroutines.suspendCoroutine
const val protocol = "https:" const val protocol = "https:"
val evaluationContext = Dispatchers.Main + Job()
suspend fun WebView.evaluate(script: String): String = coroutineScope {
var result: String? = null
while (result == null) {
try {
result = withContext(evaluationContext) {
while (webViewFailed || !webViewReady) yield()
suspendCoroutine { continuation ->
evaluateJavascript(script) {
continuation.resume(it)
}
}
}
} catch (e: CancellationException) {
continue
}
}
result
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun WebView.evaluatePromise(
script: String,
then: String = ".then(result => Callback.onResult(%uid, JSON.stringify(result))).catch(err => Callback.onError(%uid, JSON.stringify(error)))"
): String? = coroutineScope {
var result: String? = null
while (result == null) {
try {
result = withContext(evaluationContext) {
while (webViewFailed || !webViewReady) yield()
val uid = UUID.randomUUID().toString()
evaluateJavascript((script + then).replace("%uid", "'$uid'"), null)
val flow: Flow<Pair<String, String?>> = webViewFlow.transformWhile { (currentUid, result) ->
if (currentUid == uid) {
emit(currentUid to result)
}
currentUid != uid
}
flow.first().second
}
} catch (e: CancellationException) {
continue
}
}
result
}
@Suppress("EXPERIMENTAL_API_USAGE") @Suppress("EXPERIMENTAL_API_USAGE")
fun getGalleryInfo(galleryID: Int) = suspend fun getGalleryInfo(galleryID: Int): GalleryInfo {
json.decodeFromString<GalleryInfo>( val result = webView.evaluatePromise("get_gallery_info($galleryID)")
URL("$protocol//$domain/galleries/$galleryID.js").readText()
.replace("var galleryinfo = ", "") return json.decodeFromString(result!!)
) }
//common.js //common.js
const val domain = "ltn.hitomi.la" const val domain = "ltn.hitomi.la"
const val galleryblockextension = ".html"
const val galleryblockdir = "galleryblock" const val galleryblockdir = "galleryblock"
const val nozomiextension = ".nozomi" const val nozomiextension = ".nozomi"
@SuppressLint("SetJavaScriptEnabled") val String?.js: String
interface gg { get() = if (this == null) "null" else "'$this'"
fun m(g: Int): Int
val b: String
fun s(h: String): String
companion object { @OptIn(ExperimentalSerializationApi::class)
@Volatile private var instance: gg? = null suspend fun urlFromUrlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null, base: String? = null): String {
val result = webView.evaluate(
"""
url_from_url_from_hash(
${galleryID.toString().js},
${Json.encodeToString(image)},
${dir.js}, ${ext.js}, ${base.js}
)
""".trimIndent()
)
fun getInstance(): gg = return Json.decodeFromString(result)
instance ?: synchronized(this) {
instance ?: object: gg {
override fun m(g: Int): Int {
var result: Int? = null
MainScope().launch {
while (webView.progress != 100) delay(100)
webView.evaluateJavascript("gg.m($g)") {
result = it.toInt()
}
}
while (result == null) Thread.sleep(100)
return result!!
}
override val b: String
get() {
var result: String? = null
MainScope().launch {
while (webView.progress != 100) delay(100)
webView.evaluateJavascript("gg.b") {
result = it.replace("\"", "")
}
}
while (result == null) Thread.sleep(100)
return result!!
}
override fun s(h: String): String {
var result: String? = null
MainScope().launch {
while (webView.progress != 100) delay(100)
webView.evaluateJavascript("gg.s('$h')") {
result = it.replace("\"", "")
}
}
while (result == null) Thread.sleep(100)
return result!!
}
}.also { instance = it }
}
}
} }
fun subdomainFromURL(url: String, base: String? = null) : String { suspend fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean) : String {
var retval = "b"
if (!base.isNullOrBlank())
retval = base
val b = 16
val r = Regex("""/[0-9a-f]{61}([0-9a-f]{2})([0-9a-f])""")
val m = r.find(url) ?: return "a"
val g = m.groupValues.let { it[2]+it[1] }.toIntOrNull(b)
if (g != null) {
retval = (97+ gg.getInstance().m(g)).toChar().toString() + retval
}
return retval
}
fun urlFromUrl(url: String, base: String? = null) : String {
return url.replace(Regex("""//..?\.hitomi\.la/"""), "//${subdomainFromURL(url, base)}.hitomi.la/")
}
fun fullPathFromHash(hash: String) : String =
"${gg.getInstance().b}${gg.getInstance().s(hash)}/$hash"
fun realFullPathFromHash(hash: String): String =
hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1/$hash")
fun urlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null) : String {
val ext = ext ?: dir ?: image.name.takeLastWhile { it != '.' }
val dir = dir ?: "images"
return "https://a.hitomi.la/$dir/${fullPathFromHash(image.hash)}.$ext"
}
fun urlFromUrlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null, base: String? = null) =
if (base == "tn")
urlFromUrl("https://a.hitomi.la/$dir/${realFullPathFromHash(image.hash)}.$ext", base)
else
urlFromUrl(urlFromHash(galleryID, image, dir, ext), base)
fun rewriteTnPaths(html: String) =
html.replace(Regex("""//tn\.hitomi\.la/[^/]+/[0-9a-f]/[0-9a-f]{2}/[0-9a-f]{64}""")) { url ->
urlFromUrl(url.value, "tn")
}
fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean) : String {
return when { return when {
noWebp -> noWebp ->
urlFromUrlFromHash(galleryID, image) urlFromUrlFromHash(galleryID, image)

View File

@@ -37,7 +37,7 @@ data class Gallery(
val tags: List<String>, val tags: List<String>,
val thumbnails: List<String> val thumbnails: List<String>
) )
fun getGallery(galleryID: Int) : Gallery { suspend fun getGallery(galleryID: Int) : Gallery {
val url = Jsoup.parse(URL("https://hitomi.la/galleries/$galleryID.html").readText()) val url = Jsoup.parse(URL("https://hitomi.la/galleries/$galleryID.html").readText())
.select("link").attr("href") .select("link").attr("href")

View File

@@ -18,6 +18,7 @@ package xyz.quaver.pupil.hitomi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jsoup.Jsoup import org.jsoup.Jsoup
import xyz.quaver.pupil.webView
import xyz.quaver.readText import xyz.quaver.readText
import java.net.URL import java.net.URL
import java.net.URLDecoder import java.net.URLDecoder
@@ -26,42 +27,6 @@ import java.nio.ByteOrder
import java.util.* import java.util.*
import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HttpsURLConnection
//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 @Serializable
data class GalleryBlock( data class GalleryBlock(
val id: Int, val id: Int,
@@ -75,10 +40,21 @@ data class GalleryBlock(
val relatedTags: List<String> val relatedTags: List<String>
) )
fun getGalleryBlock(galleryID: Int) : GalleryBlock { suspend fun getGalleryBlock(galleryID: Int) : GalleryBlock {
val url = "$protocol//$domain/$galleryblockdir/$galleryID$extension" val url = "$protocol//$domain/$galleryblockdir/$galleryID$extension"
val doc = Jsoup.parse(rewriteTnPaths(URL(url).readText())) val html: String = webView.evaluatePromise(
"""
$.get('$url').always(function(data, status) {
if (status === 'success') {
Callback.onResult(%uid, data);
}
});
""".trimIndent(),
then = ""
)!!
val doc = Jsoup.parse(html)
val galleryUrl = doc.selectFirst("h1 > a")!!.attr("href") val galleryUrl = doc.selectFirst("h1 > a")!!.attr("href")
@@ -100,6 +76,4 @@ fun getGalleryBlock(galleryID: Int) : GalleryBlock {
} }
return GalleryBlock(galleryID, galleryUrl, thumbnails, title, artists, series, type, language, relatedTags) return GalleryBlock(galleryID, galleryUrl, thumbnails, title, artists, series, type, language, relatedTags)
} }
fun getGalleryBlockOrNull(galleryID: Int) = runCatching { getGalleryBlock(galleryID) }.getOrNull()

View File

@@ -44,6 +44,6 @@ data class GalleryFiles(
//Set header `Referer` to reader url to avoid 403 error //Set header `Referer` to reader url to avoid 403 error
@Deprecated("", replaceWith = ReplaceWith("getGalleryInfo")) @Deprecated("", replaceWith = ReplaceWith("getGalleryInfo"))
fun getReader(galleryID: Int) : GalleryInfo { suspend fun getReader(galleryID: Int) : GalleryInfo {
return getGalleryInfo(galleryID) return getGalleryInfo(galleryID)
} }

View File

@@ -16,13 +16,10 @@
package xyz.quaver.pupil.hitomi package xyz.quaver.pupil.hitomi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import java.util.* import java.util.*
fun doSearch(query: String, sortByPopularity: Boolean = false) : Set<Int> { suspend fun doSearch(query: String, sortByPopularity: Boolean = false) : Set<Int> = coroutineScope {
val terms = query val terms = query
.trim() .trim()
.replace(Regex("""^\?"""), "") .replace(Regex("""^\?"""), "")
@@ -43,16 +40,16 @@ fun doSearch(query: String, sortByPopularity: Boolean = false) : Set<Int> {
} }
val positiveResults = positiveTerms.map { val positiveResults = positiveTerms.map {
CoroutineScope(Dispatchers.IO).async { async {
kotlin.runCatching { runCatching {
getGalleryIDsForQuery(it) getGalleryIDsForQuery(it)
}.getOrElse { emptySet() } }.getOrElse { emptySet() }
} }
} }
val negativeResults = negativeTerms.map { val negativeResults = negativeTerms.map {
CoroutineScope(Dispatchers.IO).async { async {
kotlin.runCatching { runCatching {
getGalleryIDsForQuery(it) getGalleryIDsForQuery(it)
}.getOrElse { emptySet() } }.getOrElse { emptySet() }
} }
@@ -64,28 +61,26 @@ fun doSearch(query: String, sortByPopularity: Boolean = false) : Set<Int> {
else -> emptySet() else -> emptySet()
} }
runBlocking { fun filterPositive(newResults: Set<Int>) {
@Synchronized fun filterPositive(newResults: Set<Int>) { results = when {
results = when { results.isEmpty() -> newResults
results.isEmpty() -> newResults else -> results intersect newResults
else -> results intersect newResults
}
}
@Synchronized fun filterNegative(newResults: Set<Int>) {
results = results subtract newResults
}
//positive results
positiveResults.forEach {
filterPositive(it.await())
}
//negative results
negativeResults.forEach {
filterNegative(it.await())
} }
} }
return results fun filterNegative(newResults: Set<Int>) {
results = results subtract newResults
}
//positive results
positiveResults.forEach {
filterPositive(it.await())
}
//negative results
negativeResults.forEach {
filterNegative(it.await())
}
results
} }

View File

@@ -16,315 +16,35 @@
package xyz.quaver.pupil.hitomi package xyz.quaver.pupil.hitomi
import okhttp3.Request import kotlinx.serialization.ExperimentalSerializationApi
import xyz.quaver.pupil.client import kotlinx.serialization.Serializable
import xyz.quaver.readBytes import kotlinx.serialization.decodeFromString
import xyz.quaver.readText import kotlinx.serialization.json.Json
import java.net.URL import xyz.quaver.pupil.webView
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
import kotlin.math.min
//searchlib.js //searchlib.js
const val separator = "-"
const val extension = ".html" 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"
val tag_index_version: String by lazy { getIndexVersion("tagindex") } @OptIn(ExperimentalSerializationApi::class)
val galleries_index_version: String by lazy { getIndexVersion("galleriesindex") } suspend fun getGalleryIDsForQuery(query: String) : Set<Int> {
val result = webView.evaluatePromise("get_galleryids_for_query('$query')") ?: return emptySet()
fun sha256(data: ByteArray) : ByteArray { return Json.decodeFromString(result)
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()
}
} }
@Serializable
data class Suggestion(val s: String, val t: Int, val u: String, val n: String) data class Suggestion(val s: String, val t: Int, val u: String, val n: String)
fun getSuggestionsFromData(field: String, data: Pair<Long, Int>) : List<Suggestion> {
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)) @OptIn(ExperimentalSerializationApi::class)
suspend fun getSuggestionsForQuery(query: String) : List<Suggestion> {
val result = webView.evaluatePromise("get_suggestions_for_query('$query')") ?: return emptyList()
val suggestions = ArrayList<Suggestion>() return Json.decodeFromString<List<List<Suggestion>?>>(result)[0] ?: return emptyList()
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> { @OptIn(ExperimentalSerializationApi::class)
val nozomiAddress = suspend fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : Set<Int> {
when(area) { val jsArea = if (area == null) "null" else "'$area'"
null -> "$protocol//$domain/$compressed_nozomi_prefix/$tag-$language$nozomiextension"
else -> "$protocol//$domain/$compressed_nozomi_prefix/$area/$tag-$language$nozomiextension"
}
val bytes = try { return Json.decodeFromString(webView.evaluatePromise("""get_galleryids_from_nozomi($jsArea, '$tag', '$language')""") ?: return emptySet())
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

@@ -24,6 +24,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@@ -76,7 +77,7 @@ class UpdateBroadcastReceiver : BroadcastReceiver() {
val pendingIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW).apply { val pendingIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK 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")) setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
}, 0) }, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_IMMUTABLE else 0)
val notification = NotificationCompat.Builder(context, "update") val notification = NotificationCompat.Builder(context, "update")
.setSmallIcon(android.R.drawable.stat_sys_download_done) .setSmallIcon(android.R.drawable.stat_sys_download_done)

View File

@@ -23,6 +23,7 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@@ -41,12 +42,9 @@ import okhttp3.ResponseBody
import okio.* import okio.*
import xyz.quaver.pupil.* import xyz.quaver.pupil.*
import xyz.quaver.pupil.ui.ReaderActivity import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.util.cleanCache import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.Cache import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.ellipsize
import xyz.quaver.pupil.util.normalizeID
import xyz.quaver.pupil.util.requestBuilders
import java.io.IOException import java.io.IOException
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.math.ceil import kotlin.math.ceil
@@ -77,7 +75,7 @@ class DownloadService : Service() {
val pendingIntent = TaskStackBuilder.create(this).run { val pendingIntent = TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(intent) addNextIntentWithParentStack(intent)
getPendingIntent(galleryID, PendingIntent.FLAG_UPDATE_CURRENT) getPendingIntent(galleryID, PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
} }
val action = val action =
NotificationCompat.Action.Builder(0, getText(android.R.string.cancel), NotificationCompat.Action.Builder(0, getText(android.R.string.cancel),
@@ -87,7 +85,7 @@ class DownloadService : Service() {
Intent(this, DownloadService::class.java) Intent(this, DownloadService::class.java)
.putExtra(KEY_COMMAND, COMMAND_CANCEL) .putExtra(KEY_COMMAND, COMMAND_CANCEL)
.putExtra(KEY_ID, galleryID), .putExtra(KEY_ID, galleryID),
PendingIntent.FLAG_UPDATE_CURRENT), PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0),
).build() ).build()
notification[galleryID] = NotificationCompat.Builder(this, "download").apply { notification[galleryID] = NotificationCompat.Builder(this, "download").apply {
@@ -357,7 +355,7 @@ class DownloadService : Service() {
} }
} }
galleryInfo.requestBuilders.forEachIndexed { index, it -> galleryInfo.getRequestBuilders().forEachIndexed { index, it ->
if (progress[galleryID]?.get(index)?.isInfinite() == false) { if (progress[galleryID]?.get(index)?.isInfinite() == false) {
val request = it.tag(Tag(galleryID, index, startId)).build() val request = it.tag(Tag(galleryID, index, startId)).build()
client.newCall(request).enqueue(callback) client.newCall(request).enqueue(callback)

View File

@@ -25,6 +25,7 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.InputType import android.text.InputType
import android.text.util.Linkify import android.text.util.Linkify
import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View

View File

@@ -161,12 +161,10 @@ class ReaderActivity : BaseActivity() {
} }
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.reader, menu) menuInflater.inflate(R.menu.reader, menu)
with(menu?.findItem(R.id.reader_menu_favorite)) { with(menu.findItem(R.id.reader_menu_favorite)) {
this ?: return@with
if (favorites.contains(galleryID)) if (favorites.contains(galleryID))
(icon as Animatable).start() (icon as Animatable).start()
} }

View File

@@ -21,6 +21,7 @@ package xyz.quaver.pupil.util.downloader
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.net.Uri import android.net.Uri
import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -39,6 +40,7 @@ import xyz.quaver.pupil.hitomi.GalleryBlock
import xyz.quaver.pupil.hitomi.GalleryInfo import xyz.quaver.pupil.hitomi.GalleryInfo
import xyz.quaver.pupil.hitomi.getGalleryBlock import xyz.quaver.pupil.hitomi.getGalleryBlock
import xyz.quaver.pupil.hitomi.getGalleryInfo import xyz.quaver.pupil.hitomi.getGalleryInfo
import xyz.quaver.pupil.userAgent
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -171,6 +173,7 @@ class Cache private constructor(context: Context, val galleryID: Int) : ContextW
kotlin.runCatching { kotlin.runCatching {
val request = Request.Builder() val request = Request.Builder()
.url(it) .url(it)
.header("Referer", "https://hitomi.la/")
.build() .build()
client.newCall(request).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.bytes() } client.newCall(request).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.bytes() }

View File

@@ -19,6 +19,7 @@
package xyz.quaver.pupil.util package xyz.quaver.pupil.util
import android.annotation.SuppressLint import android.annotation.SuppressLint
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -97,18 +98,25 @@ fun GalleryBlock.formatDownloadFolderTest(format: String): String =
} }
}.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127) }.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
val GalleryInfo.requestBuilders: List<Request.Builder> suspend fun GalleryInfo.getRequestBuilders(): List<Request.Builder> {
get() { val galleryID = this.id ?: 0
val galleryID = this.id ?: 0 val lowQuality = Preferences["low_quality", true]
val lowQuality = Preferences["low_quality", true]
return this.files.map { return this.files.map {
Request.Builder() Request.Builder()
.url(imageUrlFromImage(galleryID, it, !lowQuality)) .url(
.header("Referer", "https://hitomi.la/") runCatching {
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36") imageUrlFromImage(galleryID, it, !lowQuality)
} }
.onFailure {
FirebaseCrashlytics.getInstance().recordException(it)
}
.getOrDefault("https://a/")
)
.header("Referer", "https://hitomi.la/")
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36")
} }
}
fun String.ellipsize(n: Int): String = fun String.ellipsize(n: Int): String =
if (this.length > n) if (this.length > n)

View File

@@ -6,14 +6,14 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:4.2.2' classpath 'com.android.tools.build:gradle:7.0.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-android-extensions:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-android-extensions:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath "com.google.gms:google-services:4.3.8" classpath "com.google.gms:google-services:4.3.10"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files
classpath "com.google.firebase:firebase-crashlytics-gradle:2.7.0" classpath "com.google.firebase:firebase-crashlytics-gradle:2.8.1"
classpath "com.google.firebase:perf-plugin:1.4.0" classpath "com.google.firebase:perf-plugin:1.4.0"
classpath "com.google.android.gms:oss-licenses-plugin:0.10.4" classpath "com.google.android.gms:oss-licenses-plugin:0.10.4"
} }

View File

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

View File

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