Compare commits

...

19 Commits

Author SHA1 Message Date
tom5079
62dce26c73 Merge remote-tracking branch 'origin/master' 2022-01-25 04:28:16 +09:00
tom5079
ac0cff62d4 Ask user to update WebView when es2020 is not supported 2022-01-25 04:28:04 +09:00
tom5079
655c060814 Drop Guava from dependency 2022-01-22 10:00:28 +09:00
tom5079
36d27895e7 Update README.md 2022-01-21 17:11:03 +09:00
tom5079
803481f74c Merge remote-tracking branch 'origin/master' 2022-01-21 17:08:57 +09:00
tom5079
b3ca1686e3 5.2.19
Improved error report
Lenient JSON decoding
2022-01-21 17:08:49 +09:00
tom5079
8f220eb0cb Update README.md 2022-01-20 19:42:41 +09:00
tom5079
51d5f42e8b Merge remote-tracking branch 'origin/master' 2022-01-20 19:41:22 +09:00
tom5079
8d8c5ace61 Fixed {} 2022-01-20 19:41:10 +09:00
tom5079
4bb6b8ccc9 Update README.md 2022-01-20 18:06:28 +09:00
tom5079
6bebd36e83 Merge remote-tracking branch 'origin/master' 2022-01-20 18:05:27 +09:00
tom5079
edc7053e50 Optimize Firebase 2022-01-20 18:05:18 +09:00
tom5079
55e6ef5f78 Update README.md 2022-01-20 16:06:26 +09:00
tom5079
9781d7a5dc Merge remote-tracking branch 'origin/master' 2022-01-20 16:05:31 +09:00
tom5079
b83cf87cd8 Updated proguard-rules.pro 2022-01-20 16:05:22 +09:00
tom5079
430864512d Update README.md 2022-01-20 15:58:17 +09:00
tom5079
16eeef1878 Merge remote-tracking branch 'origin/master' 2022-01-20 15:57:54 +09:00
tom5079
994d4b589b 5.2.15 Fixed thumbnail not loading 2022-01-20 15:57:43 +09:00
tom5079
43adba6f13 Update README.md 2022-01-18 18:26:14 +09:00
22 changed files with 259 additions and 322 deletions

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">
<runningDeviceTargetSelectedWithDropDown>
<Target>
<type value="RUNNING_DEVICE_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="$USER_HOME$/.android/avd/Pixel_2_API_31.avd" />
</Key>
</deviceKey>
</Target>
</runningDeviceTargetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2022-01-24T19:20:51.436372Z" />
</component>
</project>

1
.idea/misc.xml generated
View File

@@ -3,6 +3,7 @@
<component name="DesignSurface"> <component name="DesignSurface">
<option name="filePathToZoomLevelMap"> <option name="filePathToZoomLevelMap">
<map> <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/layout/reader_activity.xml" value="0.14351851851851852" />
</map> </map>
</option> </option>

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.2.1/Pupil-v5.2.14.apk?color=%234fc3f7&label=DOWNLOAD%20APP&style=for-the-badge)](https://github.com/tom5079/Pupil/releases/download/5.2.14/Pupil-v5.2.14.apk) [![](https://img.shields.io/github/downloads/tom5079/Pupil/5.2.19/Pupil-v5.2.19.apk?color=%234fc3f7&label=DOWNLOAD%20APP&style=for-the-badge)](https://github.com/tom5079/Pupil/releases/download/5.2.19/Pupil-v5.2.19.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

@@ -38,7 +38,7 @@ android {
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 31 targetSdkVersion 31
versionCode 69 versionCode 69
versionName "5.2.14" versionName "5.2.20"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
} }
@@ -91,6 +91,7 @@ dependencies {
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.7.1" implementation "androidx.work:work-runtime-ktx:2.7.1"
implementation 'androidx.webkit:webkit:1.4.0'
implementation "com.daimajia.swipelayout:library:1.2.0@aar" implementation "com.daimajia.swipelayout:library:1.2.0@aar"
@@ -128,8 +129,6 @@ dependencies {
implementation "org.jsoup:jsoup:1.14.3" implementation "org.jsoup:jsoup:1.14.3"
implementation "com.google.guava:guava:31.0.1-android"
implementation "xyz.quaver:documentfilex:0.7.2" implementation "xyz.quaver:documentfilex:0.7.2"
implementation "xyz.quaver:floatingsearchview:1.1.7" implementation "xyz.quaver:floatingsearchview:1.1.7"

View File

@@ -33,4 +33,4 @@
} }
-keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment -keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment
-keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment -keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment
-keep class com.hippo.quickjs.** { *; } -keep class xyz.quaver.pupil.** { *; }

View File

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

View File

@@ -46,48 +46,7 @@ class ExampleInstrumentedTest {
runBlocking { runBlocking {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
WebView.setWebContentsDebuggingEnabled(true) initWebView(appContext)
webView = WebView(appContext).apply {
with (settings) {
javaScriptEnabled = true
domStorageEnabled = true
}
userAgent = settings.userAgentString
webViewClient = object: WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
webViewReady = true
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
}
}
webChromeClient = object: WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
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)
}
}, "Callback")
}
reloadWhenFailedOrUpdate()
} }
} }
} }
@@ -141,10 +100,19 @@ class ExampleInstrumentedTest {
} }
} }
@Test
fun test_getGallery() {
runBlocking {
val gallery = getGallery(2109479)
Log.d("PUPILD", gallery.toString())
}
}
@Test @Test
fun test_getGalleryBlock() { fun test_getGalleryBlock() {
runBlocking { runBlocking {
val block = getGalleryBlock(2102731) val block = getGalleryBlock(2119310)
Log.d("PUPILD", block.toString()) Log.d("PUPILD", block.toString())
} }

View File

@@ -25,15 +25,14 @@ 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.util.Log import android.util.Log
import android.webkit.* 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
import androidx.webkit.WebViewCompat
import com.facebook.imagepipeline.backends.okhttp3.OkHttpImagePipelineConfigFactory import com.facebook.imagepipeline.backends.okhttp3.OkHttpImagePipelineConfigFactory
import com.github.piasy.biv.BigImageViewer import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.fresco.FrescoImageLoader import com.github.piasy.biv.loader.fresco.FrescoImageLoader
@@ -42,18 +41,19 @@ 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 kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.* import okhttp3.*
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.pupil.hitomi.evaluationContext import xyz.quaver.pupil.hitomi.evaluationContext
import xyz.quaver.pupil.types.JavascriptException
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.net.URL
import java.util.* import java.util.*
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.reflect.KClass import kotlin.reflect.KClass
@@ -85,6 +85,7 @@ val _webViewFlow = MutableSharedFlow<Pair<String, String?>>()
val webViewFlow = _webViewFlow.asSharedFlow() val webViewFlow = _webViewFlow.asSharedFlow()
var webViewReady = false var webViewReady = false
var webViewFailed = false var webViewFailed = false
var oldWebView = false
private var reloadJob: Job? = null private var reloadJob: Job? = null
fun reloadWebView() { fun reloadWebView() {
@@ -93,15 +94,16 @@ fun reloadWebView() {
reloadJob = CoroutineScope(Dispatchers.IO).launch { reloadJob = CoroutineScope(Dispatchers.IO).launch {
webViewReady = false webViewReady = false
webViewFailed = false webViewFailed = false
oldWebView = false
evaluationContext.cancelChildren(CancellationException("reload")) evaluationContext.cancelChildren(CancellationException("reload"))
runCatching { runCatching {
URL( URL(
if (isDebugBuild) if (BuildConfig.DEBUG)
"https://tom5079.github.io/Pupil/hitomi-dev.html" "https://tom5079.github.io/PupilSources/hitomi-dev.html"
else else
"https://tom5079.github.io/Pupil/hitomi.html" "https://tom5079.github.io/PupilSources/hitomi.html"
).readText() ).readText()
}.onFailure { }.onFailure {
webViewFailed = true webViewFailed = true
@@ -126,10 +128,10 @@ fun reloadWhenFailedOrUpdate() = CoroutineScope(Dispatchers.Default).launch {
webViewFailed || webViewFailed ||
runCatching { runCatching {
URL( URL(
if (isDebugBuild) if (BuildConfig.DEBUG)
"https://tom5079.github.io/Pupil/hitomi-dev.html.ver" "https://tom5079.github.io/PupilSources/hitomi-dev.html.ver"
else else
"https://tom5079.github.io/Pupil/hitomi.html.ver" "https://tom5079.github.io/PupilSources/hitomi.html.ver"
).readText() ).readText()
}.getOrNull().let { version -> }.getOrNull().let { version ->
(!version.isNullOrEmpty() && version != htmlVersion).also { (!version.isNullOrEmpty() && version != htmlVersion).also {
@@ -144,7 +146,83 @@ fun reloadWhenFailedOrUpdate() = CoroutineScope(Dispatchers.Default).launch {
} }
} }
var isDebugBuild: Boolean = false @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 {
new Function('(x => x?.y ?? z)');
true;
} catch (err) {
false;
}
""".trimIndent()
) {
val es2020: Boolean = Json.decodeFromString(it)
webViewReady = es2020
oldWebView = !es2020
}
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
FirebaseCrashlytics.getInstance().log(
"onReceivedError: ${error?.description}"
)
}
}
}
webChromeClient = object: WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
FirebaseCrashlytics.getInstance().log(
"onConsoleMessage: ${consoleMessage?.message()} (${consoleMessage?.sourceId()}:${consoleMessage?.lineNumber()})"
)
return super.onConsoleMessage(consoleMessage)
}
}
addJavascriptInterface(object {
@JavascriptInterface
fun onResult(uid: String, result: String) {
CoroutineScope(Dispatchers.Unconfined).launch {
_webViewFlow.emit(uid to result)
}
}
@JavascriptInterface
fun onError(uid: String, script: String, message: String, stack: String) {
CoroutineScope(Dispatchers.Unconfined).launch {
_webViewFlow.emit(uid to "")
}
FirebaseCrashlytics.getInstance().recordException(
JavascriptException("onError script: $script\nmessage: $message\nstack: $stack")
)
}
}, "Callback")
}
reloadWhenFailedOrUpdate()
}
lateinit var userAgent: String lateinit var userAgent: String
class Pupil : Application() { class Pupil : Application() {
@@ -157,67 +235,8 @@ 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) initWebView(this)
webView = WebView(this).apply {
with (settings) {
javaScriptEnabled = true
domStorageEnabled = true
}
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) {
CoroutineScope(Dispatchers.Unconfined).launch {
_webViewFlow.emit(uid to result)
}
}
@JavascriptInterface
fun onError(uid: String, message: String) {
CoroutineScope(Dispatchers.Unconfined).launch {
_webViewFlow.emit(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)

View File

@@ -186,7 +186,7 @@ class GalleryBlockAdapter(private val galleries: List<Int>) : RecyclerSwipeAdapt
text = text =
resources.getString(R.string.galleryblock_language, languages[galleryBlock.language]) resources.getString(R.string.galleryblock_language, languages[galleryBlock.language])
visibility = when { visibility = when {
galleryBlock.language.isNotEmpty() -> View.VISIBLE !galleryBlock.language.isNullOrEmpty() -> View.VISIBLE
else -> View.GONE else -> View.GONE
} }
} }

View File

@@ -1,46 +0,0 @@
/*
* Copyright 2019 tom5079
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package xyz.quaver
import kotlinx.serialization.json.Json
import okhttp3.Request
import xyz.quaver.pupil.client
import java.io.IOException
import java.net.URL
/**
* kotlinx.serialization.json.Json object for global use
* properties should not be changed
*
* @see [https://kotlin.github.io/kotlinx.serialization/kotlinx-serialization-core/kotlinx-serialization-core/kotlinx.serialization.json/-json/index.html]
*/
val json = Json {
isLenient = true
ignoreUnknownKeys = true
allowSpecialFloatingPointValues = true
useArrayPolymorphism = true
}
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()
return client.newCall(request).execute().also{ if (it.code() != 200) throw IOException() }.body()?.use { it.string() } ?: throw IOException()
}

View File

@@ -18,9 +18,6 @@ package xyz.quaver.pupil.hitomi
import android.util.Log import android.util.Log
import android.webkit.WebView import android.webkit.WebView
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.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -29,10 +26,8 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import xyz.quaver.json
import xyz.quaver.pupil.* import xyz.quaver.pupil.*
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@@ -40,14 +35,29 @@ const val protocol = "https:"
val evaluationContext = Dispatchers.Main + Job() val evaluationContext = Dispatchers.Main + Job()
suspend fun WebView.evaluate(script: String): String = coroutineScope { /**
* kotlinx.serialization.json.Json object for global use
* properties should not be changed
*
* @see [https://kotlin.github.io/kotlinx.serialization/kotlinx-serialization-core/kotlinx-serialization-core/kotlinx.serialization.json/-json/index.html]
*/
val json = Json {
isLenient = true
ignoreUnknownKeys = true
allowSpecialFloatingPointValues = true
useArrayPolymorphism = true
}
suspend inline fun <reified T> WebView.evaluate(script: String): T = coroutineScope {
var result: String? = null var result: String? = null
while (result == null) { while (result == null) {
try { try {
result = withContext(evaluationContext) { while (!oldWebView && !(webViewReady && !webViewFailed)) yield()
while (webViewFailed || !webViewReady) yield()
result = if (oldWebView)
"null"
else withContext(evaluationContext) {
suspendCoroutine { continuation -> suspendCoroutine { continuation ->
evaluateJavascript(script) { evaluateJavascript(script) {
continuation.resume(it) continuation.resume(it)
@@ -60,21 +70,23 @@ suspend fun WebView.evaluate(script: String): String = coroutineScope {
} }
} }
result json.decodeFromString(result)
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
suspend fun WebView.evaluatePromise( suspend inline fun <reified T> WebView.evaluatePromise(
script: String, script: String,
then: String = ".then(result => Callback.onResult(%uid, JSON.stringify(result))).catch(err => Callback.onError(%uid, JSON.stringify(error)))" then: String = ".then(result => Callback.onResult(%uid, JSON.stringify(result))).catch(err => Callback.onError(%uid, String.raw`$script`, err.message, err.stack))"
): String = coroutineScope { ): T = coroutineScope {
var result: String? = null var result: String? = null
while (result == null) { while (result == null) {
try { try {
result = withContext(evaluationContext) { while (!oldWebView && !(webViewReady && !webViewFailed)) yield()
while (webViewFailed || !webViewReady) yield()
result = if (oldWebView)
"null"
else withContext(evaluationContext) {
val uid = UUID.randomUUID().toString() val uid = UUID.randomUUID().toString()
val flow: Flow<Pair<String, String?>> = webViewFlow.transformWhile { (currentUid, result) -> val flow: Flow<Pair<String, String?>> = webViewFlow.transformWhile { (currentUid, result) ->
@@ -95,27 +107,22 @@ suspend fun WebView.evaluatePromise(
} }
} }
result json.decodeFromString(result)
} }
@Suppress("EXPERIMENTAL_API_USAGE") @Suppress("EXPERIMENTAL_API_USAGE")
suspend fun getGalleryInfo(galleryID: Int): GalleryInfo { suspend fun getGalleryInfo(galleryID: Int): GalleryInfo =
val result = webView.evaluatePromise("get_gallery_info($galleryID)") webView.evaluatePromise("get_gallery_info($galleryID)")
return json.decodeFromString(result)
}
//common.js //common.js
const val domain = "ltn.hitomi.la" const val domain = "ltn.hitomi.la"
const val galleryblockdir = "galleryblock"
const val nozomiextension = ".nozomi"
val String?.js: String val String?.js: String
get() = if (this == null) "null" else "'$this'" get() = if (this == null) "null" else "'$this'"
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun urlFromUrlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null, base: String? = null): String { suspend fun urlFromUrlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null, base: String? = null): String =
val result = webView.evaluate( webView.evaluate(
""" """
url_from_url_from_hash( url_from_url_from_hash(
${galleryID.toString().js}, ${galleryID.toString().js},
@@ -125,9 +132,6 @@ suspend fun urlFromUrlFromHash(galleryID: Int, image: GalleryFiles, dir: String?
""".trimIndent() """.trimIndent()
) )
return Json.decodeFromString(result)
}
suspend fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean) : String { suspend fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean) : String {
return when { return when {
noWebp -> noWebp ->

View File

@@ -17,15 +17,14 @@
package xyz.quaver.pupil.hitomi package xyz.quaver.pupil.hitomi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jsoup.Jsoup import kotlinx.serialization.decodeFromString
import xyz.quaver.readText import kotlinx.serialization.json.Json
import java.net.URL import xyz.quaver.pupil.webView
import java.net.URLDecoder
@Serializable @Serializable
data class Gallery( data class Gallery(
val related: List<Int>, val related: List<Int>,
val langList: List<Pair<String, String>>, val langList: Map<String, String>,
val cover: String, val cover: String,
val title: String, val title: String,
val artists: List<String>, val artists: List<String>,
@@ -37,44 +36,5 @@ data class Gallery(
val tags: List<String>, val tags: List<String>,
val thumbnails: List<String> val thumbnails: List<String>
) )
suspend fun getGallery(galleryID: Int) : Gallery { suspend fun getGallery(galleryID: Int) : Gallery =
val url = Jsoup.parse(URL("https://hitomi.la/galleries/$galleryID.html").readText()) webView.evaluatePromise("get_gallery($galleryID)")
.select("link").attr("href")
val doc = Jsoup.parse(URL(url).readText())
val related = Regex("\\d+")
.findAll(doc.select("script").first()!!.html())
.map {
it.value.toInt()
}.toList()
val langList = doc.select("#lang-list a").map {
Pair(it.text(), "$protocol//hitomi.la${it.attr("href")}")
}
val cover = protocol + doc.selectFirst(".cover img")!!.attr("src")
val title = doc.selectFirst(".gallery h1 a")!!.text()
val artists = doc.select(".gallery h2 a").map { it.text() }
val groups = doc.select(".gallery-info a[href~=^/group/]").map { it.text() }
val type = doc.selectFirst(".gallery-info a[href~=^/type/]")!!.text()
val language = run {
val href = doc.select(".gallery-info a[href~=^/index.+\\.html\$]").attr("href")
Regex("""index-([^-]+)(-.+)?\.html""").find(href)?.groupValues?.getOrNull(1) ?: ""
}
val series = doc.select(".gallery-info a[href~=^/series/]").map { it.text() }
val characters = doc.select(".gallery-info a[href~=^/character/]").map { it.text() }
val tags = doc.select(".gallery-info a[href~=^/tag/]").map {
val href = URLDecoder.decode(it.attr("href"), "UTF-8")
href.slice(5 until href.indexOf('-'))
}
val thumbnails = getGalleryInfo(galleryID).files.map { galleryInfo ->
urlFromUrlFromHash(galleryID, galleryInfo, "smalltn", "jpg", "tn")
}
return Gallery(related, langList, cover, title, artists, groups, type, language, series, characters, tags, thumbnails)
}

View File

@@ -17,15 +17,7 @@
package xyz.quaver.pupil.hitomi package xyz.quaver.pupil.hitomi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import xyz.quaver.pupil.webView import xyz.quaver.pupil.webView
import xyz.quaver.readText
import java.net.URL
import java.net.URLDecoder
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.*
import javax.net.ssl.HttpsURLConnection
@Serializable @Serializable
data class GalleryBlock( data class GalleryBlock(
@@ -36,44 +28,9 @@ data class GalleryBlock(
val artists: List<String>, val artists: List<String>,
val series: List<String>, val series: List<String>,
val type: String, val type: String,
val language: String, val language: String?,
val relatedTags: List<String> val relatedTags: List<String>
) )
suspend fun getGalleryBlock(galleryID: Int) : GalleryBlock { suspend fun getGalleryBlock(galleryID: Int) : GalleryBlock =
val url = "$protocol//$domain/$galleryblockdir/$galleryID$extension" webView.evaluatePromise("get_gallery_block($galleryID)")
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 thumbnails = doc.select(".dj-img-cont img").map { protocol + it.attr("data-src") }
val title = doc.selectFirst("h1 > a")!!.text()
val artists = doc.select(".artist-list a").map{ it.text() }
val series = doc.select(".dj-content a[href~=^/series/]").map { it.text() }
val type = doc.selectFirst("a[href~=^/type/]")!!.text()
val language = run {
val href = doc.select("a[href~=^/index.+\\.html\$]").attr("href")
Regex("""index-([^-]+)(-.+)?\.html""").find(href)?.groupValues?.getOrNull(1) ?: ""
}
val relatedTags = doc.select(".relatedtags a").map {
val href = URLDecoder.decode(it.attr("href"), "UTF-8")
href.slice(5 until href.indexOf("-all"))
}
return GalleryBlock(galleryID, galleryUrl, thumbnails, title, artists, series, type, language, relatedTags)
}

View File

@@ -27,23 +27,19 @@ import xyz.quaver.pupil.webView
const val extension = ".html" const val extension = ".html"
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun getGalleryIDsForQuery(query: String) : Set<Int> { suspend fun getGalleryIDsForQuery(query: String) : Set<Int> =
val result = webView.evaluatePromise("get_galleryids_for_query('$query')") webView.evaluatePromise("get_galleryids_for_query('$query')")
return Json.decodeFromString(result)
}
@Serializable @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)
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun getSuggestionsForQuery(query: String) : List<Suggestion> { suspend fun getSuggestionsForQuery(query: String) : List<Suggestion> =
val result = webView.evaluatePromise( webView.evaluatePromise(
"get_suggestions_for_query('$query', ++search_serial)", "get_suggestions_for_query('$query', ++search_serial)",
then = """ then = """
.then(r => { .then(r => {
let [results, results_serial] = r; let [results, results_serial] = r;
console.log(results_serial, r, search_serial);
if (search_serial !== results_serial) { if (search_serial !== results_serial) {
Callback.onResult(%uid, '[]'); Callback.onResult(%uid, '[]');
} else { } else {
@@ -53,14 +49,9 @@ suspend fun getSuggestionsForQuery(query: String) : List<Suggestion> {
""".trimIndent() """.trimIndent()
) )
return Json.decodeFromString(result) ?: return emptyList()
}
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : Set<Int> { suspend fun getGalleryIDsFromNozomi(area: String?, tag: String, language: String) : Set<Int> {
val jsArea = if (area == null) "null" else "'$area'" val jsArea = if (area == null) "null" else "'$area'"
val json = webView.evaluatePromise("""get_galleryids_from_nozomi($jsArea, '$tag', '$language')""") return webView.evaluatePromise("""get_galleryids_from_nozomi($jsArea, '$tag', '$language')""")
return Json.decodeFromString(json)
} }

View File

@@ -29,7 +29,6 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder import androidx.core.app.TaskStackBuilder
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.common.util.concurrent.RateLimiter
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -166,9 +165,6 @@ class DownloadService : Service() {
} }
} }
private val rateLimiter = RateLimiter.create(2.0)
private val rateLimitHost = Regex("..?\\.hitomi.la")
private val interceptor: PupilInterceptor = { chain -> private val interceptor: PupilInterceptor = { chain ->
val request = chain.request() val request = chain.request()
@@ -222,31 +218,33 @@ class DownloadService : Service() {
val (galleryID, index, startId) = call.request().tag() as Tag val (galleryID, index, startId) = call.request().tag() as Tag
val ext = call.request().url().encodedPath().split('.').last() val ext = call.request().url().encodedPath().split('.').last()
kotlin.runCatching { CoroutineScope(Dispatchers.IO).launch {
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") runCatching {
val padding = ceil(progress[galleryID]?.size?.let { log10(it.toFloat()) } ?: 0F).toInt() response.also {
if (it.code() != 200) throw IOException(
"$galleryID $index ${response.request().url()} CODE ${it.code()}"
)
}.body()?.use {
val padding = ceil(progress[galleryID]?.size?.let { log10(it.toFloat()) } ?: 0F).toInt()
Cache.getInstance(this@DownloadService, galleryID)
.putImage(index, "${index.toString().padStart(padding, '0')}.$ext", it.byteStream())
CoroutineScope(Dispatchers.IO).launch {
kotlin.runCatching {
Cache.getInstance(this@DownloadService, galleryID).putImage(index, "${index.toString().padStart(padding, '0')}.$ext", image)
}.onSuccess {
progress[galleryID]?.set(index, Float.POSITIVE_INFINITY) progress[galleryID]?.set(index, Float.POSITIVE_INFINITY)
notify(galleryID) notify(galleryID)
if (isCompleted(galleryID)) { if (isCompleted(galleryID)) {
if (DownloadManager.getInstance(this@DownloadService) if (DownloadManager.getInstance(this@DownloadService)
.getDownloadFolder(galleryID) != null) .getDownloadFolder(galleryID) != null
)
Cache.getInstance(this@DownloadService, galleryID).moveToDownload() Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
startId?.let { stopSelf(it) } startId?.let { stopSelf(it) }
} }
}.onFailure { } ?: throw Exception("Response null")
FirebaseCrashlytics.getInstance().recordException(it) }.onFailure {
} FirebaseCrashlytics.getInstance().recordException(it)
} }
}.onFailure {
it.printStackTrace()
FirebaseCrashlytics.getInstance().recordException(it)
} }
} }
} }

View File

@@ -0,0 +1,22 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2022 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.types
class SendLogException : Exception()
class JavascriptException(message: String?) : Exception(message)

View File

@@ -19,6 +19,7 @@
package xyz.quaver.pupil.ui package xyz.quaver.pupil.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@@ -37,6 +38,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.webkit.WebViewCompat
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
@@ -105,6 +107,8 @@ class MainActivity :
private lateinit var binding: MainActivityBinding private lateinit var binding: MainActivityBinding
private var oldWebViewJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = MainActivityBinding.inflate(layoutInflater) binding = MainActivityBinding.inflate(layoutInflater)
@@ -125,9 +129,40 @@ class MainActivity :
if (Preferences["download_folder", ""].isEmpty()) if (Preferences["download_folder", ""].isEmpty())
DownloadLocationDialogFragment().show(supportFragmentManager, "Download Location Dialog") 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 && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Preferences["download_folder_ignore_warning", false] &&
!Preferences["download_folder_ignore_warning", false] &&
ContextCompat.getExternalFilesDirs(this, null).filterNotNull().map { Uri.fromFile(it).toString() } ContextCompat.getExternalFilesDirs(this, null).filterNotNull().map { Uri.fromFile(it).toString() }
.contains(Preferences["download_folder", ""]) .contains(Preferences["download_folder", ""])
) { ) {
@@ -169,6 +204,8 @@ class MainActivity :
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
oldWebViewJob?.cancel()
(binding.contents.recyclerview.adapter as? GalleryBlockAdapter)?.updateAll = false (binding.contents.recyclerview.adapter as? GalleryBlockAdapter)?.updateAll = false
} }

View File

@@ -26,6 +26,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.* import androidx.preference.*
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -36,6 +37,7 @@ import xyz.quaver.pupil.R
import xyz.quaver.pupil.client import xyz.quaver.pupil.client
import xyz.quaver.pupil.clientBuilder import xyz.quaver.pupil.clientBuilder
import xyz.quaver.pupil.clientHolder import xyz.quaver.pupil.clientHolder
import xyz.quaver.pupil.types.SendLogException
import xyz.quaver.pupil.ui.LockActivity import xyz.quaver.pupil.ui.LockActivity
import xyz.quaver.pupil.ui.SettingsActivity import xyz.quaver.pupil.ui.SettingsActivity
import xyz.quaver.pupil.ui.dialog.* import xyz.quaver.pupil.ui.dialog.*
@@ -107,6 +109,7 @@ class SettingsFragment :
ProxyDialogFragment().show(parentFragmentManager, "Proxy Dialog") ProxyDialogFragment().show(parentFragmentManager, "Proxy Dialog")
} }
"user_id" -> { "user_id" -> {
FirebaseCrashlytics.getInstance().recordException(SendLogException())
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip( (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText("user_id", Preferences.get<String>("user_id")) ClipData.newPlainText("user_id", Preferences.get<String>("user_id"))
) )

View File

@@ -43,6 +43,7 @@ import xyz.quaver.pupil.hitomi.getGalleryInfo
import xyz.quaver.pupil.userAgent import xyz.quaver.pupil.userAgent
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@Serializable @Serializable
@@ -210,12 +211,14 @@ class Cache private constructor(context: Context, val galleryID: Int) : ContextW
metadata.imageList?.getOrNull(index)?.let { findFile(it) } metadata.imageList?.getOrNull(index)?.let { findFile(it) }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
fun putImage(index: Int, fileName: String, data: ByteArray) { fun putImage(index: Int, fileName: String, data: InputStream) {
val file = cacheFolder.getChild(fileName) val file = cacheFolder.getChild(fileName)
if (!file.exists()) if (!file.exists())
file.createNewFile() file.createNewFile()
file.writeBytes(data) file.outputStream()?.use {
data.copyTo(it)
}
setMetadata { metadata -> metadata.imageList!![index] = fileName } setMetadata { metadata -> metadata.imageList!![index] = fileName }
} }

View File

@@ -157,4 +157,5 @@
<string name="settings_max_concurrent_download">並列ダウンロード</string> <string name="settings_max_concurrent_download">並列ダウンロード</string>
<string name="unaccessible_download_folder">アンドロイド11以上では外部からのアプリ内部空間接近が不可能です。ダウンロードフォルダを変更しますか</string> <string name="unaccessible_download_folder">アンドロイド11以上では外部からのアプリ内部空間接近が不可能です。ダウンロードフォルダを変更しますか</string>
<string name="settings_networking">ネットワーク</string> <string name="settings_networking">ネットワーク</string>
<string name="old_webview">WebViewのアップデートが必要です</string>
</resources> </resources>

View File

@@ -157,4 +157,5 @@
<string name="settings_max_concurrent_download">병렬 다운로드</string> <string name="settings_max_concurrent_download">병렬 다운로드</string>
<string name="unaccessible_download_folder">안드로이드 11 이상에서는 외부에서 현재 다운로드 폴더에 접근할 수 없습니다. 변경하시겠습니까?</string> <string name="unaccessible_download_folder">안드로이드 11 이상에서는 외부에서 현재 다운로드 폴더에 접근할 수 없습니다. 변경하시겠습니까?</string>
<string name="settings_networking">네트워크</string> <string name="settings_networking">네트워크</string>
<string name="old_webview">WebView 업데이트가 필요합니다</string>
</resources> </resources>

View File

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