Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37eb2f1aed | ||
|
|
c70ec5f354 | ||
|
|
7139af4629 | ||
|
|
a2bac029f5 | ||
|
|
6c68074e8b | ||
|
|
991caf73f4 | ||
|
|
0ba11641fa | ||
|
|
b51120e914 | ||
|
|
b02fb2d1d7 | ||
|
|
c6247230da | ||
|
|
d64be78b4e | ||
|
|
e75f633165 | ||
|
|
27c5598133 | ||
|
|
a9449bfbc7 | ||
|
|
4fecbe259b | ||
|
|
81d72edbf0 | ||
|
|
ea7e6e4624 | ||
|
|
6335d5bca4 | ||
|
|
227b430dbb | ||
|
|
0bba56a29f | ||
|
|
c07d9da7b0 | ||
|
|
900c5ed007 | ||
|
|
ba0788f6df | ||
|
|
c517856e89 | ||
|
|
791ee0ddbf | ||
|
|
0746938cb9 | ||
|
|
bbae24aba9 | ||
|
|
677edb0e02 | ||
|
|
a4328c5111 | ||
|
|
27cf341fef | ||
|
|
bc654e98b8 | ||
|
|
588f78e598 | ||
|
|
70f952285a | ||
|
|
a6f5e4f98d | ||
|
|
6fe5c1d666 | ||
|
|
1dba12ec94 | ||
|
|
aeb3f689d1 | ||
|
|
0a2268d4de | ||
|
|
9a32b020cf | ||
|
|
c5b01a7089 | ||
|
|
0634fbb724 | ||
|
|
445e9511a0 | ||
|
|
ac532acb5b | ||
|
|
7a8e21fbef | ||
|
|
58b0f3a41e | ||
|
|
ab373990cd | ||
|
|
272c157fe0 | ||
|
|
e5e047ee10 | ||
|
|
bf0b529018 | ||
|
|
acf2392406 | ||
|
|
cecd6fb359 | ||
|
|
7b67210876 | ||
|
|
e40c4f2c71 | ||
|
|
b0717b38b1 | ||
|
|
18c9b95b9b | ||
|
|
fc63c41ab0 | ||
|
|
d1c7434993 | ||
|
|
da15ba8b9a | ||
|
|
1d2a2a3608 | ||
|
|
145cd7a7c0 | ||
|
|
048415f3e0 | ||
|
|
95ccabc756 | ||
|
|
1f08c0e08c | ||
|
|
b036aeef23 | ||
|
|
d469a8404c | ||
|
|
5b16b6aed9 | ||
|
|
151949aecf | ||
|
|
de2c4ca3f5 | ||
|
|
e5bebcfe80 | ||
|
|
0080a482e4 | ||
|
|
756867416b | ||
|
|
124ff2f190 | ||
|
|
828a3d09b6 | ||
|
|
fc3b9d73ec | ||
|
|
ba943a31d4 | ||
|
|
8ee1ac7617 | ||
|
|
113f2a1794 | ||
|
|
f6028813e2 | ||
|
|
b094a32d12 | ||
|
|
fd3e4bdc60 | ||
|
|
142092d930 | ||
|
|
75e3e78ecb | ||
|
|
053832681f | ||
|
|
29e33d6e69 | ||
|
|
2d878e2f1c | ||
|
|
0fdbfbb1ae | ||
|
|
52f314f6b0 | ||
|
|
ff8d8916aa |
No files matched your search
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
- 'renovate/**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# 变更日志
|
||||
|
||||
## 1.10.0
|
||||
|
||||
### 重大变更
|
||||
|
||||
* 后备 HTTP 引擎变更为 Apache HttpClient 5
|
||||
* 类别和高级搜索选项移至搜索栏下方
|
||||
|
||||
### 新功能
|
||||
|
||||
* 支持在搜索时指定画廊语言
|
||||
|
||||
### 改进
|
||||
|
||||
* 在下载中阅读画廊时会更新历史记录
|
||||
* 多处性能改进
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 修复无法由外部应用进入画廊页面 [#436](https://github.com/FooIbar/EhViewer/issues/436)
|
||||
* 修复点击精确到页的画廊链接闪退 [#430](https://github.com/FooIbar/EhViewer/issues/430)
|
||||
* 修复无法搜索本地收藏 [#491](https://github.com/FooIbar/EhViewer/issues/491)
|
||||
* 修复 CI 版本不显示变更日志
|
||||
|
||||
|
||||
## 1.9.0
|
||||
|
||||
### 重大变更
|
||||
|
||||
@@ -75,7 +75,7 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
|
||||
|
||||
\* Archives and GIFs are not supported
|
||||
|
||||
** Fall back to OkHttp on devices that don't support [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine)
|
||||
** Fall back to Apache5 on devices that don't support [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine)
|
||||
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img alt="Get it on GitHub" src="https://github.com/Ehviewer-Overhauled/Art/blob/master/get-it-on-github.svg" width="200px"/>
|
||||
@@ -95,7 +95,7 @@ Here is the libraries
|
||||
- [Kotlin & KotlinX](https://kotlinlang.org/)
|
||||
- [MDC-Android](https://github.com/material-components/material-components-android)
|
||||
- [Material Icons](https://github.com/google/material-design-icons)
|
||||
- [OkHttp](https://square.github.io/okhttp/)
|
||||
- [Ktor](https://ktor.io/)
|
||||
- [Coil](https://coil-kt.github.io/coil/)
|
||||
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
|
||||
- [libarchive](http://www.libarchive.org/)
|
||||
|
||||
+3
-18
@@ -64,9 +64,8 @@ android {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
minSdk = 28
|
||||
targetSdk = 34
|
||||
versionCode = 180046
|
||||
versionCode = 180047
|
||||
versionName = "1.10.0"
|
||||
versionNameSuffix = "-SNAPSHOT"
|
||||
resourceConfigurations.addAll(
|
||||
listOf(
|
||||
"zh",
|
||||
@@ -132,7 +131,7 @@ android {
|
||||
"-Xjvm-default=all",
|
||||
"-Xlambdas=indy",
|
||||
|
||||
"-opt-in=coil.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
||||
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
|
||||
@@ -148,7 +147,6 @@ android {
|
||||
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
|
||||
|
||||
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
|
||||
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:intrinsicRemember=true",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -213,7 +211,6 @@ dependencies {
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
|
||||
implementation(libs.androidx.constraintlayout.compose)
|
||||
implementation(libs.androidx.coordinatorlayout)
|
||||
// https://developer.android.com/jetpack/androidx/releases/lifecycle
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.compose)
|
||||
@@ -223,7 +220,7 @@ dependencies {
|
||||
implementation(libs.bundles.androidx.navigation)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/paging
|
||||
implementation(libs.bundles.androidx.paging)
|
||||
implementation(libs.androidx.paging.compose)
|
||||
|
||||
implementation(libs.androidx.recyclerview)
|
||||
|
||||
@@ -234,16 +231,10 @@ dependencies {
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.photoview) // Dead Dependency
|
||||
implementation(libs.directionalviewpager) // Dead Dependency
|
||||
// https://github.com/google/accompanist/releases
|
||||
implementation(libs.bundles.accompanist)
|
||||
implementation(libs.material)
|
||||
|
||||
implementation(libs.bundles.splitties)
|
||||
|
||||
// https://square.github.io/okhttp/changelogs/changelog/
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.bundles.okhttp)
|
||||
|
||||
implementation(libs.okio.jvm)
|
||||
|
||||
implementation(libs.diff)
|
||||
@@ -269,9 +260,6 @@ dependencies {
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.jsoup)
|
||||
|
||||
debugImplementation(libs.chucker)
|
||||
releaseImplementation(libs.chucker.nop)
|
||||
|
||||
coreLibraryDesugaring(libs.desugar)
|
||||
|
||||
"gmsImplementation"(libs.bundles.cronet)
|
||||
@@ -279,9 +267,6 @@ dependencies {
|
||||
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
// Workaround IME won't show again once hidden.
|
||||
// Google issue-tracker link?
|
||||
force("androidx.compose.ui:ui-text-android:1.6.0-alpha08")
|
||||
exclude("androidx.navigation", "navigation-compose")
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+12
-4
@@ -11,10 +11,18 @@
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
# https://issuetracker.google.com/issues/307323842
|
||||
-keepclassmembers class androidx.compose.ui.platform.WindowInfoImpl {
|
||||
setWindowFocused(boolean);
|
||||
}
|
||||
# Ktor logger
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
|
||||
# Apache5 Http Client
|
||||
-dontwarn org.ietf.jgss.GSSContext
|
||||
-dontwarn org.ietf.jgss.GSSCredential
|
||||
-dontwarn org.ietf.jgss.GSSException
|
||||
-dontwarn org.ietf.jgss.GSSManager
|
||||
-dontwarn org.ietf.jgss.GSSName
|
||||
-dontwarn org.ietf.jgss.Oid
|
||||
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
|
||||
-allowaccessmodification
|
||||
-repackageclasses
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "c801ee62729ba20a203b150211234d5c",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "OK_HTTP_3_COOKIE",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`NAME` TEXT, `VALUE` TEXT, `EXPIRES_AT` INTEGER NOT NULL, `DOMAIN` TEXT, `PATH` TEXT, `SECURE` INTEGER NOT NULL, `HTTP_ONLY` INTEGER NOT NULL, `PERSISTENT` INTEGER NOT NULL, `HOST_ONLY` INTEGER NOT NULL, `_id` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "NAME",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "VALUE",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "expiresAt",
|
||||
"columnName": "EXPIRES_AT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "domain",
|
||||
"columnName": "DOMAIN",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "path",
|
||||
"columnName": "PATH",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "secure",
|
||||
"columnName": "SECURE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "httpOnly",
|
||||
"columnName": "HTTP_ONLY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "persistent",
|
||||
"columnName": "PERSISTENT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hostOnly",
|
||||
"columnName": "HOST_ONLY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c801ee62729ba20a203b150211234d5c')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "1b80cd29939b0f43934721f1289ba94d",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "OK_HTTP_3_COOKIE",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`NAME` TEXT NOT NULL, `VALUE` TEXT NOT NULL, `EXPIRES_AT` INTEGER NOT NULL, `DOMAIN` TEXT NOT NULL, `PATH` TEXT NOT NULL, `SECURE` INTEGER NOT NULL, `HTTP_ONLY` INTEGER NOT NULL, `PERSISTENT` INTEGER NOT NULL, `HOST_ONLY` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "NAME",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "VALUE",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "expiresAt",
|
||||
"columnName": "EXPIRES_AT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "domain",
|
||||
"columnName": "DOMAIN",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "path",
|
||||
"columnName": "PATH",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "secure",
|
||||
"columnName": "SECURE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "httpOnly",
|
||||
"columnName": "HTTP_ONLY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "persistent",
|
||||
"columnName": "PERSISTENT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hostOnly",
|
||||
"columnName": "HOST_ONLY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1b80cd29939b0f43934721f1289ba94d')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,106 +1,20 @@
|
||||
package com.hippo.ehviewer.cronet
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.CHROME_ACCEPT
|
||||
import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import okio.Buffer
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.chromium.net.CronetEngine
|
||||
import org.chromium.net.CronetException
|
||||
import org.chromium.net.UploadDataProvider
|
||||
import org.chromium.net.UploadDataSink
|
||||
import org.chromium.net.UrlRequest
|
||||
import org.chromium.net.UrlResponseInfo
|
||||
import splitties.init.appCtx
|
||||
|
||||
val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
|
||||
enableBrotli(true)
|
||||
setUserAgent(Settings.userAgent)
|
||||
|
||||
// Cache Quic hint only since the real cache mechanism should on Ktor layer
|
||||
val cache = (appCtx.cacheDir.toOkioPath() / "http_cache").toFile().apply { mkdirs() }
|
||||
setStoragePath(cache.absolutePath)
|
||||
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 100 * 1024)
|
||||
setUserAgent(Settings.userAgent)
|
||||
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096)
|
||||
|
||||
addQuicHint("e-hentai.org", 443, 443)
|
||||
addQuicHint("forums.e-hentai.org", 443, 443)
|
||||
addQuicHint("exhentai.org", 443, 443)
|
||||
}.build()
|
||||
|
||||
class CronetRequest {
|
||||
lateinit var consumer: (ByteBuffer) -> Unit
|
||||
lateinit var onResponse: CronetRequest.(UrlResponseInfo) -> Unit
|
||||
lateinit var request: UrlRequest
|
||||
lateinit var onError: (Throwable) -> Unit
|
||||
lateinit var readerCont: Continuation<Unit>
|
||||
val callback = object : UrlRequest.Callback() {
|
||||
override fun onRedirectReceived(req: UrlRequest, info: UrlResponseInfo, url: String) {
|
||||
req.followRedirect()
|
||||
}
|
||||
|
||||
override fun onResponseStarted(req: UrlRequest, info: UrlResponseInfo) {
|
||||
onResponse(info)
|
||||
}
|
||||
|
||||
override fun onReadCompleted(req: UrlRequest, info: UrlResponseInfo, data: ByteBuffer) {
|
||||
consumer(data)
|
||||
}
|
||||
|
||||
override fun onSucceeded(req: UrlRequest, info: UrlResponseInfo) {
|
||||
readerCont.resume(Unit)
|
||||
}
|
||||
|
||||
override fun onFailed(req: UrlRequest, info: UrlResponseInfo?, e: CronetException) {
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun cronetRequest(url: String, referer: String? = null, origin: String? = null, conf: UrlRequest.Builder.() -> Unit = {}) = CronetRequest().apply {
|
||||
request = cronetHttpClient.newUrlRequestBuilder(url, callback, cronetHttpClientExecutor).apply {
|
||||
addHeader("Cookie", EhCookieStore.getCookieHeader(url.toHttpUrl()))
|
||||
addHeader("Accept", CHROME_ACCEPT)
|
||||
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
|
||||
referer?.let { addHeader("Referer", it) }
|
||||
origin?.let { addHeader("Origin", it) }
|
||||
}.apply(conf).build()
|
||||
}
|
||||
|
||||
suspend inline fun <R> CronetRequest.execute(crossinline callback: suspend CronetRequest.(UrlResponseInfo) -> R): R {
|
||||
contract {
|
||||
callsInPlace(callback, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
return coroutineScope {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
onResponse = { launch { cont.resume(callback(it)) } }
|
||||
cont.invokeOnCancellation { request.cancel() }
|
||||
onError = { cont.resumeWithException(it) }
|
||||
request.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun UrlRequest.Builder.withRequestBody(body: RequestBody) {
|
||||
addHeader("Content-Type", body.contentType().toString())
|
||||
val buffer = Buffer().apply { body.writeTo(this) }
|
||||
val provider = object : UploadDataProvider() {
|
||||
override fun getLength() = body.contentLength()
|
||||
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
|
||||
buffer.read(byteBuffer)
|
||||
uploadDataSink.onReadSucceeded(false)
|
||||
}
|
||||
override fun rewind(uploadDataSink: UploadDataSink) {
|
||||
error("OneShot!")
|
||||
}
|
||||
}
|
||||
setUploadDataProvider(provider, cronetHttpClientExecutor)
|
||||
}
|
||||
|
||||
fun UrlRequest.Builder.noCache(): UrlRequest.Builder = disableCache()
|
||||
fun UrlResponseInfo.getHeadersMap(): Map<String, List<String>> = allHeaders
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import com.hippo.ehviewer.cronet.cronetDispatcher
|
||||
import com.hippo.ehviewer.cronet.cronetHttpClient
|
||||
import com.hippo.ehviewer.cronet.cronetHttpClientExecutor
|
||||
import com.hippo.ehviewer.cronet.pool
|
||||
import io.ktor.client.engine.HttpClientEngineBase
|
||||
import io.ktor.client.engine.HttpClientEngineConfig
|
||||
import io.ktor.client.engine.callContext
|
||||
import io.ktor.client.request.HttpRequestData
|
||||
import io.ktor.client.request.HttpResponseData
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpProtocolVersion
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.content.OutgoingContent
|
||||
import io.ktor.http.headers
|
||||
import io.ktor.util.date.GMTDate
|
||||
import io.ktor.util.flattenForEach
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.InternalAPI
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import io.ktor.utils.io.writer
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.chromium.net.CronetException
|
||||
import org.chromium.net.UploadDataProvider
|
||||
import org.chromium.net.UploadDataSink
|
||||
import org.chromium.net.UrlRequest
|
||||
import org.chromium.net.UrlResponseInfo
|
||||
|
||||
object CronetEngine : HttpClientEngineBase("Cronet") {
|
||||
override val config = HttpClientEngineConfig()
|
||||
override val dispatcher = cronetDispatcher
|
||||
|
||||
@InternalAPI
|
||||
override suspend fun execute(data: HttpRequestData): HttpResponseData {
|
||||
val callContext = callContext()
|
||||
|
||||
return executeHttpRequest(callContext, data)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private suspend fun executeHttpRequest(
|
||||
callContext: CoroutineContext,
|
||||
data: HttpRequestData,
|
||||
): HttpResponseData = suspendCancellableCoroutine { continuation ->
|
||||
val requestTime = GMTDate()
|
||||
|
||||
val callback = object : UrlRequest.Callback() {
|
||||
lateinit var chunkChan: Channel<ByteBuffer>
|
||||
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) {
|
||||
continuation.resume(
|
||||
info.toHttpResponseData(
|
||||
requestTime = requestTime,
|
||||
callContext = callContext,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
|
||||
val channel = GlobalScope.writer(callContext) {
|
||||
pool.useInstance {
|
||||
chunkChan = Channel(1)
|
||||
request.read(it)
|
||||
chunkChan.consumeEach { buffer ->
|
||||
buffer.flip()
|
||||
channel.writeFully(buffer)
|
||||
buffer.clear()
|
||||
request.read(buffer)
|
||||
}
|
||||
}
|
||||
}.channel
|
||||
continuation.resume(
|
||||
info.toHttpResponseData(
|
||||
requestTime = requestTime,
|
||||
callContext = callContext,
|
||||
responseBody = channel,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onReadCompleted(request: UrlRequest, info: UrlResponseInfo, byteBuffer: ByteBuffer) {
|
||||
chunkChan.trySend(byteBuffer).getOrThrow()
|
||||
}
|
||||
|
||||
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
|
||||
chunkChan.close()
|
||||
}
|
||||
|
||||
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
|
||||
if (::chunkChan.isInitialized) {
|
||||
chunkChan.close(error)
|
||||
} else {
|
||||
continuation.resumeWithException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val request = cronetHttpClient.newUrlRequestBuilder(data.url.toString(), callback, cronetHttpClientExecutor).apply {
|
||||
setHttpMethod(data.method.value)
|
||||
data.headers.flattenForEach { key, value -> addHeader(key, value) }
|
||||
data.body.contentType?.let { addHeader(HttpHeaders.ContentType, it.toString()) }
|
||||
data.body.contentLength?.let { addHeader(HttpHeaders.ContentLength, it.toString()) }
|
||||
data.body.toUploadDataProvider(callContext)?.let { setUploadDataProvider(it, cronetHttpClientExecutor) }
|
||||
}.build()
|
||||
request.start()
|
||||
callContext[Job]!!.invokeOnCompletion { request.cancel() }
|
||||
continuation.invokeOnCancellation { request.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun UrlResponseInfo.toHttpResponseData(
|
||||
requestTime: GMTDate,
|
||||
callContext: CoroutineContext,
|
||||
responseBody: ByteReadChannel = ByteReadChannel.Empty,
|
||||
): HttpResponseData {
|
||||
return HttpResponseData(
|
||||
statusCode = HttpStatusCode.fromValue(httpStatusCode),
|
||||
requestTime = requestTime,
|
||||
headers = headers {
|
||||
allHeaders.forEach { (key, value) ->
|
||||
appendAll(key, value)
|
||||
}
|
||||
},
|
||||
version = when (negotiatedProtocol) {
|
||||
"h2" -> HttpProtocolVersion.HTTP_2_0
|
||||
"h3" -> HttpProtocolVersion.QUIC
|
||||
"quic/1+spdy/3" -> HttpProtocolVersion.SPDY_3
|
||||
else -> HttpProtocolVersion.HTTP_1_1
|
||||
},
|
||||
body = responseBody,
|
||||
callContext = callContext,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun OutgoingContent.toUploadDataProvider(callContext: CoroutineContext): UploadDataProvider? = when (this) {
|
||||
is OutgoingContent.NoContent -> null
|
||||
is OutgoingContent.ByteArrayContent -> object : UploadDataProvider() {
|
||||
val buffer = ByteBuffer.wrap(bytes()).slice()
|
||||
override fun getLength() = buffer.limit().toLong()
|
||||
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
|
||||
byteBuffer.put(buffer)
|
||||
uploadDataSink.onReadSucceeded(false)
|
||||
}
|
||||
override fun rewind(uploadDataSink: UploadDataSink) {
|
||||
buffer.position(0)
|
||||
uploadDataSink.onRewindSucceeded()
|
||||
}
|
||||
}
|
||||
is OutgoingContent.WriteChannelContent -> object : UploadDataProvider() {
|
||||
fun launch() = GlobalScope.writer(callContext) { writeTo(channel) }.channel
|
||||
var chan = launch()
|
||||
override fun getLength() = contentLength!!
|
||||
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
|
||||
GlobalScope.launch(callContext) {
|
||||
chan.readAvailable(byteBuffer)
|
||||
uploadDataSink.onReadSucceeded(false)
|
||||
}
|
||||
}
|
||||
override fun rewind(uploadDataSink: UploadDataSink) {
|
||||
chan = launch()
|
||||
uploadDataSink.onRewindSucceeded()
|
||||
}
|
||||
}
|
||||
else -> error("UnsupportedContentType $this")
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
import com.google.android.gms.net.CronetProviderInstaller
|
||||
import com.hippo.ehviewer.Settings
|
||||
import org.chromium.net.CronetProvider
|
||||
import splitties.init.appCtx
|
||||
|
||||
val isCronetSupported: Boolean
|
||||
get() = Settings.enableCronet && CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
|
||||
val isCronetAvailable: Boolean
|
||||
get() = CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
|
||||
if (!it) {
|
||||
CronetProviderInstaller.installProvider(appCtx)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -30,6 +31,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.BlurEffect
|
||||
@@ -302,9 +304,13 @@ fun ModalNavigationDrawer(
|
||||
Modifier
|
||||
}
|
||||
Box(modifier.fillMaxSize().then(dragModifier)) {
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
|
||||
lerp(0, 10, step).dp
|
||||
}
|
||||
}.collectAsState(0.dp)
|
||||
val blurModifier = Modifier.graphicsLayer {
|
||||
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
|
||||
val radius = lerp(0.dp, 10.dp, step)
|
||||
if (radius != 0.dp) {
|
||||
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
|
||||
shape = RectangleShape
|
||||
@@ -436,9 +442,13 @@ fun SideDrawer(
|
||||
Modifier
|
||||
}
|
||||
Box(modifier.fillMaxSize().then(dragModifier)) {
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
|
||||
lerp(0, 10, step).dp
|
||||
}
|
||||
}.collectAsState(0.dp)
|
||||
val blurModifier = Modifier.graphicsLayer {
|
||||
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
|
||||
val radius = lerp(0.dp, 10.dp, step)
|
||||
if (radius != 0.dp) {
|
||||
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
|
||||
shape = RectangleShape
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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 com.google.accompanist.web
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.google.accompanist.web.LoadingState.Finished
|
||||
import com.google.accompanist.web.LoadingState.Loading
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* A wrapper around the Android View WebView to provide a basic WebView composable.
|
||||
*
|
||||
* If you require more customisation you are most likely better rolling your own and using this
|
||||
* wrapper as an example.
|
||||
*
|
||||
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it
|
||||
* is incorrectly sizing, use the layoutParams composable function instead.
|
||||
*
|
||||
* @param state The webview state holder where the Uri to load is defined.
|
||||
* @param modifier A compose modifier
|
||||
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
|
||||
* the WebView back.
|
||||
* @param navigator An optional navigator object that can be used to control the WebView's
|
||||
* navigation from outside the composable.
|
||||
* @param onCreated Called when the WebView is first created, this can be used to set additional
|
||||
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be
|
||||
* subsequently overwritten after this lambda is called.
|
||||
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved
|
||||
* if you need to save and restore state in this WebView.
|
||||
* @param client Provides access to WebViewClient via subclassing
|
||||
* @param chromeClient Provides access to WebChromeClient via subclassing
|
||||
* @param factory An optional WebView factory for using a custom subclass of WebView
|
||||
*/
|
||||
@Composable
|
||||
fun WebView(
|
||||
state: WebViewState,
|
||||
modifier: Modifier = Modifier,
|
||||
captureBackPresses: Boolean = true,
|
||||
navigator: WebViewNavigator = rememberWebViewNavigator(),
|
||||
onCreated: (WebView) -> Unit = {},
|
||||
onDispose: (WebView) -> Unit = {},
|
||||
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
|
||||
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
|
||||
factory: ((Context) -> WebView)? = null,
|
||||
) {
|
||||
BoxWithConstraints(modifier) {
|
||||
// WebView changes it's layout strategy based on
|
||||
// it's layoutParams. We convert from Compose Modifier to
|
||||
// layout params here.
|
||||
val width =
|
||||
if (constraints.hasFixedWidth) {
|
||||
LayoutParams.MATCH_PARENT
|
||||
} else {
|
||||
LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
val height =
|
||||
if (constraints.hasFixedHeight) {
|
||||
LayoutParams.MATCH_PARENT
|
||||
} else {
|
||||
LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
|
||||
val layoutParams = FrameLayout.LayoutParams(
|
||||
width,
|
||||
height,
|
||||
)
|
||||
|
||||
WebView(
|
||||
state,
|
||||
layoutParams,
|
||||
Modifier,
|
||||
captureBackPresses,
|
||||
navigator,
|
||||
onCreated,
|
||||
onDispose,
|
||||
client,
|
||||
chromeClient,
|
||||
factory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper around the Android View WebView to provide a basic WebView composable.
|
||||
*
|
||||
* If you require more customisation you are most likely better rolling your own and using this
|
||||
* wrapper as an example.
|
||||
*
|
||||
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it
|
||||
* is incorrectly sizing, use the layoutParams composable function instead.
|
||||
*
|
||||
* @param state The webview state holder where the Uri to load is defined.
|
||||
* @param layoutParams A FrameLayout.LayoutParams object to custom size the underlying WebView.
|
||||
* @param modifier A compose modifier
|
||||
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
|
||||
* the WebView back.
|
||||
* @param navigator An optional navigator object that can be used to control the WebView's
|
||||
* navigation from outside the composable.
|
||||
* @param onCreated Called when the WebView is first created, this can be used to set additional
|
||||
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be
|
||||
* subsequently overwritten after this lambda is called.
|
||||
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved
|
||||
* if you need to save and restore state in this WebView.
|
||||
* @param client Provides access to WebViewClient via subclassing
|
||||
* @param chromeClient Provides access to WebChromeClient via subclassing
|
||||
* @param factory An optional WebView factory for using a custom subclass of WebView
|
||||
*/
|
||||
@Composable
|
||||
fun WebView(
|
||||
state: WebViewState,
|
||||
layoutParams: FrameLayout.LayoutParams,
|
||||
modifier: Modifier = Modifier,
|
||||
captureBackPresses: Boolean = true,
|
||||
navigator: WebViewNavigator = rememberWebViewNavigator(),
|
||||
onCreated: (WebView) -> Unit = {},
|
||||
onDispose: (WebView) -> Unit = {},
|
||||
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
|
||||
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
|
||||
factory: ((Context) -> WebView)? = null,
|
||||
) {
|
||||
val webView = state.webView
|
||||
|
||||
BackHandler(captureBackPresses && navigator.canGoBack) {
|
||||
webView?.goBack()
|
||||
}
|
||||
|
||||
webView?.let { wv ->
|
||||
LaunchedEffect(wv, navigator) {
|
||||
with(navigator) {
|
||||
wv.handleNavigationEvents()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(wv, state) {
|
||||
snapshotFlow { state.content }.collect { content ->
|
||||
when (content) {
|
||||
is WebContent.Url -> {
|
||||
wv.loadUrl(content.url, content.additionalHttpHeaders)
|
||||
}
|
||||
|
||||
is WebContent.Data -> {
|
||||
wv.loadDataWithBaseURL(
|
||||
content.baseUrl,
|
||||
content.data,
|
||||
content.mimeType,
|
||||
content.encoding,
|
||||
content.historyUrl,
|
||||
)
|
||||
}
|
||||
|
||||
is WebContent.Post -> {
|
||||
wv.postUrl(
|
||||
content.url,
|
||||
content.postData,
|
||||
)
|
||||
}
|
||||
|
||||
is WebContent.NavigatorOnly -> {
|
||||
// NO-OP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the state of the client and chrome client
|
||||
// This is done internally to ensure they always are the same instance as the
|
||||
// parent Web composable
|
||||
client.state = state
|
||||
client.navigator = navigator
|
||||
chromeClient.state = state
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
(factory?.invoke(context) ?: WebView(context)).apply {
|
||||
onCreated(this)
|
||||
|
||||
this.layoutParams = layoutParams
|
||||
|
||||
state.viewState?.let {
|
||||
this.restoreState(it)
|
||||
}
|
||||
|
||||
webChromeClient = chromeClient
|
||||
webViewClient = client
|
||||
}.also { state.webView = it }
|
||||
},
|
||||
modifier = modifier,
|
||||
onRelease = {
|
||||
onDispose(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* AccompanistWebViewClient
|
||||
*
|
||||
* A parent class implementation of WebViewClient that can be subclassed to add custom behaviour.
|
||||
*
|
||||
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
|
||||
* class that can be overriden if further custom behaviour is required.
|
||||
*/
|
||||
open class AccompanistWebViewClient : WebViewClient() {
|
||||
open lateinit var state: WebViewState
|
||||
internal set
|
||||
open lateinit var navigator: WebViewNavigator
|
||||
internal set
|
||||
|
||||
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
state.loadingState = Loading(0.0f)
|
||||
state.errorsForCurrentRequest.clear()
|
||||
state.pageTitle = null
|
||||
state.pageIcon = null
|
||||
|
||||
state.lastLoadedUrl = url
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
state.loadingState = Finished
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) {
|
||||
super.doUpdateVisitedHistory(view, url, isReload)
|
||||
|
||||
navigator.canGoBack = view.canGoBack()
|
||||
navigator.canGoForward = view.canGoForward()
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?,
|
||||
) {
|
||||
super.onReceivedError(view, request, error)
|
||||
|
||||
if (error != null) {
|
||||
state.errorsForCurrentRequest.add(WebViewError(request, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AccompanistWebChromeClient
|
||||
*
|
||||
* A parent class implementation of WebChromeClient that can be subclassed to add custom behaviour.
|
||||
*
|
||||
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
|
||||
* class that can be overriden if further custom behaviour is required.
|
||||
*/
|
||||
open class AccompanistWebChromeClient : WebChromeClient() {
|
||||
open lateinit var state: WebViewState
|
||||
internal set
|
||||
|
||||
override fun onReceivedTitle(view: WebView, title: String?) {
|
||||
super.onReceivedTitle(view, title)
|
||||
state.pageTitle = title
|
||||
}
|
||||
|
||||
override fun onReceivedIcon(view: WebView, icon: Bitmap?) {
|
||||
super.onReceivedIcon(view, icon)
|
||||
state.pageIcon = icon
|
||||
}
|
||||
|
||||
override fun onProgressChanged(view: WebView, newProgress: Int) {
|
||||
super.onProgressChanged(view, newProgress)
|
||||
if (state.loadingState is Finished) return
|
||||
state.loadingState = Loading(newProgress / 100.0f)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class WebContent {
|
||||
data class Url(
|
||||
val url: String,
|
||||
val additionalHttpHeaders: Map<String, String> = emptyMap(),
|
||||
) : WebContent()
|
||||
|
||||
data class Data(
|
||||
val data: String,
|
||||
val baseUrl: String? = null,
|
||||
val encoding: String = "utf-8",
|
||||
val mimeType: String? = null,
|
||||
val historyUrl: String? = null,
|
||||
) : WebContent()
|
||||
|
||||
data class Post(
|
||||
val url: String,
|
||||
val postData: ByteArray,
|
||||
) : WebContent() {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Post
|
||||
|
||||
if (url != other.url) return false
|
||||
if (!postData.contentEquals(other.postData)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = url.hashCode()
|
||||
result = 31 * result + postData.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
data object NavigatorOnly : WebContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed class for constraining possible loading states.
|
||||
* See [Loading] and [Finished].
|
||||
*/
|
||||
sealed class LoadingState {
|
||||
/**
|
||||
* Describes a WebView that has not yet loaded for the first time.
|
||||
*/
|
||||
data object Initializing : LoadingState()
|
||||
|
||||
/**
|
||||
* Describes a webview between `onPageStarted` and `onPageFinished` events, contains a
|
||||
* [progress] property which is updated by the webview.
|
||||
*/
|
||||
data class Loading(val progress: Float) : LoadingState()
|
||||
|
||||
/**
|
||||
* Describes a webview that has finished loading content.
|
||||
*/
|
||||
data object Finished : LoadingState()
|
||||
}
|
||||
|
||||
/**
|
||||
* A state holder to hold the state for the WebView. In most cases this will be remembered
|
||||
* using the rememberWebViewState(uri) function.
|
||||
*/
|
||||
@Stable
|
||||
class WebViewState(webContent: WebContent) {
|
||||
var lastLoadedUrl: String? by mutableStateOf(null)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* The content being loaded by the WebView
|
||||
*/
|
||||
var content: WebContent by mutableStateOf(webContent)
|
||||
|
||||
/**
|
||||
* Whether the WebView is currently [LoadingState.Loading] data in its main frame (along with
|
||||
* progress) or the data loading has [LoadingState.Finished]. See [LoadingState]
|
||||
*/
|
||||
var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* Whether the webview is currently loading data in its main frame
|
||||
*/
|
||||
val isLoading: Boolean
|
||||
get() = loadingState !is Finished
|
||||
|
||||
/**
|
||||
* The title received from the loaded content of the current page
|
||||
*/
|
||||
var pageTitle: String? by mutableStateOf(null)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* the favicon received from the loaded content of the current page
|
||||
*/
|
||||
var pageIcon: Bitmap? by mutableStateOf(null)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* A list for errors captured in the last load. Reset when a new page is loaded.
|
||||
* Errors could be from any resource (iframe, image, etc.), not just for the main page.
|
||||
* For more fine grained control use the OnError callback of the WebView.
|
||||
*/
|
||||
val errorsForCurrentRequest: SnapshotStateList<WebViewError> = mutableStateListOf()
|
||||
|
||||
/**
|
||||
* The saved view state from when the view was destroyed last. To restore state,
|
||||
* use the navigator and only call loadUrl if the bundle is null.
|
||||
* See WebViewSaveStateSample.
|
||||
*/
|
||||
var viewState: Bundle? = null
|
||||
internal set
|
||||
|
||||
// We need access to this in the state saver. An internal DisposableEffect or AndroidView
|
||||
// onDestroy is called after the state saver and so can't be used.
|
||||
internal var webView by mutableStateOf<WebView?>(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows control over the navigation of a WebView from outside the composable. E.g. for performing
|
||||
* a back navigation in response to the user clicking the "up" button in a TopAppBar.
|
||||
*
|
||||
* @see [rememberWebViewNavigator]
|
||||
*/
|
||||
@Stable
|
||||
class WebViewNavigator(private val coroutineScope: CoroutineScope) {
|
||||
private sealed interface NavigationEvent {
|
||||
data object Back : NavigationEvent
|
||||
data object Forward : NavigationEvent
|
||||
data object Reload : NavigationEvent
|
||||
data object StopLoading : NavigationEvent
|
||||
|
||||
data class LoadUrl(
|
||||
val url: String,
|
||||
val additionalHttpHeaders: Map<String, String> = emptyMap(),
|
||||
) : NavigationEvent
|
||||
|
||||
data class LoadHtml(
|
||||
val html: String,
|
||||
val baseUrl: String? = null,
|
||||
val mimeType: String? = null,
|
||||
val encoding: String? = "utf-8",
|
||||
val historyUrl: String? = null,
|
||||
) : NavigationEvent
|
||||
|
||||
data class PostUrl(
|
||||
val url: String,
|
||||
val postData: ByteArray,
|
||||
) : NavigationEvent {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as PostUrl
|
||||
|
||||
if (url != other.url) return false
|
||||
if (!postData.contentEquals(other.postData)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = url.hashCode()
|
||||
result = 31 * result + postData.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val navigationEvents: MutableSharedFlow<NavigationEvent> = MutableSharedFlow(replay = 1)
|
||||
|
||||
// Use Dispatchers.Main to ensure that the webview methods are called on UI thread
|
||||
internal suspend fun WebView.handleNavigationEvents(): Nothing = withContext(Dispatchers.Main) {
|
||||
navigationEvents.collect { event ->
|
||||
when (event) {
|
||||
is NavigationEvent.Back -> goBack()
|
||||
is NavigationEvent.Forward -> goForward()
|
||||
is NavigationEvent.Reload -> reload()
|
||||
is NavigationEvent.StopLoading -> stopLoading()
|
||||
is NavigationEvent.LoadHtml -> loadDataWithBaseURL(
|
||||
event.baseUrl,
|
||||
event.html,
|
||||
event.mimeType,
|
||||
event.encoding,
|
||||
event.historyUrl,
|
||||
)
|
||||
|
||||
is NavigationEvent.LoadUrl -> {
|
||||
loadUrl(event.url, event.additionalHttpHeaders)
|
||||
}
|
||||
|
||||
is NavigationEvent.PostUrl -> {
|
||||
postUrl(event.url, event.postData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the web view is able to navigate backwards, false otherwise.
|
||||
*/
|
||||
var canGoBack: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* True when the web view is able to navigate forwards, false otherwise.
|
||||
*/
|
||||
var canGoForward: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
fun loadUrl(url: String, additionalHttpHeaders: Map<String, String> = emptyMap()) {
|
||||
coroutineScope.launch {
|
||||
navigationEvents.emit(
|
||||
NavigationEvent.LoadUrl(
|
||||
url,
|
||||
additionalHttpHeaders,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadHtml(
|
||||
html: String,
|
||||
baseUrl: String? = null,
|
||||
mimeType: String? = null,
|
||||
encoding: String? = "utf-8",
|
||||
historyUrl: String? = null,
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
navigationEvents.emit(
|
||||
NavigationEvent.LoadHtml(
|
||||
html,
|
||||
baseUrl,
|
||||
mimeType,
|
||||
encoding,
|
||||
historyUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun postUrl(
|
||||
url: String,
|
||||
postData: ByteArray,
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
navigationEvents.emit(
|
||||
NavigationEvent.PostUrl(
|
||||
url,
|
||||
postData,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates the webview back to the previous page.
|
||||
*/
|
||||
fun navigateBack() {
|
||||
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates the webview forward after going back from a page.
|
||||
*/
|
||||
fun navigateForward() {
|
||||
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the current page in the webview.
|
||||
*/
|
||||
fun reload() {
|
||||
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the current page load (if one is loading).
|
||||
*/
|
||||
fun stopLoading() {
|
||||
coroutineScope.launch { navigationEvents.emit(NavigationEvent.StopLoading) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and remembers a [WebViewNavigator] using the default [CoroutineScope] or a provided
|
||||
* override.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWebViewNavigator(
|
||||
coroutineScope: CoroutineScope = rememberCoroutineScope(),
|
||||
): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope) }
|
||||
|
||||
/**
|
||||
* A wrapper class to hold errors from the WebView.
|
||||
*/
|
||||
@Immutable
|
||||
data class WebViewError(
|
||||
/**
|
||||
* The request the error came from.
|
||||
*/
|
||||
val request: WebResourceRequest?,
|
||||
/**
|
||||
* The error that was reported.
|
||||
*/
|
||||
val error: WebResourceError,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a WebView state that is remembered across Compositions.
|
||||
*
|
||||
* @param url The url to load in the WebView
|
||||
* @param additionalHttpHeaders Optional, additional HTTP headers that are passed to [WebView.loadUrl].
|
||||
* Note that these headers are used for all subsequent requests of the WebView.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWebViewState(
|
||||
url: String,
|
||||
additionalHttpHeaders: Map<String, String> = emptyMap(),
|
||||
): WebViewState =
|
||||
// Rather than using .apply {} here we will recreate the state, this prevents
|
||||
// a recomposition loop when the webview updates the url itself.
|
||||
remember {
|
||||
WebViewState(
|
||||
WebContent.Url(
|
||||
url = url,
|
||||
additionalHttpHeaders = additionalHttpHeaders,
|
||||
),
|
||||
)
|
||||
}.apply {
|
||||
this.content = WebContent.Url(
|
||||
url = url,
|
||||
additionalHttpHeaders = additionalHttpHeaders,
|
||||
)
|
||||
}
|
||||
@@ -18,29 +18,31 @@ package com.hippo.ehviewer
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.os.StrictMode
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.util.DebugLogger
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.asCoilImage
|
||||
import coil3.decode.ImageDecoderDecoder
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.coil.MergeInterceptor
|
||||
import com.hippo.ehviewer.coil.installCronetHttpUriFetcher
|
||||
import com.hippo.ehviewer.coil.installKtorHttpUriFetcher
|
||||
import com.hippo.ehviewer.dailycheck.checkDawn
|
||||
import com.hippo.ehviewer.dao.SearchDatabase
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.ktbuilder.cache
|
||||
import com.hippo.ehviewer.ktbuilder.chunker
|
||||
import com.hippo.ehviewer.ktbuilder.diskCache
|
||||
import com.hippo.ehviewer.ktbuilder.httpClient
|
||||
import com.hippo.ehviewer.ktbuilder.imageLoader
|
||||
import com.hippo.ehviewer.ktor.CronetEngine
|
||||
import com.hippo.ehviewer.legacy.cleanObsoleteCache
|
||||
import com.hippo.ehviewer.legacy.migrateCookies
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.ui.lockObserver
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
@@ -49,18 +51,15 @@ import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import com.hippo.ehviewer.util.isCronetSupported
|
||||
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
||||
import com.hippo.ehviewer.util.isCronetAvailable
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.apache5.Apache5
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.kt.unreachable
|
||||
import okhttp3.AsyncDns
|
||||
import okhttp3.android.AndroidAsyncDns
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import splitties.arch.room.roomDb
|
||||
import splitties.init.appCtx
|
||||
@@ -68,7 +67,7 @@ import splitties.init.appCtx
|
||||
private val lifecycle = ProcessLifecycleOwner.get().lifecycle
|
||||
private val lifecycleScope = lifecycle.coroutineScope
|
||||
|
||||
class EhApplication : Application(), ImageLoaderFactory {
|
||||
class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
override fun onCreate() {
|
||||
// Initialize Settings on first access
|
||||
lifecycleScope.launchIO {
|
||||
@@ -97,9 +96,6 @@ class EhApplication : Application(), ImageLoaderFactory {
|
||||
System.loadLibrary("ehviewer_rust")
|
||||
ReadableTime.initialize(this)
|
||||
lifecycleScope.launchIO {
|
||||
launchIO {
|
||||
migrateCookies()
|
||||
}
|
||||
launchIO {
|
||||
EhTagDatabase
|
||||
}
|
||||
@@ -120,8 +116,9 @@ class EhApplication : Application(), ImageLoaderFactory {
|
||||
}
|
||||
cleanObsoleteCache(this)
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.enableDefaults()
|
||||
Snapshot.registerApplyObserver { anies, _ ->
|
||||
logcat { anies.toString() }
|
||||
logcat(Log.VERBOSE) { anies.toString() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,12 +156,7 @@ class EhApplication : Application(), ImageLoaderFactory {
|
||||
|
||||
override fun newImageLoader() = imageLoader {
|
||||
components {
|
||||
if (isCronetSupported) {
|
||||
callFactory { unreachable() }
|
||||
installCronetHttpUriFetcher()
|
||||
} else {
|
||||
callFactory(baseOkHttpClient)
|
||||
}
|
||||
installKtorHttpUriFetcher()
|
||||
if (isAtLeastP) {
|
||||
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
|
||||
}
|
||||
@@ -172,37 +164,34 @@ class EhApplication : Application(), ImageLoaderFactory {
|
||||
}
|
||||
diskCache(imageCache)
|
||||
crossfade(300)
|
||||
error(R.drawable.image_failed)
|
||||
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
|
||||
if (drawable != null) error(drawable.asCoilImage(true))
|
||||
if (BuildConfig.DEBUG) logger(DebugLogger())
|
||||
respectCacheHeaders(false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val baseOkHttpClient by lazy {
|
||||
httpClient {
|
||||
cookieJar(EhCookieStore)
|
||||
if (isAtLeastQ) {
|
||||
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
|
||||
val ktorClient by lazy {
|
||||
if (Settings.enableCronet && isCronetAvailable) {
|
||||
HttpClient(CronetEngine) {
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HttpClient(Apache5) {
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
}
|
||||
chunker { alwaysReadResponseBody(false) }
|
||||
addInterceptor(UncaughtExceptionInterceptor())
|
||||
addInterceptor(CloudflareInterceptor(appCtx))
|
||||
}
|
||||
}
|
||||
|
||||
val noRedirectOkHttpClient by lazy {
|
||||
httpClient(baseOkHttpClient) {
|
||||
followRedirects(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Never use this okhttp client to download large blobs!!!
|
||||
val okHttpClient by lazy {
|
||||
httpClient(baseOkHttpClient) {
|
||||
cache(
|
||||
appCtx.cacheDir.toOkioPath() / "http_cache",
|
||||
20L * 1024L * 1024L,
|
||||
)
|
||||
val noRedirectKtorClient by lazy {
|
||||
HttpClient(ktorClient.engine) {
|
||||
followRedirects = false
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.compose.runtime.remember
|
||||
import com.hippo.ehviewer.client.CHROME_USER_AGENT
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import java.util.Locale
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -105,8 +107,10 @@ object Settings : DataStorePreferences(null) {
|
||||
val detailSize = intPref("detail_size_2", 0)
|
||||
val thumbColumns = intPref("thumb_columns", 3)
|
||||
val listThumbSize = intPref("list_tile_size", 40)
|
||||
val languageFilter = intPref("language_filter", -1)
|
||||
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
|
||||
val predictiveNavAnim = boolPref("predictive_nav_anim", true)
|
||||
val blackDarkTheme = boolPref("black_dark_theme", false)
|
||||
|
||||
val needSignInFlow: Flow<Boolean>
|
||||
var downloadScheme by stringOrNullPref("image_scheme", null)
|
||||
@@ -143,7 +147,6 @@ object Settings : DataStorePreferences(null) {
|
||||
var security by boolPref("require_unlock", false)
|
||||
var removeImageFiles by boolPref("include_pic", true)
|
||||
var needSignIn by boolPref("need_sign_in", true).also { needSignInFlow = it.valueFlow() }
|
||||
var blackDarkTheme by boolPref("black_dark_theme", false).observed { updateWhenAmoledModeChanges() }
|
||||
var harmonizeCategoryColor by boolPref("harmonize_category_color", true)
|
||||
var preloadThumbAggressively by boolPref("preload_thumb_aggressively", false)
|
||||
var downloadOriginImage by boolPref("download_origin_image", false)
|
||||
@@ -155,7 +158,7 @@ object Settings : DataStorePreferences(null) {
|
||||
var searchCategory by intPref("search_pref", EhUtils.ALL_CATEGORY)
|
||||
var requestNewsTimerHour by intPref("request_news_timer_hour", -1)
|
||||
var requestNewsTimerMinute by intPref("request_news_timer_minute", -1)
|
||||
var updateIntervalDays by intPref("update_interval_days", 0)
|
||||
var updateIntervalDays by intPref("update_interval_days", 7)
|
||||
var recentToplist by stringPref("recent_toplist", "11")
|
||||
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
|
||||
var recentDownloadLabel by stringOrNullPref("recent_download_label", null)
|
||||
@@ -163,13 +166,23 @@ object Settings : DataStorePreferences(null) {
|
||||
var displayName by stringOrNullPref("display_name", null)
|
||||
var avatar by stringOrNullPref("avatar", null)
|
||||
var language by stringPref("app_language", "system").observed { updateWhenLocaleChanges() }
|
||||
var lastDawnDay by longPref("last_dawn_day", 0)
|
||||
var lastUpdateDay by longPref("last_update_day", 0)
|
||||
var lastDawnTime by longPref("last_dawn_time", 0)
|
||||
var lastUpdateTime by longPref("last_update_time", 0)
|
||||
var favDialogTheta by floatPref("fav_select_dialog_delta", 0F)
|
||||
|
||||
// TODO: Remove this after swipe gestures are correctly handled in compose
|
||||
var touchSlopFactor by intPref("touch_slop", 3)
|
||||
|
||||
// Tachiyomi Reader
|
||||
val colorFilter = boolPref("pref_color_filter_key", false)
|
||||
val colorFilterValue = intPref("color_filter_value", 0)
|
||||
val colorFilterMode = intPref("color_filter_mode", 0)
|
||||
val customBrightness = boolPref("pref_custom_brightness_key", false)
|
||||
val customBrightnessValue = intPref("custom_brightness_value", 0)
|
||||
val readingMode = intPref("pref_default_reading_mode_key", ReadingModeType.LEFT_TO_RIGHT.flagValue)
|
||||
val orientationMode = intPref("pref_default_orientation_type_key", OrientationType.FREE.flagValue)
|
||||
val showReaderSeekbar = boolPref("pref_show_reader_seekbar", true)
|
||||
|
||||
init {
|
||||
if ("CN" == Locale.getDefault().country) {
|
||||
edit {
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.hippo.ehviewer
|
||||
|
||||
import android.app.UiModeManager
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
@@ -59,14 +58,6 @@ fun updateWhenThemeChanges() {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWhenAmoledModeChanges() {
|
||||
collectScope.launch {
|
||||
delay(100) // Avoid recompose being cancelled
|
||||
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_AUTO_BATTERY) // Trigger onNightModeChanged
|
||||
AppCompatDelegate.setDefaultNightMode(Settings.theme)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWhenRequestNewsChanges() {
|
||||
updateDailyCheckWork(appCtx)
|
||||
}
|
||||
|
||||
@@ -1,50 +1,22 @@
|
||||
/*
|
||||
* Copyright 2016 Hippo Seven
|
||||
*
|
||||
* 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 com.hippo.ehviewer.client
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import androidx.compose.ui.util.fastFilter
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import androidx.compose.ui.util.fastJoinToString
|
||||
import androidx.compose.ui.util.fastMapNotNull
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import io.ktor.client.plugins.cookies.CookiesStorage
|
||||
import io.ktor.http.Cookie
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.http.parseClientCookiesHeader
|
||||
import io.ktor.http.renderSetCookieHeader
|
||||
|
||||
object EhCookieStore : CookieJar {
|
||||
object EhCookieStore : CookiesStorage {
|
||||
private val manager = CookieManager.getInstance()
|
||||
fun signOut() = manager.removeAllCookies(null)
|
||||
fun contains(url: HttpUrl, name: String) = get(url).fastAny { it.name == name }
|
||||
|
||||
fun get(url: HttpUrl): List<Cookie> {
|
||||
val cookies = manager.getCookie(url.toString())
|
||||
|
||||
return if (!cookies.isNullOrEmpty()) {
|
||||
cookies.split(";").fastMapNotNull { setCookie ->
|
||||
Cookie.parse(url, setCookie)?.takeUnless { it.name == KEY_UTMP_NAME }
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
fun contains(url: String, name: String) = load(Url(url)).fastAny { it.name == name }
|
||||
|
||||
fun hasSignedIn(): Boolean {
|
||||
val url = EhUrl.HOST_E.toHttpUrl()
|
||||
val url = EhUrl.HOST_E
|
||||
return contains(url, KEY_IPB_MEMBER_ID) && contains(url, KEY_IPB_PASS_HASH)
|
||||
}
|
||||
|
||||
@@ -55,16 +27,13 @@ object EhCookieStore : CookieJar {
|
||||
private const val KEY_CONTENT_WARNING = "nw"
|
||||
private const val CONTENT_WARNING_NOT_SHOW = "1"
|
||||
private const val KEY_UTMP_NAME = "__utmp"
|
||||
private val sTipsCookie = Cookie.Builder().apply {
|
||||
name(KEY_CONTENT_WARNING)
|
||||
value(CONTENT_WARNING_NOT_SHOW)
|
||||
domain(EhUrl.DOMAIN_E)
|
||||
path("/")
|
||||
expiresAt(Long.MAX_VALUE)
|
||||
}.build()
|
||||
private val sTipsCookie = Cookie(
|
||||
name = KEY_CONTENT_WARNING,
|
||||
value = CONTENT_WARNING_NOT_SHOW,
|
||||
)
|
||||
|
||||
fun copyNecessaryCookies() {
|
||||
val cookies = get(EhUrl.HOST_E.toHttpUrl())
|
||||
val cookies = load(Url(EhUrl.HOST_E))
|
||||
cookies.fastForEach {
|
||||
if (it.name == KEY_STAR || it.name == KEY_IPB_MEMBER_ID || it.name == KEY_IPB_PASS_HASH) {
|
||||
manager.setCookie(EhUrl.HOST_EX, it.toString())
|
||||
@@ -72,37 +41,41 @@ object EhCookieStore : CookieJar {
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCookie(url: HttpUrl, name: String) {
|
||||
manager.setCookie(url.toString(), "$name=;Max-Age=0")
|
||||
}
|
||||
|
||||
fun addCookie(cookie: Cookie) {
|
||||
manager.setCookie(if (EhUrl.DOMAIN_E == cookie.domain) EhUrl.HOST_E else EhUrl.HOST_EX, cookie.toString())
|
||||
fun addCookie(k: String, v: String, domain: String) {
|
||||
val cookie = Cookie(name = k, value = v, domain = domain, maxAge = Int.MAX_VALUE)
|
||||
val url = if (EhUrl.DOMAIN_E == cookie.domain) EhUrl.HOST_E else EhUrl.HOST_EX
|
||||
manager.setCookie(url, renderSetCookieHeader(cookie))
|
||||
}
|
||||
|
||||
fun flush() = manager.flush()
|
||||
|
||||
fun getCookieHeader(url: HttpUrl): String {
|
||||
return loadForRequest(url).fastJoinToString("; ") {
|
||||
fun getCookieHeader(url: String): String {
|
||||
return load(Url(url)).fastJoinToString("; ") {
|
||||
"${it.name}=${it.value}"
|
||||
}
|
||||
}
|
||||
|
||||
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = cookies.fastForEach {
|
||||
if (it.name != KEY_UTMP_NAME) {
|
||||
manager.setCookie(url.toString(), it.toString())
|
||||
override suspend fun addCookie(requestUrl: Url, cookie: Cookie) {
|
||||
if (cookie.name != KEY_UTMP_NAME) {
|
||||
manager.setCookie(requestUrl.toString(), renderSetCookieHeader(cookie))
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
override fun close() = Unit
|
||||
|
||||
fun load(url: Url): List<Cookie> {
|
||||
val cookies = manager.getCookie(url.toString()) ?: return emptyList()
|
||||
val checkTips = EhUrl.DOMAIN_E in url.host
|
||||
return get(url).run {
|
||||
if (checkTips) {
|
||||
fastFilter { it.name != KEY_CONTENT_WARNING }.toMutableList().apply { add(sTipsCookie) }
|
||||
} else {
|
||||
this
|
||||
return parseClientCookiesHeader(cookies)
|
||||
.map { Cookie(it.key, it.value, maxAge = Int.MAX_VALUE) }
|
||||
.filterTo(mutableListOf()) { it.name != KEY_UTMP_NAME }.apply {
|
||||
if (checkTips) {
|
||||
removeAll { it.name == KEY_CONTENT_WARNING }
|
||||
add(sTipsCookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun get(requestUrl: Url) = load(requestUrl)
|
||||
}
|
||||
@@ -15,7 +15,9 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import android.util.Log
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import arrow.fx.coroutines.parMap
|
||||
import arrow.fx.coroutines.parZip
|
||||
import com.hippo.ehviewer.R
|
||||
@@ -34,6 +36,7 @@ import com.hippo.ehviewer.client.parser.ForumsParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryApiParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryListParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryListResult
|
||||
import com.hippo.ehviewer.client.parser.GalleryNotAvailableParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryTokenApiParser
|
||||
@@ -45,48 +48,68 @@ import com.hippo.ehviewer.client.parser.TorrentParser
|
||||
import com.hippo.ehviewer.client.parser.TorrentResult
|
||||
import com.hippo.ehviewer.client.parser.VoteCommentResult
|
||||
import com.hippo.ehviewer.client.parser.VoteTagParser
|
||||
import com.hippo.ehviewer.cronet.awaitBodyFully
|
||||
import com.hippo.ehviewer.cronet.cronetRequest
|
||||
import com.hippo.ehviewer.cronet.execute
|
||||
import com.hippo.ehviewer.cronet.withRequestBody
|
||||
import com.hippo.ehviewer.dailycheck.showEventNotification
|
||||
import com.hippo.ehviewer.dailycheck.today
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import com.hippo.ehviewer.util.isCronetSupported
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.request.forms.append
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.client.statement.request
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.utils.io.core.writeFully
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import kotlin.math.ceil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonArray
|
||||
import kotlinx.serialization.json.put
|
||||
import moe.tarsin.coroutines.removeAllSuspend
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import org.jsoup.Jsoup
|
||||
import splitties.init.appCtx
|
||||
|
||||
private const val TAG = "EhEngine"
|
||||
private const val MAX_REQUEST_SIZE = 25
|
||||
private const val U_CONFIG_TEXT = "Selected Profile"
|
||||
private val MEDIA_TYPE_JPEG: MediaType = "image/jpeg".toMediaType()
|
||||
const val TAG = "EhEngine"
|
||||
|
||||
private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
|
||||
// https://ehwiki.org/wiki/API#Basics
|
||||
private const val MAX_REQUEST_SIZE = 25
|
||||
private const val MAX_SEQUENTIAL_REQUESTS = 5
|
||||
private const val REQUEST_INTERVAL = 5000L
|
||||
|
||||
private const val U_CONFIG_TEXT = "Selected Profile"
|
||||
|
||||
fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
|
||||
val dir = AppConfig.externalParseErrorDir ?: return
|
||||
val file = File(dir, ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".txt")
|
||||
RandomAccessFile(file, "rw").use { randomAccessFile ->
|
||||
with(randomAccessFile) {
|
||||
writeUTF(e.message + "\n")
|
||||
onLeft { writeUTF(it) }
|
||||
onRight { channel.write(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
|
||||
// Don't translate coroutine cancellation
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
// Check sad panda(without panda)
|
||||
if (body.isEmpty()) {
|
||||
val empty = body.fold(
|
||||
{ it.isEmpty() },
|
||||
{ !it.hasRemaining() },
|
||||
)
|
||||
if (empty) {
|
||||
if (EhUtils.isExHentai) {
|
||||
throw EhException("Sad Panda\n(without panda)")
|
||||
} else {
|
||||
@@ -95,10 +118,12 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
|
||||
}
|
||||
|
||||
// Check Gallery Not Available
|
||||
if (body.contains("Gallery Not Available - ")) {
|
||||
val error = GalleryNotAvailableParser.parse(body)
|
||||
if (!error.isNullOrBlank()) {
|
||||
throw EhException(error)
|
||||
body.onLeft {
|
||||
if ("Gallery Not Available - " in it) {
|
||||
val error = GalleryNotAvailableParser.parse(it)
|
||||
if (!error.isNullOrBlank()) {
|
||||
throw EhException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,12 +133,9 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
|
||||
}
|
||||
|
||||
if (e is ParseException || e is SerializationException) {
|
||||
if ("<" !in body) {
|
||||
throw EhException(body)
|
||||
} else {
|
||||
if (Settings.saveParseErrorBody) AppConfig.saveParseErrorBody(e, body)
|
||||
throw EhException(appCtx.getString(R.string.error_parse_error), e)
|
||||
}
|
||||
body.onLeft { if ("<" !in it) throw EhException(it) }
|
||||
if (Settings.saveParseErrorBody) body.saveParseError(e)
|
||||
throw EhException(appCtx.getString(R.string.error_parse_error), e)
|
||||
}
|
||||
|
||||
// We can't translate it, rethrow it anyway
|
||||
@@ -122,145 +144,96 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
|
||||
|
||||
val httpContentPool = DirectByteBufferPool(8, 0x80000)
|
||||
|
||||
suspend fun <T> fetchCompat(
|
||||
url: String,
|
||||
referer: String? = null,
|
||||
origin: String? = null,
|
||||
reqBody: RequestBody? = null,
|
||||
parser: suspend (ByteBuffer) -> T,
|
||||
): T {
|
||||
Log.d(TAG, url)
|
||||
fun commonChecks(body: ByteBuffer) {
|
||||
// Check sad panda(without panda)
|
||||
if (!body.hasRemaining()) {
|
||||
if (EhUtils.isExHentai) {
|
||||
throw EhException("Sad Panda\n(without panda)")
|
||||
} else {
|
||||
throw EhException("IP banned")
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (isCronetSupported) {
|
||||
cronetRequest(url, referer, origin) {
|
||||
reqBody?.let { withRequestBody(it) }
|
||||
}.execute {
|
||||
@Suppress("NewApi")
|
||||
val code = it.httpStatusCode
|
||||
if (code >= 400) {
|
||||
throw StatusCodeException(code)
|
||||
}
|
||||
httpContentPool.useInstance { buffer ->
|
||||
awaitBodyFully(buffer)
|
||||
buffer.flip()
|
||||
commonChecks(buffer)
|
||||
parser(buffer)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ehRequest(url, referer, origin) {
|
||||
reqBody?.let { post(it) }
|
||||
}.execute {
|
||||
httpContentPool.useInstance { buffer ->
|
||||
while (true) {
|
||||
if (body.source().read(buffer) == -1) break
|
||||
}
|
||||
buffer.flip()
|
||||
commonChecks(buffer)
|
||||
parser(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend String.() -> T) = execute { response ->
|
||||
logcat(tag = TAG) { response.request.url.toString() }
|
||||
val body = response.bodyAsText()
|
||||
runSuspendCatching {
|
||||
block(body)
|
||||
}.onFailure {
|
||||
rethrowExactly(response.status.value, body.left(), it)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE")
|
||||
private suspend inline fun <T> Request.executeAndParsingWith(block: suspend String.() -> T): T {
|
||||
Log.d(TAG, url.toString())
|
||||
return execute {
|
||||
val body = body.string()
|
||||
try {
|
||||
block(body)
|
||||
} catch (e: Exception) {
|
||||
rethrowExactly(code, body, e)
|
||||
}
|
||||
suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = execute { response ->
|
||||
logcat(tag = TAG) { response.request.url.toString() }
|
||||
httpContentPool.useInstance { buffer ->
|
||||
with(response.bodyAsChannel()) { while (!isClosedForRead) readAvailable(buffer) }
|
||||
buffer.flip()
|
||||
runSuspendCatching {
|
||||
block(buffer)
|
||||
}.onFailure {
|
||||
rethrowExactly(response.status.value, buffer.right(), it)
|
||||
}.getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
object EhEngine {
|
||||
suspend fun getOriginalImageUrl(url: String, referer: String?): String {
|
||||
Log.d(TAG, url)
|
||||
return ehRequest(url, referer).executeNoRedirect {
|
||||
header("Location")?.apply {
|
||||
if (contains("bounce_login")) {
|
||||
throw NotLoggedInException()
|
||||
}
|
||||
} ?: throw InsufficientFundsException()
|
||||
}
|
||||
suspend fun getOriginalImageUrl(url: String, referer: String?) = noRedirectEhRequest(url, referer).execute { response ->
|
||||
val location = response.headers["Location"] ?: throw InsufficientFundsException()
|
||||
location.takeIf { "bounce_login" !in it } ?: throw NotLoggedInException()
|
||||
}
|
||||
|
||||
suspend fun getTorrentList(url: String, gid: Long, token: String?): TorrentResult {
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
return fetchCompat(url, referer, parser = TorrentParser::parse)
|
||||
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getArchiveList(url: String, gid: Long, token: String?) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
|
||||
.executeAndParsingWith(ArchiveParser::parse)
|
||||
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) }
|
||||
.fetchUsingAsText(ArchiveParser::parse)
|
||||
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) }
|
||||
|
||||
suspend fun getImageLimits() = parZip(
|
||||
{ fetchCompat(EhUrl.URL_HOME, parser = HomeParser::parse) },
|
||||
{ ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) },
|
||||
{ ehRequest(EhUrl.URL_HOME).fetchUsingAsByteBuffer(HomeParser::parse) },
|
||||
{ ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) },
|
||||
{ limits, funds -> HomeParser.Result(limits, funds) },
|
||||
)
|
||||
|
||||
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
|
||||
.executeAndParsingWith { if (parse) EventPaneParser.parse(this) else null }
|
||||
.fetchUsingAsText { if (parse) EventPaneParser.parse(this) else null }
|
||||
|
||||
suspend fun getProfile(): ProfileParser.Result {
|
||||
val url = ehRequest(EhUrl.URL_FORUMS).executeAndParsingWith(ForumsParser::parse)
|
||||
return ehRequest(url, EhUrl.URL_FORUMS).executeAndParsingWith(ProfileParser::parse)
|
||||
val url = ehRequest(EhUrl.URL_FORUMS).fetchUsingAsText(ForumsParser::parse)
|
||||
return ehRequest(url, EhUrl.URL_FORUMS).fetchUsingAsText(ProfileParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
|
||||
runSuspendCatching {
|
||||
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
|
||||
}.onFailure {
|
||||
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
|
||||
}.onFailure { throwable ->
|
||||
// It may get redirected when accessing ex for the first time
|
||||
if (url == EhUrl.URL_UCONFIG_EX) {
|
||||
it.printStackTrace()
|
||||
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
|
||||
throwable.printStackTrace()
|
||||
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
|
||||
} else {
|
||||
throw it
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getGalleryPage(
|
||||
url: String,
|
||||
gid: Long,
|
||||
token: String?,
|
||||
): GalleryPageParser.Result {
|
||||
suspend fun getGalleryPage(url: String, gid: Long, token: String?): GalleryPageParser.Result {
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
return ehRequest(url, referer).executeAndParsingWith(GalleryPageParser::parse)
|
||||
return ehRequest(url, referer).fetchUsingAsText(GalleryPageParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getGalleryList(url: String) = fetchCompat(url, EhUrl.referer, parser = GalleryListParser::parse)
|
||||
.apply { fillGalleryList(galleryInfoList, url, true) }
|
||||
suspend fun getGalleryList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(GalleryListParser::parse)
|
||||
.apply { galleryInfoList.fillInfo(url, true) }
|
||||
.takeUnless { it.galleryInfoList.isEmpty() } ?: GalleryListParser.emptyResult
|
||||
|
||||
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
|
||||
EventPaneParser.parse(this)?.let {
|
||||
Settings.lastDawnDay = today
|
||||
showEventNotification(it)
|
||||
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
|
||||
val eventPane = EventPaneParser.parse(this)
|
||||
if (eventPane != null) {
|
||||
Settings.lastDawnTime = Clock.System.now().epochSeconds
|
||||
showEventNotification(eventPane)
|
||||
}
|
||||
GalleryDetailParser.parse(this)
|
||||
}
|
||||
|
||||
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
|
||||
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
|
||||
GalleryDetailParser.parsePreviewList(this) to GalleryDetailParser.parsePreviewPages(this)
|
||||
}
|
||||
|
||||
suspend fun getFavorites(url: String) = fetchCompat(url, EhUrl.referer, parser = FavoritesParser::parse)
|
||||
.apply { fillGalleryList(galleryInfoList, url) }
|
||||
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(FavoritesParser::parse)
|
||||
.apply { galleryInfoList.fillInfo(url) }
|
||||
|
||||
suspend fun signIn(username: String, password: String): String {
|
||||
val referer = "https://forums.e-hentai.org/index.php?act=Login&CODE=00"
|
||||
@@ -268,26 +241,26 @@ object EhEngine {
|
||||
val origin = "https://forums.e-hentai.org"
|
||||
return ehRequest(url, referer, origin) {
|
||||
formBody {
|
||||
add("referer", referer)
|
||||
add("b", "")
|
||||
add("bt", "")
|
||||
add("UserName", username)
|
||||
add("PassWord", password)
|
||||
add("CookieDate", "1")
|
||||
append("referer", referer)
|
||||
append("b", "")
|
||||
append("bt", "")
|
||||
append("UserName", username)
|
||||
append("PassWord", password)
|
||||
append("CookieDate", "1")
|
||||
}
|
||||
}.executeAndParsingWith(SignInParser::parse)
|
||||
}.fetchUsingAsText(SignInParser::parse)
|
||||
}
|
||||
|
||||
suspend fun commentGallery(url: String, comment: String, id: Long = -1) = ehRequest(url, url, EhUrl.origin) {
|
||||
formBody {
|
||||
if (id == -1L) {
|
||||
add("commenttext_new", comment)
|
||||
append("commenttext_new", comment)
|
||||
} else {
|
||||
add("commenttext_edit", comment)
|
||||
add("edit_comment", id.toString())
|
||||
append("commenttext_edit", comment)
|
||||
append("edit_comment", id.toString())
|
||||
}
|
||||
}
|
||||
}.executeAndParsingWith {
|
||||
}.fetchUsingAsText {
|
||||
val document = Jsoup.parse(this)
|
||||
val elements = document.select("#chd + p")
|
||||
if (elements.size > 0) {
|
||||
@@ -303,54 +276,43 @@ object EhEngine {
|
||||
else -> throw EhException("Invalid dstCat: $dstCat")
|
||||
}
|
||||
val url = EhUrl.getAddFavorites(gid, token)
|
||||
val body = formBody {
|
||||
add("favcat", catStr)
|
||||
add("favnote", note)
|
||||
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
|
||||
add("apply", "Apply Changes")
|
||||
add("update", "1")
|
||||
}
|
||||
fetchCompat(url, url, EhUrl.origin, body) { }
|
||||
ehRequest(url, url, EhUrl.origin) {
|
||||
formBody {
|
||||
append("favcat", catStr)
|
||||
append("favnote", note)
|
||||
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
|
||||
append("apply", "Apply Changes")
|
||||
append("update", "1")
|
||||
}
|
||||
}.execute { }
|
||||
}
|
||||
|
||||
suspend fun downloadArchive(
|
||||
gid: Long,
|
||||
token: String?,
|
||||
or: String?,
|
||||
res: String?,
|
||||
isHAtH: Boolean,
|
||||
): String? {
|
||||
if (or.isNullOrEmpty()) {
|
||||
throw EhException("Invalid form param or: $or")
|
||||
}
|
||||
if (res.isNullOrEmpty()) {
|
||||
throw EhException("Invalid res: $res")
|
||||
}
|
||||
suspend fun downloadArchive(gid: Long, token: String?, or: String?, res: String?, isHAtH: Boolean): String? {
|
||||
if (or.isNullOrEmpty()) throw EhException("Invalid form param or: $or")
|
||||
if (res.isNullOrEmpty()) throw EhException("Invalid res: $res")
|
||||
val url = EhUrl.getDownloadArchive(gid, token, or)
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
val request = ehRequest(url, referer, EhUrl.origin) {
|
||||
formBody {
|
||||
if (isHAtH) {
|
||||
add("hathdl_xres", res)
|
||||
append("hathdl_xres", res)
|
||||
} else {
|
||||
add("dltype", res)
|
||||
append("dltype", res)
|
||||
if (res == "org") {
|
||||
add("dlcheck", "Download Original Archive")
|
||||
append("dlcheck", "Download Original Archive")
|
||||
} else {
|
||||
add("dlcheck", "Download Resample Archive")
|
||||
append("dlcheck", "Download Resample Archive")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
|
||||
var result = request.fetchUsingAsText(ArchiveParser::parseArchiveUrl)
|
||||
if (!isHAtH) {
|
||||
if (result == null) {
|
||||
// Wait for the server to prepare archives
|
||||
delay(1000)
|
||||
result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
|
||||
if (result == null) {
|
||||
throw EhException("Archive unavailable")
|
||||
}
|
||||
result = request.fetchUsingAsText(ArchiveParser::parseArchiveUrl)
|
||||
if (result == null) throw EhException("Archive unavailable")
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -359,37 +321,28 @@ object EhEngine {
|
||||
|
||||
suspend fun resetImageLimits() = ehRequest(EhUrl.URL_HOME) {
|
||||
formBody {
|
||||
add("reset_imagelimit", "Reset Limit")
|
||||
append("reset_imagelimit", "Reset Limit")
|
||||
}
|
||||
}.executeAndParsingWith(HomeParser::parseResetLimits)
|
||||
}.fetchUsingAsText(HomeParser::parseResetLimits)
|
||||
|
||||
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavoritesParser.Result {
|
||||
val url = ehUrl {
|
||||
addPathSegments(EhUrl.FAV_PATH)
|
||||
if (FavListUrlBuilder.isValidFavCat(srcCat)) {
|
||||
addQueryParameter("favcat", srcCat.toString())
|
||||
}
|
||||
}.toString()
|
||||
val url = ehUrl(EhUrl.FAV_PATH) {
|
||||
if (FavListUrlBuilder.isValidFavCat(srcCat)) addQueryParameter("favcat", srcCat.toString())
|
||||
}.buildString()
|
||||
val catStr: String = when (dstCat) {
|
||||
-1 -> "delete"
|
||||
in 0..9 -> "fav$dstCat"
|
||||
else -> throw EhException("Invalid dstCat: $dstCat")
|
||||
}
|
||||
val body = formBody {
|
||||
add("ddact", catStr)
|
||||
gidArray.forEach { add("modifygids[]", it.toString()) }
|
||||
}
|
||||
return fetchCompat(url, url, EhUrl.origin, body, FavoritesParser::parse)
|
||||
.apply { fillGalleryList(galleryInfoList, url) }
|
||||
return ehRequest(url, url, EhUrl.origin) {
|
||||
formBody {
|
||||
append("ddact", catStr)
|
||||
gidArray.forEach { append("modifygids[]", it.toString()) }
|
||||
}
|
||||
}.fetchUsingAsByteBuffer(FavoritesParser::parse).apply { galleryInfoList.fillInfo(url) }
|
||||
}
|
||||
|
||||
suspend fun getGalleryPageApi(
|
||||
gid: Long,
|
||||
index: Int,
|
||||
pToken: String,
|
||||
showKey: String?,
|
||||
previousPToken: String?,
|
||||
): GalleryPageParser.Result {
|
||||
suspend fun getGalleryPageApi(gid: Long, index: Int, pToken: String, showKey: String?, previousPToken: String?): GalleryPageParser.Result {
|
||||
val referer = if (index > 0 && previousPToken != null) EhUrl.getPageUrl(gid, index - 1, previousPToken) else null
|
||||
return ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
@@ -399,16 +352,10 @@ object EhEngine {
|
||||
put("imgkey", pToken)
|
||||
put("showkey", showKey)
|
||||
}
|
||||
}.executeAndParsingWith { GalleryPageParser.parse(filterNot { it == '\\' }) }
|
||||
}.fetchUsingAsText { GalleryPageParser.parse(filterNot { it == '\\' }) }
|
||||
}
|
||||
|
||||
suspend fun rateGallery(
|
||||
apiUid: Long,
|
||||
apiKey: String?,
|
||||
gid: Long,
|
||||
token: String?,
|
||||
rating: Float,
|
||||
): RateGalleryResult = ehRequest(EhUrl.apiUrl, EhUrl.getGalleryDetailUrl(gid, token), EhUrl.origin) {
|
||||
suspend fun rateGallery(apiUid: Long, apiKey: String?, gid: Long, token: String?, rating: Float): RateGalleryResult = ehRequest(EhUrl.apiUrl, EhUrl.getGalleryDetailUrl(gid, token), EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "rategallery")
|
||||
put("apiuid", apiUid)
|
||||
@@ -417,63 +364,56 @@ object EhEngine {
|
||||
put("token", token)
|
||||
put("rating", ceil((rating * 2).toDouble()).toInt())
|
||||
}
|
||||
}.executeAndParsingWith(String::parseAs)
|
||||
}.fetchUsingAsText(String::parseAs)
|
||||
|
||||
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String) =
|
||||
galleryInfoList.chunked(MAX_REQUEST_SIZE).parMap(concurrency = Settings.multiThreadDownload) {
|
||||
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "gdata")
|
||||
array("gidlist") {
|
||||
it.forEach {
|
||||
addJsonArray {
|
||||
add(it.gid)
|
||||
add(it.token)
|
||||
galleryInfoList.chunked(MAX_REQUEST_SIZE).chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
|
||||
if (index != 0) {
|
||||
delay(REQUEST_INTERVAL)
|
||||
}
|
||||
chunk.parMap {
|
||||
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "gdata")
|
||||
array("gidlist") {
|
||||
it.forEach {
|
||||
addJsonArray {
|
||||
add(it.gid)
|
||||
add(it.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
put("namespace", 1)
|
||||
}
|
||||
put("namespace", 1)
|
||||
}
|
||||
}.executeAndParsingWith { GalleryApiParser.parse(this, it) }
|
||||
}.fetchUsingAsText { GalleryApiParser.parse(this, it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun voteComment(
|
||||
apiUid: Long,
|
||||
apiKey: String?,
|
||||
gid: Long,
|
||||
token: String?,
|
||||
commentId: Long,
|
||||
commentVote: Int,
|
||||
): VoteCommentResult = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "votecomment")
|
||||
put("apiuid", apiUid)
|
||||
put("apikey", apiKey)
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("comment_id", commentId)
|
||||
put("comment_vote", commentVote)
|
||||
}
|
||||
}.executeAndParsingWith(String::parseAs)
|
||||
suspend fun voteComment(apiUid: Long, apiKey: String?, gid: Long, token: String?, commentId: Long, commentVote: Int): VoteCommentResult =
|
||||
ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "votecomment")
|
||||
put("apiuid", apiUid)
|
||||
put("apikey", apiKey)
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("comment_id", commentId)
|
||||
put("comment_vote", commentVote)
|
||||
}
|
||||
}.fetchUsingAsText(String::parseAs)
|
||||
|
||||
suspend fun voteTag(
|
||||
apiUid: Long,
|
||||
apiKey: String?,
|
||||
gid: Long,
|
||||
token: String?,
|
||||
tags: String?,
|
||||
vote: Int,
|
||||
) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "taggallery")
|
||||
put("apiuid", apiUid)
|
||||
put("apikey", apiKey)
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("tags", tags)
|
||||
put("vote", vote)
|
||||
}
|
||||
}.executeAndParsingWith(VoteTagParser::parse)
|
||||
suspend fun voteTag(apiUid: Long, apiKey: String?, gid: Long, token: String?, tags: String?, vote: Int) =
|
||||
ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "taggallery")
|
||||
put("apiuid", apiUid)
|
||||
put("apikey", apiKey)
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("tags", tags)
|
||||
put("vote", vote)
|
||||
}
|
||||
}.fetchUsingAsText(VoteTagParser::parse)
|
||||
|
||||
suspend fun getGalleryToken(gid: Long, gtoken: String?, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
@@ -486,51 +426,44 @@ object EhEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
}.executeAndParsingWith(GalleryTokenApiParser::parse)
|
||||
}.fetchUsingAsText(GalleryTokenApiParser::parse)
|
||||
|
||||
/**
|
||||
* @param image Must be jpeg
|
||||
*/
|
||||
suspend fun imageSearch(image: File, uss: Boolean, osc: Boolean) = fetchCompat(
|
||||
EhUrl.imageSearchUrl,
|
||||
EhUrl.referer,
|
||||
EhUrl.origin,
|
||||
multipartBody {
|
||||
setType(MultipartBody.FORM)
|
||||
addFormDataPart("sfile", "a.jpg", image.asRequestBody(MEDIA_TYPE_JPEG))
|
||||
if (uss) addFormDataPart("fs_similar", "on")
|
||||
if (osc) addFormDataPart("fs_covers", "on")
|
||||
addFormDataPart("f_sfile", "File Search")
|
||||
},
|
||||
parser = GalleryListParser::parse,
|
||||
).apply { fillGalleryList(galleryInfoList, EhUrl.imageSearchUrl) }
|
||||
|
||||
private suspend fun fillGalleryList(list: MutableList<BaseGalleryInfo>, url: String, filter: Boolean = false) {
|
||||
// Filter title and uploader
|
||||
if (filter) list.removeAllSuspend { EhFilter.filterTitle(it) || EhFilter.filterUploader(it) }
|
||||
|
||||
var hasTags = false
|
||||
var hasPages = false
|
||||
var hasRated = false
|
||||
for (gi in list) {
|
||||
if (!gi.simpleTags.isNullOrEmpty()) {
|
||||
hasTags = true
|
||||
}
|
||||
if (gi.pages != 0) {
|
||||
hasPages = true
|
||||
}
|
||||
if (gi.rated) {
|
||||
hasRated = true
|
||||
suspend fun imageSearch(jpeg: File, uss: Boolean, osc: Boolean): GalleryListResult {
|
||||
val location = noRedirectEhRequest(EhUrl.imageSearchUrl, EhUrl.referer, EhUrl.origin) {
|
||||
multipartBody {
|
||||
append("sfile", "a.jpg", ContentType.Image.JPEG, jpeg.length()) {
|
||||
RandomAccessFile(jpeg, "r").use {
|
||||
writeFully(it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.length()))
|
||||
}
|
||||
}
|
||||
if (uss) append("fs_similar", "on")
|
||||
if (osc) append("fs_covers", "on")
|
||||
append("f_sfile", "File Search")
|
||||
}
|
||||
}.execute { it.headers["Location"] ?: error("Failed to search image!!!") }
|
||||
return ehRequest(location).fetchUsingAsByteBuffer(GalleryListParser::parse).apply {
|
||||
galleryInfoList.fillInfo(EhUrl.imageSearchUrl)
|
||||
}
|
||||
val needApi = filter && EhFilter.needTags() && !hasTags || Settings.showGalleryPages && !hasPages || hasRated
|
||||
if (needApi) fillGalleryListByApi(list, url)
|
||||
}
|
||||
|
||||
// Filter tag, thumbnail mode need filter uploader again
|
||||
if (filter) list.removeAllSuspend { EhFilter.filterUploader(it) || EhFilter.filterTag(it) || EhFilter.filterTagNamespace(it) }
|
||||
private suspend fun MutableList<BaseGalleryInfo>.fillInfo(url: String, filter: Boolean = false) = with(EhFilter) {
|
||||
if (filter) removeAllSuspend { filterTitle(it) || filterUploader(it) }
|
||||
val hasTags = any { !it.simpleTags.isNullOrEmpty() }
|
||||
val hasPages = any { it.pages != 0 }
|
||||
val hasRated = any { it.rated }
|
||||
val needApi = filter && needTags() && !hasTags || Settings.showGalleryPages && !hasPages || hasRated
|
||||
if (needApi) fillGalleryListByApi(this@fillInfo, url)
|
||||
if (filter) removeAllSuspend { filterUploader(it) || filterTag(it) || filterTagNamespace(it) }
|
||||
}
|
||||
|
||||
suspend fun addFavorites(galleryList: List<Pair<Long, String>>, dstCat: Int) {
|
||||
galleryList.parMap(concurrency = Settings.multiThreadDownload) { (gid, token) -> modifyFavorites(gid, token, dstCat) }
|
||||
galleryList.chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
|
||||
if (index != 0) {
|
||||
delay(REQUEST_INTERVAL)
|
||||
}
|
||||
chunk.parMap { (gid, token) ->
|
||||
modifyFavorites(gid, token, dstCat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,111 +15,36 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import android.util.Log
|
||||
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.noRedirectOkHttpClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.okHttpClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
|
||||
import com.hippo.ehviewer.Settings
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.forms.FormBuilder
|
||||
import io.ktor.client.request.forms.FormDataContent
|
||||
import io.ktor.client.request.forms.MultiPartFormDataContent
|
||||
import io.ktor.client.request.forms.formData
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.prepareRequest
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.ParametersBuilder
|
||||
import io.ktor.http.content.TextContent
|
||||
import io.ktor.http.userAgent
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArrayBuilder
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||
import okhttp3.Call
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.executeAsync
|
||||
import okio.BufferedSource
|
||||
|
||||
inline fun ehRequest(url: String, referer: String? = null, origin: String? = null, builder: Request.Builder.() -> Unit = {}) = Request.Builder().url(url).apply {
|
||||
addHeader("User-Agent", Settings.userAgent)
|
||||
addHeader("Accept", CHROME_ACCEPT)
|
||||
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
|
||||
referer?.let { addHeader("Referer", it) }
|
||||
origin?.let { addHeader("Origin", it) }
|
||||
}.apply(builder).build()
|
||||
|
||||
inline fun formBody(builder: FormBody.Builder.() -> Unit) = FormBody.Builder().apply(builder).build()
|
||||
inline fun Request.Builder.formBody(builder: FormBody.Builder.() -> Unit) = post(FormBody.Builder().apply(builder).build())
|
||||
|
||||
inline fun multipartBody(builder: MultipartBody.Builder.() -> Unit) = MultipartBody.Builder().apply(builder).build()
|
||||
|
||||
val MEDIA_TYPE_JSON: MediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
|
||||
|
||||
inline fun Request.Builder.jsonBody(builder: JsonObjectBuilder.() -> Unit) = post(buildJsonObject(builder).toString().toRequestBody(MEDIA_TYPE_JSON))
|
||||
|
||||
const val TAG_CALL = "CancellableCallReadingScope"
|
||||
|
||||
// This scoped response reading scope suspend function aimed at cancelling the call immediately once coroutine is cancelled.
|
||||
// i.e., bind a call with a suspended coroutine, and launch another coroutine to execute reading
|
||||
// Once origin coroutine is cancelled, then call cancelled, socket is closed, the reading coroutine will encounter [IOException]
|
||||
// Since Okio is not suspendable/cancellable, also okhttp, [executeAsync] only make [Request -> Response] cancellable, reading a Response is still not cancellable
|
||||
// Considering interrupting is not safe and performant, See https://github.com/Kotlin/kotlinx.coroutines/issues/3551#issuecomment-1353245978, we have such [usingCancellable]
|
||||
// TODO: Remove logging after this function stable
|
||||
suspend inline fun <R> Call.usingCancellable(crossinline block: suspend Response.() -> R): R = executeAsync().use {
|
||||
val call = this
|
||||
Log.d(TAG_CALL, "Got response of call$call")
|
||||
coroutineScope {
|
||||
suspendCancellableCoroutine<R> { cont ->
|
||||
launch {
|
||||
val r = runCatching {
|
||||
Log.d(TAG_CALL, "Reading response of call$call")
|
||||
block(it)
|
||||
}
|
||||
if (!isCanceled() && cont.isActive) {
|
||||
r.exceptionOrNull()?.let {
|
||||
Log.e(TAG_CALL, "Reading response of call$call failed!")
|
||||
cont.resumeWithException(it)
|
||||
}
|
||||
r.getOrNull()?.let {
|
||||
Log.d(TAG_CALL, "Reading response of call$call succeed!")
|
||||
cont.resume(it)
|
||||
}
|
||||
} else if (isCanceled() && cont.isActive) {
|
||||
Log.e(TAG_CALL, "call$call cancelled but coroutine is Active!")
|
||||
} else if (!isCanceled() && !cont.isActive) {
|
||||
Log.e(TAG_CALL, "call$call not cancelled but coroutine is Dead!")
|
||||
} else {
|
||||
Log.d(TAG_CALL, "Reading response of call$call cancelled")
|
||||
}
|
||||
}
|
||||
cont.invokeOnCancellation {
|
||||
Log.d(TAG_CALL, "Cancelling call$call")
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
Log.d(TAG_CALL, "Reading response of call$call finished!")
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <R> Request.execute(block: Response.() -> R): R {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
return okHttpClient.newCall(this).executeAsync().use(block)
|
||||
}
|
||||
suspend inline fun <R> Request.executeNonCache(crossinline block: suspend Response.() -> R) = baseOkHttpClient.newCall(this).usingCancellable(block)
|
||||
suspend inline fun <R> Request.executeNoRedirect(block: Response.() -> R) = noRedirectOkHttpClient.newCall(this).executeAsync().use(block)
|
||||
|
||||
suspend inline fun <reified T> Request.executeAndParseAs() = execute { parseAs<T>() }
|
||||
|
||||
inline fun <reified T> Response.parseAs(): T = body.source().parseAs()
|
||||
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = execute { it.bodyAsText().parseAs<T>() }
|
||||
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
|
||||
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
|
||||
|
||||
@@ -128,3 +53,49 @@ val json = Json { ignoreUnknownKeys = true }
|
||||
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36"
|
||||
const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
|
||||
const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
|
||||
|
||||
fun HttpRequestBuilder.applyEhConfig(referer: String?, origin: String?) {
|
||||
method = HttpMethod.Get
|
||||
header("Referer", referer)
|
||||
header("Origin", origin)
|
||||
userAgent(Settings.userAgent)
|
||||
header(HttpHeaders.Accept, CHROME_ACCEPT)
|
||||
header(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
|
||||
}
|
||||
|
||||
suspend inline fun ehRequest(
|
||||
url: String,
|
||||
referer: String? = null,
|
||||
origin: String? = null,
|
||||
builder: HttpRequestBuilder.() -> Unit = {},
|
||||
) = ktorClient.prepareRequest(url) {
|
||||
applyEhConfig(referer, origin)
|
||||
apply(builder)
|
||||
}
|
||||
|
||||
suspend inline fun noRedirectEhRequest(
|
||||
url: String,
|
||||
referer: String? = null,
|
||||
origin: String? = null,
|
||||
builder: HttpRequestBuilder.() -> Unit = {},
|
||||
) = noRedirectKtorClient.prepareRequest(url) {
|
||||
applyEhConfig(referer, origin)
|
||||
apply(builder)
|
||||
}
|
||||
|
||||
inline fun HttpRequestBuilder.formBody(builder: ParametersBuilder.() -> Unit) {
|
||||
method = HttpMethod.Post
|
||||
val parameters = ParametersBuilder().apply(builder).build()
|
||||
setBody(FormDataContent(parameters))
|
||||
}
|
||||
|
||||
inline fun HttpRequestBuilder.jsonBody(builder: JsonObjectBuilder.() -> Unit) {
|
||||
method = HttpMethod.Post
|
||||
val json = buildJsonObject(builder).toString()
|
||||
setBody(TextContent(text = json, contentType = ContentType.Application.Json))
|
||||
}
|
||||
|
||||
fun HttpRequestBuilder.multipartBody(builder: FormBuilder.() -> Unit) {
|
||||
method = HttpMethod.Post
|
||||
setBody(MultiPartFormDataContent(formData(builder)))
|
||||
}
|
||||
@@ -19,11 +19,16 @@ import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.copyToFile
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import com.hippo.ehviewer.util.copyTo
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.isSuccess
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -35,9 +40,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.executeAsync
|
||||
import okio.BufferedSource
|
||||
import okio.HashingSink.Companion.sha1
|
||||
import okio.blackholeSink
|
||||
@@ -175,24 +177,18 @@ object EhTagDatabase : CoroutineScope {
|
||||
return sha1 != null && sha1 == getFileSha1(data)
|
||||
}
|
||||
|
||||
private suspend fun save(client: OkHttpClient, url: String, file: File): Boolean {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
val call = client.newCall(request)
|
||||
private suspend fun save(client: HttpClient, url: String, file: File) {
|
||||
runCatching {
|
||||
call.executeAsync().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return false
|
||||
client.prepareGet(url).execute {
|
||||
if (it.status.isSuccess()) {
|
||||
it.bodyAsChannel().copyTo(file)
|
||||
} else {
|
||||
throw StatusCodeException(it.status.value)
|
||||
}
|
||||
response.body.use {
|
||||
it.copyToFile(file)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}.onFailure {
|
||||
file.delete()
|
||||
it.printStackTrace()
|
||||
}
|
||||
return false
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun update() {
|
||||
@@ -246,7 +242,7 @@ object EhTagDatabase : CoroutineScope {
|
||||
|
||||
// Save new sha1
|
||||
val tempSha1File = File(dir, "$sha1Name.tmp")
|
||||
check(save(baseOkHttpClient, sha1Url, tempSha1File))
|
||||
save(ktorClient, sha1Url, tempSha1File)
|
||||
val tempSha1 = getFileContent(tempSha1File)
|
||||
|
||||
// Check new sha1 and current sha1
|
||||
@@ -258,7 +254,7 @@ object EhTagDatabase : CoroutineScope {
|
||||
|
||||
// Save new data
|
||||
val tempDataFile = File(dir, "$dataName.tmp")
|
||||
check(save(baseOkHttpClient, dataUrl, tempDataFile))
|
||||
save(ktorClient, dataUrl, tempDataFile)
|
||||
|
||||
// Check new sha1 and new data
|
||||
if (!checkData(tempSha1, tempDataFile)) {
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import okhttp3.HttpUrl
|
||||
import io.ktor.http.Parameters
|
||||
import io.ktor.http.ParametersBuilder
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.URLProtocol
|
||||
|
||||
object EhUrl {
|
||||
const val SITE_E = 0
|
||||
@@ -120,35 +123,41 @@ object EhUrl {
|
||||
return getGalleryDetailUrl(gid, token, 0, false)
|
||||
}
|
||||
|
||||
fun getGalleryDetailUrl(gid: Long, token: String?, index: Int, allComment: Boolean) = ehUrl {
|
||||
addPathSegments("g/$gid/$token/")
|
||||
fun getGalleryDetailUrl(gid: Long, token: String?, index: Int, allComment: Boolean) = ehUrl(listOf("g", gid.toString(), token.orEmpty())) {
|
||||
if (index != 0) addQueryParameter("p", "$index")
|
||||
if (allComment) addQueryParameter("hc", "1")
|
||||
}.toString()
|
||||
}.buildString()
|
||||
|
||||
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl {
|
||||
addPathSegments("mpv/$gid/$token/")
|
||||
}.toString()
|
||||
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl(listOf("mpv", gid.toString(), token)).buildString()
|
||||
|
||||
fun getPageUrl(gid: Long, index: Int, pToken: String): String {
|
||||
return host + "s/" + pToken + '/' + gid + '-' + (index + 1)
|
||||
}
|
||||
fun getPageUrl(gid: Long, index: Int, pToken: String) = host + "s/" + pToken + '/' + gid + '-' + (index + 1)
|
||||
|
||||
fun getAddFavorites(gid: Long, token: String?): String {
|
||||
return host + "gallerypopups.php?gid=" + gid + "&t=" + token + "&act=addfav"
|
||||
}
|
||||
fun getAddFavorites(gid: Long, token: String?) = host + "gallerypopups.php?gid=" + gid + "&t=" + token + "&act=addfav"
|
||||
|
||||
fun getDownloadArchive(gid: Long, token: String?, or: String): String {
|
||||
return host + "archiver.php?gid=" + gid + "&token=" + token + "&or=" + or
|
||||
}
|
||||
fun getDownloadArchive(gid: Long, token: String?, or: String) = host + "archiver.php?gid=" + gid + "&token=" + token + "&or=" + or
|
||||
|
||||
fun getTagDefinitionUrl(tag: String): String {
|
||||
return "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
|
||||
}
|
||||
fun getTagDefinitionUrl(tag: String) = "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
|
||||
}
|
||||
|
||||
inline fun httpsUrl(builder: HttpUrl.Builder.() -> Unit) = HttpUrl.Builder().apply(builder).scheme("https").build()
|
||||
inline fun ehUrl(builder: HttpUrl.Builder.() -> Unit) = httpsUrl { apply(builder).host(EhUrl.domain) }
|
||||
fun HttpUrl.Builder.addQueryParameterIfNotBlank(name: String, value: String?) = apply {
|
||||
if (!value.isNullOrBlank()) addQueryParameter(name, value)
|
||||
inline fun ehUrl(path: String?, builder: ParametersBuilder.() -> Unit) = ehUrl(
|
||||
path = if (path.isNullOrBlank()) emptyList() else listOf(path),
|
||||
parameters = Parameters.build(builder),
|
||||
)
|
||||
|
||||
inline fun ehUrl(path: List<String>, builder: ParametersBuilder.() -> Unit) = ehUrl(
|
||||
path = path,
|
||||
parameters = Parameters.build(builder),
|
||||
)
|
||||
|
||||
fun ehUrl(path: List<String>, parameters: Parameters = Parameters.Empty) = URLBuilder(
|
||||
protocol = URLProtocol.HTTPS,
|
||||
host = EhUrl.domain,
|
||||
pathSegments = path,
|
||||
parameters = parameters,
|
||||
)
|
||||
|
||||
fun ParametersBuilder.addQueryParameterIfNotBlank(name: String, value: String?) = apply {
|
||||
if (!value.isNullOrBlank()) append(name, value)
|
||||
}
|
||||
|
||||
fun ParametersBuilder.addQueryParameter(name: String, value: String) = append(name, value)
|
||||
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.addQueryParameter
|
||||
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
|
||||
import com.hippo.ehviewer.client.ehUrl
|
||||
import kotlinx.parcelize.Parcelize
|
||||
@@ -34,24 +35,20 @@ data class FavListUrlBuilder(
|
||||
prev = index.takeUnless { isNext }
|
||||
}
|
||||
|
||||
fun build() = ehUrl {
|
||||
addPathSegment(EhUrl.FAV_PATH)
|
||||
if (isValidFavCat(favCat)) {
|
||||
addQueryParameter("favcat", "$favCat")
|
||||
} else if (favCat == FAV_CAT_ALL) {
|
||||
addQueryParameter("favcat", "all")
|
||||
fun build() = ehUrl(EhUrl.FAV_PATH) {
|
||||
when {
|
||||
isValidFavCat(favCat) -> addQueryParameter("favcat", "$favCat")
|
||||
favCat == FAV_CAT_ALL -> addQueryParameter("favcat", "all")
|
||||
}
|
||||
addQueryParameterIfNotBlank("f_search", keyword)
|
||||
addQueryParameterIfNotBlank("prev", prev)
|
||||
addQueryParameterIfNotBlank("next", next)
|
||||
addQueryParameterIfNotBlank("seek", jumpTo)
|
||||
}.toString()
|
||||
}.buildString()
|
||||
|
||||
companion object {
|
||||
const val FAV_CAT_ALL = -1
|
||||
const val FAV_CAT_LOCAL = -2
|
||||
fun isValidFavCat(favCat: Int): Boolean {
|
||||
return favCat in 0..9
|
||||
}
|
||||
fun isValidFavCat(favCat: Int) = favCat in 0..9
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ interface GalleryInfo {
|
||||
Pattern.compile("[(\\[]hun(?:garian)?[)\\]]|ハンガリー翻訳", Pattern.CASE_INSENSITIVE),
|
||||
Pattern.compile("[(\\[]dutch[)\\]]|オランダ翻訳", Pattern.CASE_INSENSITIVE),
|
||||
)
|
||||
private val S_LANG_TAGS = arrayOf(
|
||||
val S_LANG_TAGS = arrayOf(
|
||||
"language:english",
|
||||
"language:chinese",
|
||||
"language:spanish",
|
||||
|
||||
@@ -17,8 +17,10 @@ package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.IntDef
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.addQueryParameter
|
||||
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
|
||||
import com.hippo.ehviewer.client.ehUrl
|
||||
import com.hippo.ehviewer.dao.QuickSearch
|
||||
@@ -253,31 +255,23 @@ data class ListUrlBuilder(
|
||||
|
||||
fun build(): String {
|
||||
return when (mode) {
|
||||
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl {
|
||||
if (mode == MODE_SUBSCRIPTION) addPathSegment(EhUrl.WATCHED_PATH)
|
||||
if (category != EhUtils.NONE) {
|
||||
addEncodedQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
|
||||
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
|
||||
if (category > 0) {
|
||||
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
|
||||
}
|
||||
val query = mKeyword?.let {
|
||||
val index = Settings.languageFilter.value
|
||||
GalleryInfo.S_LANG_TAGS.getOrNull(index)?.let { lang ->
|
||||
"$it $lang"
|
||||
} ?: it
|
||||
}
|
||||
addQueryParameterIfNotBlank("f_search", query)
|
||||
addQueryParameterIfNotBlank("f_shash", hash)
|
||||
addQueryParameterIfNotBlank("prev", mPrev)
|
||||
addQueryParameterIfNotBlank("next", mNext)
|
||||
addQueryParameterIfNotBlank("seek", mJumpTo)
|
||||
// Search key
|
||||
// the settings of ub:UrlBuilder may be overwritten by following Advance search
|
||||
mKeyword?.split('|')?.forEachIndexed { idx, kwd ->
|
||||
val keyword = kwd.trim { it <= ' ' }
|
||||
when (idx) {
|
||||
0 -> addQueryParameterIfNotBlank("f_search", keyword)
|
||||
|
||||
else -> keyword.indexOf(':').takeIf { it >= 0 }?.run {
|
||||
val key = keyword.substring(0, this).trim { it <= ' ' }
|
||||
val value = keyword.substring(this + 1).trim { it <= ' ' }
|
||||
addQueryParameterIfNotBlank(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Advance search
|
||||
if (advanceSearch != -1) {
|
||||
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
|
||||
addQueryParameter("advsearch", "1")
|
||||
if (advanceSearch and AdvanceTable.SH != 0) {
|
||||
addQueryParameter("f_sh", "on")
|
||||
@@ -295,20 +289,20 @@ data class ListUrlBuilder(
|
||||
addQueryParameter("f_sft", "on")
|
||||
}
|
||||
// Set min star
|
||||
if (minRating != -1) {
|
||||
if (minRating > 0) {
|
||||
addQueryParameter("f_sr", "on")
|
||||
addQueryParameter("f_srdd", "$minRating")
|
||||
}
|
||||
// Pages
|
||||
if (pageFrom != -1 || pageTo != -1) {
|
||||
if (pageFrom > 0 || pageTo > 0) {
|
||||
addQueryParameter("f_sp", "on")
|
||||
addQueryParameterIfNotBlank(
|
||||
"f_spf",
|
||||
pageFrom.takeUnless { it == -1 }?.toString(),
|
||||
pageFrom.takeIf { it > 0 }?.toString(),
|
||||
)
|
||||
addQueryParameterIfNotBlank(
|
||||
"f_spt",
|
||||
pageTo.takeUnless { it == -1 }?.toString(),
|
||||
pageTo.takeIf { it > 0 }?.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil.disk.DiskCache
|
||||
import coil3.disk.DiskCache
|
||||
|
||||
inline fun DiskCache.edit(key: String, crossinline block: DiskCache.Editor.() -> Unit): Boolean {
|
||||
val editor = openEditor(key) ?: return false
|
||||
|
||||
@@ -2,11 +2,12 @@ package com.hippo.ehviewer.coil
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import coil.decode.DecodeResult
|
||||
import coil.decode.Decoder
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import coil3.asCoilImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.Decoder
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
@@ -27,7 +28,8 @@ fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
|
||||
if (preview is NormalGalleryPreview) size(Size.ORIGINAL)
|
||||
}
|
||||
|
||||
private val stubResult = DecodeResult(ColorDrawable(Color.BLACK), false)
|
||||
private val stubImage = ColorDrawable(Color.BLACK).asCoilImage(true)
|
||||
private val stubResult = DecodeResult(stubImage, false)
|
||||
private val stubFactory = Decoder { stubResult }
|
||||
|
||||
fun ImageRequest.Builder.justDownload() = apply {
|
||||
|
||||
+19
-25
@@ -2,45 +2,39 @@
|
||||
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import coil.ComponentRegistry
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
import coil.request.Options
|
||||
import com.hippo.ehviewer.cronet.copyToChannel
|
||||
import com.hippo.ehviewer.cronet.cronetRequest
|
||||
import com.hippo.ehviewer.cronet.execute
|
||||
import com.hippo.ehviewer.cronet.noCache
|
||||
import java.io.RandomAccessFile
|
||||
import coil3.ComponentRegistry
|
||||
import coil3.ImageLoader
|
||||
import coil3.Uri
|
||||
import coil3.decode.DataSource
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import com.hippo.ehviewer.client.ehRequest
|
||||
import com.hippo.ehviewer.util.copyTo
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
|
||||
/**
|
||||
* A HttpUriFetcher impl use Cronet for jvmheapless IO
|
||||
* A HttpUriFetcher impl use Ktor (Will do jvmheapless IO when backing engine is Cronet)
|
||||
* cacheHeader is never respected | recorded since thumb is immutable
|
||||
*/
|
||||
class CronetHttpUriFetcher(private val data: String, private val options: Options, private val imageLoader: ImageLoader) : Fetcher {
|
||||
class KtorHttpUriFetcher(private val data: String, private val options: Options, private val imageLoader: ImageLoader) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult {
|
||||
val diskCacheKey = options.diskCacheKey ?: data
|
||||
val diskCache = requireNotNull(imageLoader.diskCache)
|
||||
val snapshot = diskCache.openSnapshot(diskCacheKey) ?: run {
|
||||
val success = cronetRequest(data) {
|
||||
noCache()
|
||||
}.execute {
|
||||
val success = ehRequest(data).execute {
|
||||
diskCache.suspendEdit(diskCacheKey) {
|
||||
RandomAccessFile(data.toFile(), "rw").use {
|
||||
copyToChannel(it.channel)
|
||||
}
|
||||
it.bodyAsChannel().copyTo(data.toFile())
|
||||
}
|
||||
}
|
||||
check(success)
|
||||
// diskcache snapshot MUST exist here
|
||||
requireNotNull(diskCache.openSnapshot(diskCacheKey))
|
||||
}
|
||||
return SourceResult(
|
||||
return SourceFetchResult(
|
||||
source = ImageSource(snapshot.data, diskCache.fileSystem, diskCacheKey, snapshot),
|
||||
mimeType = getMimeType(data),
|
||||
dataSource = DataSource.DISK,
|
||||
@@ -48,9 +42,9 @@ class CronetHttpUriFetcher(private val data: String, private val options: Option
|
||||
}
|
||||
}
|
||||
|
||||
fun ComponentRegistry.Builder.installCronetHttpUriFetcher() = add { data: Uri, options, loader ->
|
||||
fun ComponentRegistry.Builder.installKtorHttpUriFetcher() = add { data: Uri, options, loader ->
|
||||
if (data.scheme != "http" && data.scheme != "https") return@add null
|
||||
CronetHttpUriFetcher(data.toString(), options, loader)
|
||||
KtorHttpUriFetcher(data.toString(), options, loader)
|
||||
}
|
||||
|
||||
private fun getMimeType(url: String) = MimeTypeMap.getSingleton().getMimeTypeFromUrl(url)
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil.intercept.Interceptor
|
||||
import coil.request.ImageResult
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageResult
|
||||
import com.hippo.ehviewer.Settings
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
@@ -41,7 +41,7 @@ object MergeInterceptor : Interceptor {
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val req = chain.request
|
||||
val key = req.memoryCacheKey?.key?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed(req) }
|
||||
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed() }
|
||||
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
@@ -62,7 +62,7 @@ object MergeInterceptor : Interceptor {
|
||||
}
|
||||
|
||||
try {
|
||||
return withContext(req.interceptorDispatcher) { chain.proceed(req) }.apply {
|
||||
return withContext(req.interceptorDispatcher) { chain.proceed() }.apply {
|
||||
// Wake all pending continuations shared with the same memory key since we have written it to memory cache
|
||||
notifyScope.launch {
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
|
||||
@@ -1,49 +1,11 @@
|
||||
package com.hippo.ehviewer.cronet
|
||||
|
||||
import com.hippo.ehviewer.EhApplication
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
|
||||
val pool = DirectByteBufferPool(32)
|
||||
|
||||
val cronetHttpClientExecutor = EhApplication.baseOkHttpClient.dispatcher.executorService
|
||||
|
||||
@Suppress("NewApi")
|
||||
suspend inline fun CronetRequest.awaitBodyFully(crossinline callback: (ByteBuffer) -> Unit) {
|
||||
return pool.useInstance { buffer ->
|
||||
suspendCancellableCoroutine { cont ->
|
||||
consumer = {
|
||||
check(it === buffer)
|
||||
buffer.flip()
|
||||
callback(it)
|
||||
buffer.clear()
|
||||
request.read(buffer)
|
||||
}
|
||||
onError = { readerCont.resumeWithException(it) }
|
||||
readerCont = cont
|
||||
request.read(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NewApi")
|
||||
suspend inline fun CronetRequest.awaitBodyFully(buffer: ByteBuffer) {
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
consumer = {
|
||||
request.read(buffer)
|
||||
check(buffer.hasRemaining())
|
||||
}
|
||||
onError = { readerCont.resumeWithException(it) }
|
||||
readerCont = cont
|
||||
request.read(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun CronetRequest.copyToChannel(chan: FileChannel, crossinline listener: ((Int) -> Unit) = {}) = awaitBodyFully {
|
||||
val bytes = chan.write(it)
|
||||
listener(bytes)
|
||||
}
|
||||
// Limit thread to 1 since we are async & non-blocking
|
||||
val cronetDispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
val cronetHttpClientExecutor = cronetDispatcher.asExecutor()
|
||||
@@ -25,15 +25,17 @@ import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import splitties.init.appCtx
|
||||
|
||||
private val signedIn
|
||||
get() = EhCookieStore.hasSignedIn()
|
||||
|
||||
private const val CHANNEL_ID = "DailyCheckNotification"
|
||||
|
||||
class DailyCheckWork(context: Context, workerParams: WorkerParameters) :
|
||||
@@ -52,22 +54,20 @@ val schedHour
|
||||
val schedMinute
|
||||
get() = Settings.requestNewsTimerMinute.takeUnless { it == -1 } ?: 0
|
||||
|
||||
private val whenToWork
|
||||
get() = LocalDateTime.now()
|
||||
.run { withHour(schedHour) }
|
||||
.run { withMinute(schedMinute) }
|
||||
|
||||
private val initialDelay
|
||||
get() = Duration.between(LocalDateTime.now(), whenToWork)
|
||||
.run { if (isNegative) plusDays(1) else this }
|
||||
|
||||
private fun getDailyCheckWorkRequest(): PeriodicWorkRequest {
|
||||
val now = Clock.System.now()
|
||||
val timeZone = TimeZone.currentSystemDefault()
|
||||
val whenToWork = LocalDateTime(
|
||||
now.toLocalDateTime(timeZone).date,
|
||||
LocalTime(schedHour, schedMinute),
|
||||
).toInstant(timeZone)
|
||||
val initialDelay = (whenToWork - now).run { if (isNegative()) plus(1.days) else this }
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
return PeriodicWorkRequestBuilder<DailyCheckWork>(Duration.ofDays(1))
|
||||
.setConstraints(constraints)
|
||||
.setInitialDelay(initialDelay.seconds, TimeUnit.SECONDS)
|
||||
.setInitialDelay(initialDelay.inWholeSeconds, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -85,13 +85,12 @@ fun updateDailyCheckWork(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val today
|
||||
get() = Instant.now().truncatedTo(ChronoUnit.DAYS).epochSecond
|
||||
|
||||
suspend fun checkDawn() = runCatching {
|
||||
if (signedIn && Settings.lastDawnDay != today) {
|
||||
val now = Clock.System.now()
|
||||
val last = Instant.fromEpochSeconds(Settings.lastDawnTime)
|
||||
if (EhCookieStore.hasSignedIn() && now > last + 1.days) {
|
||||
EhEngine.getNews(true)?.let {
|
||||
Settings.lastDawnDay = today
|
||||
Settings.lastDawnTime = now.epochSeconds
|
||||
showEventNotification(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "OK_HTTP_3_COOKIE")
|
||||
data class Cookie(
|
||||
@ColumnInfo(name = "NAME")
|
||||
var name: String,
|
||||
@ColumnInfo(name = "VALUE")
|
||||
var value: String,
|
||||
@ColumnInfo(name = "EXPIRES_AT")
|
||||
var expiresAt: Long,
|
||||
@ColumnInfo(name = "DOMAIN")
|
||||
var domain: String,
|
||||
@ColumnInfo(name = "PATH")
|
||||
var path: String,
|
||||
@ColumnInfo(name = "SECURE")
|
||||
var secure: Boolean,
|
||||
@ColumnInfo(name = "HTTP_ONLY")
|
||||
var httpOnly: Boolean,
|
||||
@ColumnInfo(name = "PERSISTENT")
|
||||
var persistent: Boolean,
|
||||
@ColumnInfo(name = "HOST_ONLY")
|
||||
var hostOnly: Boolean,
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "_id")
|
||||
var id: Long?,
|
||||
)
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface CookiesDao {
|
||||
@Query("SELECT * FROM OK_HTTP_3_COOKIE")
|
||||
suspend fun list(): List<Cookie>
|
||||
}
|
||||
@@ -181,21 +181,6 @@ abstract class EhDatabase : RoomDatabase() {
|
||||
abstract fun quickSearchDao(): QuickSearchDao
|
||||
}
|
||||
|
||||
// 1 -> 2 some nullability changes
|
||||
@Database(
|
||||
entities = [Cookie::class],
|
||||
version = 2,
|
||||
autoMigrations = [
|
||||
AutoMigration(
|
||||
from = 1,
|
||||
to = 2,
|
||||
),
|
||||
],
|
||||
)
|
||||
abstract class CookiesDatabase : RoomDatabase() {
|
||||
abstract fun cookiesDao(): CookiesDao
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [Search::class],
|
||||
version = 2,
|
||||
|
||||
@@ -10,7 +10,7 @@ import androidx.room.Relation
|
||||
import com.hippo.ehviewer.client.data.AbstractDownloadInfo
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import java.time.Instant
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Entity(
|
||||
tableName = "DOWNLOADS",
|
||||
@@ -37,7 +37,7 @@ data class DownloadEntity(
|
||||
override var legacy: Int = 0,
|
||||
|
||||
@ColumnInfo(name = "TIME")
|
||||
override var time: Long = Instant.now().toEpochMilli(),
|
||||
override var time: Long = Clock.System.now().toEpochMilliseconds(),
|
||||
|
||||
@ColumnInfo(name = "LABEL", index = true)
|
||||
override var label: String? = null,
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.hippo.ehviewer.dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import java.time.Instant
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Entity(tableName = "HISTORY", foreignKeys = [ForeignKey(BaseGalleryInfo::class, ["GID"], ["GID"])])
|
||||
class HistoryInfo(gid: Long, time: Long = Instant.now().toEpochMilli()) : TimeInfo(gid, time)
|
||||
class HistoryInfo(gid: Long, time: Long = Clock.System.now().toEpochMilliseconds()) : TimeInfo(gid, time)
|
||||
@@ -3,7 +3,7 @@ package com.hippo.ehviewer.dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import java.time.Instant
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Entity(tableName = "LOCAL_FAVORITES", foreignKeys = [ForeignKey(BaseGalleryInfo::class, ["GID"], ["GID"])])
|
||||
class LocalFavoriteInfo(gid: Long = 0, time: Long = Instant.now().toEpochMilli()) : TimeInfo(gid, time)
|
||||
class LocalFavoriteInfo(gid: Long = 0, time: Long = Clock.System.now().toEpochMilliseconds()) : TimeInfo(gid, time)
|
||||
@@ -30,24 +30,24 @@ import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import coil.decode.BitmapFactoryDecoder
|
||||
import coil.decode.DecodeUtils
|
||||
import coil.decode.ImageSource
|
||||
import coil.request.Options
|
||||
import coil.size.Scale
|
||||
import coil.size.Size
|
||||
import coil3.decode.BitmapFactoryDecoder
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.request.Options
|
||||
import coil3.size.Scale
|
||||
import coil3.size.Size
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.jni.isGif
|
||||
import com.hippo.ehviewer.jni.mmap
|
||||
import com.hippo.ehviewer.jni.munmap
|
||||
import com.hippo.ehviewer.jni.rewriteGifSource
|
||||
import com.hippo.ehviewer.util.isAtLeastO
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastU
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.openInputStream
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.min
|
||||
import okio.FileSystem
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import splitties.init.appCtx
|
||||
@@ -86,8 +86,8 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
|
||||
)
|
||||
}
|
||||
}
|
||||
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 2
|
||||
private val targetHeight = appCtx.resources.displayMetrics.heightPixels * 2
|
||||
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3 / 2
|
||||
private val targetHeight = appCtx.resources.displayMetrics.heightPixels * 3 / 2
|
||||
|
||||
@delegate:RequiresApi(Build.VERSION_CODES.O)
|
||||
val isWideColorGamut by lazy { appCtx.resources.configuration.isScreenWideColorGamut }
|
||||
@@ -122,13 +122,15 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
|
||||
} else {
|
||||
val options = Options(
|
||||
appCtx,
|
||||
colorSpace = if (isAtLeastO) colorSpace else null,
|
||||
size = Size(targetWidth, targetHeight),
|
||||
scale = Scale.FILL,
|
||||
allowInexactSize = true,
|
||||
)
|
||||
val drawable = ImageSource(src.source.openInputStream().source().buffer(), appCtx).use {
|
||||
BitmapFactoryDecoder(it, options).decode().drawable
|
||||
val drawable = ImageSource(
|
||||
src.source.openInputStream().source().buffer(),
|
||||
FileSystem.SYSTEM,
|
||||
).use {
|
||||
BitmapFactoryDecoder(it, options).decode().image.asDrawable(appCtx.resources)
|
||||
}
|
||||
src.close()
|
||||
Image(drawable, src)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.hippo.ehviewer.ktbuilder
|
||||
|
||||
import android.content.Context
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import coil.request.ImageRequest
|
||||
import coil3.ImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.request.ImageRequest
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.coil.ehPreview
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.hippo.ehviewer.ktbuilder
|
||||
|
||||
import com.chuckerteam.chucker.api.ChuckerInterceptor
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import splitties.init.appCtx
|
||||
|
||||
inline fun httpClient(builder: OkHttpClient.Builder.() -> Unit): OkHttpClient = OkHttpClient.Builder().apply(builder).build()
|
||||
inline fun httpClient(client: OkHttpClient, builder: OkHttpClient.Builder.() -> Unit): OkHttpClient = client.newBuilder().apply(builder).build()
|
||||
fun OkHttpClient.Builder.cache(directory: Path, maxSize: Long, fileSystem: FileSystem = FileSystem.SYSTEM) = cache(Cache(directory, maxSize, fileSystem))
|
||||
inline fun OkHttpClient.Builder.chunker(builder: ChuckerInterceptor.Builder.() -> Unit) = addInterceptor(ChuckerInterceptor.Builder(appCtx).apply(builder).build())
|
||||
@@ -1,38 +0,0 @@
|
||||
package com.hippo.ehviewer.legacy
|
||||
|
||||
import arrow.fx.coroutines.release
|
||||
import arrow.fx.coroutines.resource
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.dao.Cookie
|
||||
import com.hippo.ehviewer.dao.CookiesDatabase
|
||||
import splitties.arch.room.roomDb
|
||||
import splitties.init.appCtx
|
||||
|
||||
private const val LEGACY_COOKIES_DB = "okhttp3-cookie.db"
|
||||
|
||||
private fun Cookie.toOkHttp3Cookie(): okhttp3.Cookie {
|
||||
return okhttp3.Cookie.Builder().apply {
|
||||
name(name)
|
||||
value(value)
|
||||
expiresAt(expiresAt)
|
||||
if (hostOnly) hostOnlyDomain(domain) else domain(domain)
|
||||
path(path)
|
||||
if (secure) secure()
|
||||
if (httpOnly) httpOnly()
|
||||
}.build()
|
||||
}
|
||||
|
||||
suspend fun migrateCookies() {
|
||||
if (LEGACY_COOKIES_DB !in appCtx.databaseList()) return
|
||||
resource {
|
||||
roomDb<CookiesDatabase>(LEGACY_COOKIES_DB)
|
||||
} release {
|
||||
it.close()
|
||||
appCtx.deleteDatabase(LEGACY_COOKIES_DB)
|
||||
} use { database ->
|
||||
database.cookiesDao().list().forEach {
|
||||
EhCookieStore.addCookie(it.toOkHttp3Cookie())
|
||||
}
|
||||
EhCookieStore.flush()
|
||||
}
|
||||
}
|
||||
@@ -22,30 +22,24 @@ import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.client.EhUtils.getSuitableTitle
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.ehRequest
|
||||
import com.hippo.ehviewer.client.executeNonCache
|
||||
import com.hippo.ehviewer.client.getImageKey
|
||||
import com.hippo.ehviewer.coil.read
|
||||
import com.hippo.ehviewer.coil.suspendEdit
|
||||
import com.hippo.ehviewer.cronet.copyToChannel
|
||||
import com.hippo.ehviewer.cronet.cronetRequest
|
||||
import com.hippo.ehviewer.cronet.execute
|
||||
import com.hippo.ehviewer.cronet.getHeadersMap
|
||||
import com.hippo.ehviewer.cronet.noCache
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.gallery.SUPPORT_IMAGE_EXTENSIONS
|
||||
import com.hippo.ehviewer.image.Image.UniFileSource
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.isCronetSupported
|
||||
import com.hippo.ehviewer.util.sendTo
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.openOutputStream
|
||||
import io.ktor.client.plugins.onDownload
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.jvm.nio.copyTo
|
||||
import java.util.Locale
|
||||
import kotlin.io.path.readText
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import okio.ForwardingSink
|
||||
import okio.sink
|
||||
|
||||
class SpiderDen(mGalleryInfo: GalleryInfo) {
|
||||
private val mGid = mGalleryInfo.gid
|
||||
@@ -127,42 +121,30 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
|
||||
return dir.createFile(perFilename(index, ext))
|
||||
}
|
||||
|
||||
@Suppress("NewApi")
|
||||
suspend fun makeHttpCallAndSaveImage(
|
||||
index: Int,
|
||||
url: String,
|
||||
referer: String?,
|
||||
notifyProgress: (Long, Long, Int) -> Unit,
|
||||
): Boolean {
|
||||
return if (isCronetSupported) {
|
||||
cronetRequest(url, referer) {
|
||||
noCache()
|
||||
}.execute { info ->
|
||||
val headers = info.getHeadersMap()
|
||||
val type = headers["Content-Type"]?.first()?.toMediaType()?.subtype ?: "jpg"
|
||||
val length = headers["Content-Length"]!!.first().toLong()
|
||||
saveResponseMeta(index, type, length) { file ->
|
||||
file.openOutputStream().use { os ->
|
||||
val chan = os.channel
|
||||
chan.truncate(0)
|
||||
copyToChannel(chan) {
|
||||
notifyProgress(length, info.receivedByteCount, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) = ehRequest(url, referer) {
|
||||
var prev = 0L
|
||||
onDownload { done, total ->
|
||||
notifyProgress(total!!, done, (done - prev).toInt())
|
||||
prev = done
|
||||
}
|
||||
}.execute {
|
||||
if (it.status.isSuccess()) {
|
||||
saveFromHttpResponse(index, it)
|
||||
} else {
|
||||
ehRequest(url, referer).executeNonCache {
|
||||
if (code >= 400) {
|
||||
false
|
||||
} else {
|
||||
saveFromHttpResponse(index, this, notifyProgress)
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveResponseMeta(index: Int, ext: String, length: Long, fops: suspend (UniFile) -> Unit): Boolean {
|
||||
private suspend inline fun saveResponseMeta(
|
||||
index: Int,
|
||||
ext: String,
|
||||
crossinline fops: suspend (UniFile) -> Unit,
|
||||
): Boolean {
|
||||
findDownloadFileForIndex(index, ext)?.run {
|
||||
fops(this)
|
||||
return true
|
||||
@@ -179,19 +161,14 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun saveFromHttpResponse(index: Int, response: Response, notifyProgress: (Long, Long, Int) -> Unit): Boolean {
|
||||
val contentType = response.body.contentType()
|
||||
val extension = contentType?.subtype ?: "jpg"
|
||||
val length = response.body.contentLength()
|
||||
return saveResponseMeta(index, extension, length) { outFile ->
|
||||
object : ForwardingSink(outFile.openOutputStream().also { it.channel.truncate(0) }.sink()) {
|
||||
var totalBytesRead = 0L
|
||||
override fun write(source: Buffer, byteCount: Long) {
|
||||
super.write(source, byteCount)
|
||||
totalBytesRead += byteCount
|
||||
notifyProgress(length, totalBytesRead, byteCount.toInt())
|
||||
}
|
||||
}.use { sink -> response.body.source().use { source -> source.readAll(sink) } }
|
||||
private suspend fun saveFromHttpResponse(index: Int, response: HttpResponse): Boolean {
|
||||
val contentType = response.contentType()
|
||||
val extension = contentType?.contentSubtype ?: "jpg"
|
||||
return saveResponseMeta(index, extension) { outFile ->
|
||||
outFile.openOutputStream().use {
|
||||
val chan = it.channel.apply { truncate(0) }
|
||||
response.bodyAsChannel().copyTo(chan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ import com.hippo.ehviewer.legacy.readLegacySpiderInfo
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.openInputStream
|
||||
import com.hippo.unifile.openOutputStream
|
||||
import java.io.File
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.cbor.Cbor
|
||||
import kotlinx.serialization.decodeFromByteArray
|
||||
import kotlinx.serialization.encodeToByteArray
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import splitties.init.appCtx
|
||||
|
||||
@Serializable
|
||||
@@ -50,7 +50,7 @@ fun SpiderInfo.saveToCache() {
|
||||
|
||||
private val spiderInfoCache by lazy {
|
||||
diskCache {
|
||||
directory(File(appCtx.cacheDir, "spider_info_v2_1"))
|
||||
directory(appCtx.cacheDir.toOkioPath() / "spider_info_v2_1")
|
||||
maxSizeBytes(20 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import com.hippo.ehviewer.client.EhUrl.referer
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.ehRequest
|
||||
import com.hippo.ehviewer.client.exception.QuotaExceededException
|
||||
import com.hippo.ehviewer.client.execute
|
||||
import com.hippo.ehviewer.client.fetchUsingAsText
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePages
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePreviewList
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePreviewPages
|
||||
@@ -41,9 +41,10 @@ import com.hippo.ehviewer.image.Image
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.TimeSource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -366,12 +367,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
ehRequest(
|
||||
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
|
||||
referer,
|
||||
).execute {
|
||||
val bodyStr = body.string()
|
||||
val pages = parsePages(bodyStr)
|
||||
val spiderInfo = SpiderInfo(galleryInfo.gid, pages)
|
||||
spiderInfo.token = galleryInfo.token
|
||||
readPreviews(bodyStr, 0, spiderInfo)
|
||||
).fetchUsingAsText {
|
||||
val pages = parsePages(this)
|
||||
val spiderInfo = SpiderInfo(galleryInfo.gid, pages, token = galleryInfo.token)
|
||||
readPreviews(this, 0, spiderInfo)
|
||||
spiderInfo
|
||||
}
|
||||
}.onFailure {
|
||||
@@ -386,9 +385,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
galleryInfo.token!!,
|
||||
)
|
||||
return try {
|
||||
ehRequest(url, referer).execute {
|
||||
val bodyStr = body.string()
|
||||
GalleryMultiPageViewerPTokenParser.parse(bodyStr).forEachIndexed { index, s ->
|
||||
ehRequest(url, referer).fetchUsingAsText {
|
||||
GalleryMultiPageViewerPTokenParser.parse(this).forEachIndexed { index, s ->
|
||||
spiderInfo.pTokenMap[index] = s
|
||||
}
|
||||
spiderInfo.pTokenMap[index]
|
||||
@@ -419,8 +417,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
false,
|
||||
)
|
||||
return try {
|
||||
ehRequest(url, referer).execute {
|
||||
readPreviews(body.string(), previewIndex, spiderInfo)
|
||||
ehRequest(url, referer).fetchUsingAsText {
|
||||
readPreviews(this, previewIndex, spiderInfo)
|
||||
spiderInfo.pTokenMap[index]
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -521,10 +519,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
private val pTokenLock = Mutex()
|
||||
private var showKey: String? = null
|
||||
private val showKeyLock = Mutex()
|
||||
private val mDownloadDelay = Settings.downloadDelay.toLong()
|
||||
private val mDownloadDelay = Settings.downloadDelay.milliseconds
|
||||
private val downloadTimeout = Settings.downloadTimeout.seconds
|
||||
private val delayLock = Mutex()
|
||||
private var delayedTime = 0L
|
||||
private var delayedTime = TimeSource.Monotonic.markNow()
|
||||
private var isDownloadMode = false
|
||||
|
||||
fun cancelDecode(index: Int) {
|
||||
@@ -561,8 +559,17 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
if (force) currentJob?.cancel()
|
||||
if (currentJob?.isActive != true) {
|
||||
mFetcherJobMap[index] = launch {
|
||||
mSemaphore.withPermit {
|
||||
doInJob(index, force, orgImg)
|
||||
runCatching {
|
||||
mSemaphore.withPermit {
|
||||
doInJob(index, force, orgImg)
|
||||
}
|
||||
}.onFailure {
|
||||
if (it is CancellationException) {
|
||||
Log.d(WORKER_DEBUG_TAG, "Download image $index cancelled")
|
||||
updatePageState(index, STATE_FAILED, "Cancelled")
|
||||
throw it
|
||||
}
|
||||
updatePageState(index, STATE_FAILED, ExceptionUtils.getReadableString(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -610,9 +617,14 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
|
||||
delayLock.withLock {
|
||||
val curTime = Instant.now().toEpochMilli()
|
||||
delayedTime = (delayedTime + mDownloadDelay).coerceAtLeast(curTime)
|
||||
delay(delayedTime - curTime)
|
||||
val new = delayedTime + mDownloadDelay
|
||||
val duration = new.elapsedNow()
|
||||
if (duration.isNegative()) {
|
||||
delayedTime = new
|
||||
delay(-duration)
|
||||
} else {
|
||||
delayedTime = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
}
|
||||
|
||||
var skipHathKey: String? = null
|
||||
@@ -620,7 +632,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
var error: String? = null
|
||||
var forceHtml = false
|
||||
val original = Settings.downloadOriginImage || orgImg
|
||||
runCatching {
|
||||
runSuspendCatching {
|
||||
repeat(2) { retries ->
|
||||
var imageUrl: String? = null
|
||||
var localShowKey: String?
|
||||
@@ -713,14 +725,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
}.onFailure {
|
||||
when (it) {
|
||||
is CancellationException -> {
|
||||
Log.d(WORKER_DEBUG_TAG, "Download image $index cancelled")
|
||||
error = "Cancelled"
|
||||
updatePageState(index, STATE_FAILED, error)
|
||||
throw it
|
||||
}
|
||||
|
||||
is QuotaExceededException -> notifyGet509(index)
|
||||
// TODO: Check IP ban
|
||||
}
|
||||
error = ExceptionUtils.getReadableString(it)
|
||||
}
|
||||
@@ -750,18 +756,22 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
|
||||
private suspend fun doInJob(index: Int) {
|
||||
mFetcherJobMap[index]?.takeIf { it.isActive }?.join()
|
||||
val src = mSpiderDen.getImageSource(index) ?: return
|
||||
val image = mSemaphore.withPermit { Image.decode(src) }
|
||||
runCatching {
|
||||
currentCoroutineContext().ensureActive()
|
||||
}.onFailure {
|
||||
image?.recycle()
|
||||
throw it
|
||||
}
|
||||
if (image == null) {
|
||||
notifyGetImageFailure(index, DECODE_ERROR)
|
||||
} else {
|
||||
val src = mSpiderDen.getImageSource(index) ?: return
|
||||
val image = mSemaphore.withPermit { Image.decode(src) }
|
||||
checkNotNull(image)
|
||||
runCatching {
|
||||
currentCoroutineContext().ensureActive()
|
||||
}.onFailure {
|
||||
image.recycle()
|
||||
throw it
|
||||
}
|
||||
notifyGetImageSuccess(index, image)
|
||||
}.onFailure {
|
||||
notifyGetImageFailure(index, DECODE_ERROR)
|
||||
if (it is CancellationException) {
|
||||
throw it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,32 +15,14 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import android.content.res.Resources.Theme
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.navigation.ActivityNavigator
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import eu.kanade.tachiyomi.util.system.isNightMode
|
||||
import eu.kanade.tachiyomi.util.view.setSecureScreen
|
||||
|
||||
abstract class EhActivity : AppCompatActivity() {
|
||||
@StyleRes
|
||||
fun getThemeStyleRes(): Int {
|
||||
return if (Settings.blackDarkTheme && isNightMode()) R.style.ThemeOverlay_Black else R.style.ThemeOverlay
|
||||
}
|
||||
|
||||
override fun onApplyThemeResource(theme: Theme, resid: Int, first: Boolean) {
|
||||
super.onApplyThemeResource(theme, resid, first)
|
||||
theme.applyStyle(getThemeStyleRes(), true)
|
||||
}
|
||||
|
||||
override fun onNightModeChanged(mode: Int) {
|
||||
theme.applyStyle(getThemeStyleRes(), true)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
@@ -113,7 +113,6 @@ import com.hippo.ehviewer.ui.screen.TokenArgs
|
||||
import com.hippo.ehviewer.ui.screen.navWithUrl
|
||||
import com.hippo.ehviewer.ui.screen.navigate
|
||||
import com.hippo.ehviewer.ui.settings.showNewVersion
|
||||
import com.hippo.ehviewer.ui.tools.IconFix
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalTouchSlopProvider
|
||||
import com.hippo.ehviewer.updater.AppUpdater
|
||||
@@ -129,9 +128,12 @@ import com.ramcosta.composedestinations.utils.currentDestinationAsState
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import splitties.systemservices.clipboardManager
|
||||
@@ -187,11 +189,12 @@ class MainActivity : EhActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
intentFlow.tryEmit(intent)
|
||||
intentChannel.trySend(intent)
|
||||
}
|
||||
|
||||
private val tipFlow = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
private val intentFlow = MutableSharedFlow<Intent>(extraBufferCapacity = 4)
|
||||
private val intentChannel = Channel<Intent>(capacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
private val intentFlow = intentChannel.receiveAsFlow()
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -211,8 +214,8 @@ class MainActivity : EhActivity() {
|
||||
|
||||
val cannotParse = stringResource(R.string.error_cannot_parse_the_url)
|
||||
LaunchedEffect(Unit) {
|
||||
intentFlow.collect {
|
||||
when (intent?.action) {
|
||||
intentFlow.collect { intent ->
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
val url = intent.data?.toString()
|
||||
if (url != null && !navController.navWithUrl(url)) {
|
||||
@@ -317,6 +320,7 @@ class MainActivity : EhActivity() {
|
||||
LocalNavDrawerState provides navDrawerState,
|
||||
LocalSideSheetState provides sideSheetState,
|
||||
LocalDrawerLockHandle provides lockDrawerHandle,
|
||||
LocalSnackbarHostState provides snackbarState,
|
||||
) {
|
||||
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) {
|
||||
LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) {
|
||||
@@ -349,7 +353,7 @@ class MainActivity : EhActivity() {
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
icon = {
|
||||
IconFix(imageVector = icon, contentDescription = null)
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -492,6 +496,7 @@ class MainActivity : EhActivity() {
|
||||
val LocalNavDrawerState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalNavDrawerState not present!") }
|
||||
val LocalSideSheetState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalSideSheetState not present!") }
|
||||
val LocalDrawerLockHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalSideSheetState not present!") }
|
||||
val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> { error("CompositionLocal LocalSnackbarHostState not present!") }
|
||||
|
||||
@Composable
|
||||
fun LockDrawer(value: Boolean) {
|
||||
|
||||
@@ -5,33 +5,42 @@ import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import com.google.accompanist.themeadapter.material3.Mdc3Theme
|
||||
import com.hippo.ehviewer.ui.theme.EhTheme
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.ProvideVectorPainterCache
|
||||
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
|
||||
|
||||
// To make sure compose theme and legacy view theme are in sync, we only use Mdc3Theme
|
||||
|
||||
inline fun ComposeView.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
|
||||
Mdc3Theme {
|
||||
EhTheme {
|
||||
val dialogState = remember { DialogState() }
|
||||
dialogState.Intercept()
|
||||
CompositionLocalProvider(LocalDialogState provides dialogState) {
|
||||
CompositionLocalProvider(
|
||||
LocalDialogState provides dialogState,
|
||||
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
|
||||
Mdc3Theme {
|
||||
val dialogState = remember { DialogState() }
|
||||
dialogState.Intercept()
|
||||
CompositionLocalProvider(LocalDialogState provides dialogState) {
|
||||
content()
|
||||
EhTheme {
|
||||
ProvideVectorPainterCache {
|
||||
val dialogState = remember { DialogState() }
|
||||
dialogState.Intercept()
|
||||
CompositionLocalProvider(
|
||||
LocalDialogState provides dialogState,
|
||||
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.DrawableWrapper
|
||||
import android.text.Html
|
||||
import android.widget.TextView
|
||||
import coil.imageLoader
|
||||
import coil3.imageLoader
|
||||
import coil3.request.crossfade
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
|
||||
class CoilImageGetter(
|
||||
@@ -16,7 +17,7 @@ class CoilImageGetter(
|
||||
data(source)
|
||||
crossfade(false)
|
||||
target { drawable ->
|
||||
setDrawable(drawable)
|
||||
setDrawable(drawable.asDrawable(textView.resources))
|
||||
if (drawable is Animatable) drawable.start()
|
||||
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
|
||||
textView.text = textView.text
|
||||
|
||||
@@ -68,7 +68,6 @@ import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Cookie
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
@@ -93,15 +92,13 @@ fun CookieSignInScene(navigator: DestinationsNavigator) {
|
||||
|
||||
val noCookies = stringResource(R.string.from_clipboard_error)
|
||||
|
||||
fun newCookie(name: String, value: String, domain: String) = Cookie.Builder().name(name).value(value).domain(domain).expiresAt(Long.MAX_VALUE).build()
|
||||
|
||||
fun storeCookie(id: String, hash: String, igneous: String) {
|
||||
EhUtils.signOut()
|
||||
EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_E))
|
||||
EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_EX))
|
||||
EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_E))
|
||||
EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_EX))
|
||||
if (igneous.isNotBlank()) EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IGNEOUS, igneous, EhUrl.DOMAIN_EX))
|
||||
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_E)
|
||||
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_EX)
|
||||
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_E)
|
||||
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_EX)
|
||||
if (igneous.isNotBlank()) EhCookieStore.addCookie(EhCookieStore.KEY_IGNEOUS, igneous, EhUrl.DOMAIN_EX)
|
||||
}
|
||||
|
||||
fun login() {
|
||||
|
||||
@@ -22,7 +22,7 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import io.ktor.http.Url
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
@@ -39,7 +39,7 @@ fun WebViewSignInScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
var getId = false
|
||||
var getHash = false
|
||||
EhCookieStore.loadForRequest(EhUrl.HOST_E.toHttpUrl()).forEach {
|
||||
EhCookieStore.load(Url(EhUrl.HOST_E)).forEach {
|
||||
if (EhCookieStore.KEY_IPB_MEMBER_ID == it.name) {
|
||||
getId = true
|
||||
} else if (EhCookieStore.KEY_IPB_PASS_HASH == it.name) {
|
||||
|
||||
@@ -14,7 +14,7 @@ object AdvanceTable {
|
||||
@Parcelize
|
||||
data class AdvancedSearchOption(
|
||||
val advanceSearch: Int = 0,
|
||||
val minRating: Int = -1,
|
||||
val fromPage: Int = -1,
|
||||
val toPage: Int = -1,
|
||||
val minRating: Int = 0,
|
||||
val fromPage: Int = 0,
|
||||
val toPage: Int = 0,
|
||||
) : Parcelable
|
||||
@@ -1,136 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
|
||||
import com.hippo.ehviewer.util.toIntOrDefault
|
||||
|
||||
@Composable
|
||||
fun SearchAdvanced(
|
||||
modifier: Modifier = Modifier,
|
||||
state: AdvancedSearchOption,
|
||||
onStateChanged: (AdvancedSearchOption) -> Unit,
|
||||
) = FlowRow(modifier = modifier, horizontalArrangement = Arrangement.Center, maxItemsInEachRow = 2) {
|
||||
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
|
||||
val adv = state.advanceSearch
|
||||
fun checked(bit: Int) = adv and bit != 0
|
||||
fun AdvancedSearchOption.inv(bit: Int) = onStateChanged(copy(advanceSearch = advanceSearch xor bit))
|
||||
LabeledCheckbox(
|
||||
checked = checked(AdvanceTable.SH),
|
||||
onCheckedChange = { state.inv(AdvanceTable.SH) },
|
||||
label = stringResource(id = R.string.search_sh),
|
||||
)
|
||||
LabeledCheckbox(
|
||||
checked = checked(AdvanceTable.STO),
|
||||
onCheckedChange = { state.inv(AdvanceTable.STO) },
|
||||
label = stringResource(id = R.string.search_sto),
|
||||
)
|
||||
val minRatingItems = stringArrayResource(id = R.array.search_min_rating)
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
modifier = Modifier.align(Alignment.CenterVertically).padding(12.dp),
|
||||
) {
|
||||
val softwareKeyboardController = LocalSoftwareKeyboardController.current
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.menuAnchor(),
|
||||
readOnly = true,
|
||||
value = minRatingItems[if (state.minRating in 2..5) state.minRating - 1 else 0],
|
||||
onValueChange = {},
|
||||
label = { Text(stringResource(id = R.string.search_sr)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
SideEffect {
|
||||
softwareKeyboardController?.hide()
|
||||
}
|
||||
minRatingItems.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(selectionOption) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
val index = minRatingItems.indexOf(selectionOption)
|
||||
onStateChanged(state.copy(minRating = if (index > 0) index + 1 else -1))
|
||||
},
|
||||
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
var enabled by rememberSaveable { mutableStateOf(false) }
|
||||
LabeledCheckbox(
|
||||
checked = enabled,
|
||||
onCheckedChange = { enabled = it },
|
||||
label = stringResource(id = R.string.search_sp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
value = if (enabled && state.fromPage != -1) state.fromPage.toString() else "",
|
||||
onValueChange = { onStateChanged(state.copy(fromPage = it.toIntOrDefault(-1))) },
|
||||
modifier = Modifier.width(96.dp).padding(16.dp),
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
)
|
||||
Text(text = stringResource(id = R.string.search_sp_to))
|
||||
OutlinedTextField(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
value = if (enabled && state.toPage != -1) state.toPage.toString() else "",
|
||||
onValueChange = { onStateChanged(state.copy(toPage = it.toIntOrDefault(-1))) },
|
||||
modifier = Modifier.width(96.dp).padding(16.dp),
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
)
|
||||
Text(text = stringResource(id = R.string.search_sp_suffix))
|
||||
}
|
||||
Text(text = stringResource(id = R.string.search_sf), modifier = Modifier.align(Alignment.CenterVertically))
|
||||
FlowRow {
|
||||
LabeledCheckbox(
|
||||
checked = checked(AdvanceTable.SFL),
|
||||
onCheckedChange = { state.inv(AdvanceTable.SFL) },
|
||||
label = stringResource(id = R.string.search_sfl),
|
||||
)
|
||||
LabeledCheckbox(
|
||||
checked = checked(AdvanceTable.SFU),
|
||||
onCheckedChange = { state.inv(AdvanceTable.SFU) },
|
||||
label = stringResource(id = R.string.search_sfu),
|
||||
)
|
||||
LabeledCheckbox(
|
||||
checked = checked(AdvanceTable.SFT),
|
||||
onCheckedChange = { state.inv(AdvanceTable.SFT) },
|
||||
label = stringResource(id = R.string.search_sft),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,8 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import com.hippo.ehviewer.EhApplication
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -46,10 +46,10 @@ import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.download.downloadDir
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.ui.tools.CropDefaults
|
||||
import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.DragHandle
|
||||
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
|
||||
import com.hippo.ehviewer.ui.tools.shouldCrop
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.sendTo
|
||||
import com.hippo.unifile.UniFile
|
||||
@@ -80,10 +80,8 @@ private fun AsyncThumb(
|
||||
modifier = modifier,
|
||||
onState = { state ->
|
||||
if (state is AsyncImagePainter.State.Success) {
|
||||
state.result.drawable.run {
|
||||
if (CropDefaults.shouldCrop(intrinsicWidth, intrinsicHeight)) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
if (state.result.image.shouldCrop) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
path?.let {
|
||||
coroutineScope.launch {
|
||||
|
||||
@@ -9,12 +9,12 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.request.ImageRequest
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.ui.tools.CropDefaults
|
||||
import com.hippo.ehviewer.ui.tools.shouldCrop
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
@@ -46,10 +46,8 @@ fun EhAsyncCropThumb(
|
||||
modifier = modifier,
|
||||
onState = {
|
||||
if (it is AsyncImagePainter.State.Success) {
|
||||
it.result.drawable.run {
|
||||
if (CropDefaults.shouldCrop(intrinsicWidth, intrinsicHeight)) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
if (it.result.image.shouldCrop) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.NoAccounts
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
@@ -35,7 +36,6 @@ import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.icons.big.SadAndroid
|
||||
import com.hippo.ehviewer.ui.tools.IconFix
|
||||
|
||||
@Composable
|
||||
fun GalleryDetailHeaderInfoCard(
|
||||
@@ -131,7 +131,7 @@ fun GalleryDetailHeaderCard(
|
||||
label = { Text(text = categoryText, maxLines = 1) },
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)),
|
||||
leadingIcon = {
|
||||
IconFix(
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.Label,
|
||||
contentDescription = null,
|
||||
)
|
||||
@@ -143,7 +143,7 @@ fun GalleryDetailHeaderCard(
|
||||
label = { Text(text = uploaderText, maxLines = 1) },
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)),
|
||||
leadingIcon = {
|
||||
IconFix(
|
||||
Icon(
|
||||
imageVector = Icons.Default.NoAccounts,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable(onClick = onBlockUploaderIconClick),
|
||||
@@ -160,7 +160,7 @@ fun GalleryDetailErrorTip(error: String, onClick: () -> Unit) = Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
IconFix(
|
||||
Icon(
|
||||
imageVector = EhIcons.Big.Default.SadAndroid,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
|
||||
@@ -30,11 +30,14 @@ import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import androidx.constraintlayout.compose.ConstraintSet
|
||||
import arrow.core.Tuple7
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
@@ -45,6 +48,41 @@ import com.hippo.ehviewer.ui.tools.ElevatedCard
|
||||
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
|
||||
private val ids = Tuple7(1, 2, 3, 4, 5, 6, 7)
|
||||
|
||||
private val constraintSet = ConstraintSet {
|
||||
val (titleRef, uploaderRef, ratingRef, categoryRef, postedRef, favRef, iconsRef) =
|
||||
createRefsFor(1, 2, 3, 4, 5, 6, 7)
|
||||
constrain(titleRef) {
|
||||
top.linkTo(parent.top)
|
||||
start.linkTo(parent.start)
|
||||
}
|
||||
constrain(uploaderRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(ratingRef.top)
|
||||
}
|
||||
constrain(ratingRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(categoryRef.top)
|
||||
}
|
||||
constrain(categoryRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(parent.bottom)
|
||||
}
|
||||
constrain(iconsRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(postedRef.top)
|
||||
}
|
||||
constrain(favRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(iconsRef.top)
|
||||
}
|
||||
constrain(postedRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(parent.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GalleryInfoListItem(
|
||||
onClick: () -> Unit,
|
||||
@@ -66,15 +104,12 @@ fun GalleryInfoListItem(
|
||||
modifier = Modifier.aspectRatio(DEFAULT_ASPECT).fillMaxSize(),
|
||||
)
|
||||
}
|
||||
ConstraintLayout(modifier = Modifier.padding(8.dp, 4.dp).fillMaxSize()) {
|
||||
val (titleRef, uploaderRef, ratingRef, categoryRef, postedRef, favRef, iconsRef) = createRefs()
|
||||
ConstraintLayout(modifier = Modifier.padding(8.dp, 4.dp).fillMaxSize(), constraintSet = constraintSet) {
|
||||
val (titleRef, uploaderRef, ratingRef, categoryRef, postedRef, favRef, iconsRef) = ids
|
||||
Text(
|
||||
text = EhUtils.getSuitableTitle(info),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.constrainAs(titleRef) {
|
||||
top.linkTo(parent.top)
|
||||
start.linkTo(parent.start)
|
||||
}.fillMaxWidth(),
|
||||
modifier = Modifier.layoutId(titleRef).fillMaxWidth(),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
@@ -82,39 +117,27 @@ fun GalleryInfoListItem(
|
||||
info.uploader?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.constrainAs(uploaderRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(ratingRef.top)
|
||||
}.alpha(if (info.disowned) 0.5f else 1f),
|
||||
modifier = Modifier.layoutId(uploaderRef).alpha(if (info.disowned) 0.5f else 1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
GalleryListCardRating(
|
||||
rating = info.rating,
|
||||
modifier = Modifier.constrainAs(ratingRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(categoryRef.top)
|
||||
},
|
||||
modifier = Modifier.layoutId(ratingRef),
|
||||
)
|
||||
val categoryColor = EhUtils.getCategoryColor(info.category)
|
||||
val categoryText = EhUtils.getCategory(info.category).uppercase()
|
||||
Text(
|
||||
text = categoryText,
|
||||
modifier = Modifier.constrainAs(categoryRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(parent.bottom)
|
||||
}.clip(ShapeDefaults.Small).background(categoryColor).padding(vertical = 2.dp, horizontal = 8.dp),
|
||||
modifier = Modifier.layoutId(categoryRef).clip(ShapeDefaults.Small).background(categoryColor).padding(vertical = 2.dp, horizontal = 8.dp),
|
||||
color = if (Settings.harmonizeCategoryColor) Color.Unspecified else EhUtils.categoryTextColor,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.constrainAs(iconsRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(postedRef.top)
|
||||
},
|
||||
modifier = Modifier.layoutId(iconsRef),
|
||||
) {
|
||||
val download by DownloadManager.collectContainDownloadInfo(info.gid)
|
||||
if (download) {
|
||||
@@ -132,10 +155,7 @@ fun GalleryInfoListItem(
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.constrainAs(favRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(iconsRef.top)
|
||||
},
|
||||
modifier = Modifier.layoutId(favRef),
|
||||
) {
|
||||
if (isInFavScene) {
|
||||
Text(
|
||||
@@ -156,10 +176,7 @@ fun GalleryInfoListItem(
|
||||
}
|
||||
Text(
|
||||
text = info.posted.orEmpty(),
|
||||
modifier = Modifier.constrainAs(postedRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(parent.bottom)
|
||||
},
|
||||
modifier = Modifier.layoutId(postedRef),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ fun GalleryList(
|
||||
thumbListState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
|
||||
thumbItemContent: @Composable (LazyStaggeredGridItemScope.(BaseGalleryInfo) -> Unit),
|
||||
refreshState: PullToRefreshState,
|
||||
scrollToTopOnRefresh: Boolean = true,
|
||||
onRefresh: suspend CoroutineScope.() -> Unit,
|
||||
onLoading: suspend CoroutineScope.() -> Unit,
|
||||
) {
|
||||
@@ -176,7 +177,7 @@ fun GalleryList(
|
||||
|
||||
val dialogState = LocalDialogState.current
|
||||
when (val state = data.loadState.refresh) {
|
||||
is LoadState.Loading -> if (!refreshState.isRefreshing) {
|
||||
is LoadState.Loading -> if (!refreshState.isRefreshing && scrollToTopOnRefresh) {
|
||||
LaunchedEffect(Unit) {
|
||||
onLoading()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
@@ -21,14 +20,15 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import coil3.BitmapImage
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.request.ImageRequest
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.ui.tools.CropDefaults
|
||||
import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.shouldCrop
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
@@ -44,6 +44,7 @@ fun EhAsyncPreview(
|
||||
model: GalleryPreview,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var contentScale by remember(model.imageKey) { mutableStateOf(ContentScale.Fit) }
|
||||
AsyncImage(
|
||||
model = requestOf(model),
|
||||
@@ -54,7 +55,7 @@ fun EhAsyncPreview(
|
||||
if (it is AsyncImagePainter.State.Success && this is NormalGalleryPreview) {
|
||||
it.copy(
|
||||
painter = BitmapPainter(
|
||||
(it.result.drawable as BitmapDrawable).bitmap.asImageBitmap(),
|
||||
(it.result.image as BitmapImage).bitmap.asImageBitmap(),
|
||||
IntOffset(offsetX, 0),
|
||||
IntSize(clipWidth - 1, clipHeight - 1),
|
||||
),
|
||||
@@ -68,14 +69,12 @@ fun EhAsyncPreview(
|
||||
if (it is AsyncImagePainter.State.Success) {
|
||||
model.run {
|
||||
if (this is NormalGalleryPreview) {
|
||||
if (CropDefaults.shouldCrop(clipWidth, clipHeight)) {
|
||||
if (shouldCrop) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
} else {
|
||||
it.result.drawable.run {
|
||||
if (CropDefaults.shouldCrop(intrinsicWidth, intrinsicHeight)) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
if (it.result.image.shouldCrop) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.hippo.ehviewer.ui.main
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -84,7 +86,7 @@ private fun BaseRoundText(
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp).width(IntrinsicSize.Max),
|
||||
color = MaterialTheme.colorScheme.onSurface.let { if (weak) it.copy(0.5F) else it },
|
||||
style = MaterialTheme.typography.labelLarge.includeFontPadding,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
|
||||
|
||||
private val categoryTable = arrayOf(
|
||||
EhUtils.DOUJINSHI to R.string.doujinshi,
|
||||
EhUtils.MANGA to R.string.manga,
|
||||
EhUtils.ARTIST_CG to R.string.artist_cg,
|
||||
EhUtils.GAME_CG to R.string.game_cg,
|
||||
EhUtils.WESTERN to R.string.western,
|
||||
EhUtils.NON_H to R.string.non_h,
|
||||
EhUtils.IMAGE_SET to R.string.image_set,
|
||||
EhUtils.COSPLAY to R.string.cosplay,
|
||||
EhUtils.ASIAN_PORN to R.string.asian_porn,
|
||||
EhUtils.MISC to R.string.misc,
|
||||
)
|
||||
|
||||
private val modeTable = arrayOf(
|
||||
1 to R.string.search_normal_search,
|
||||
2 to R.string.search_subscription_search,
|
||||
3 to R.string.search_specify_uploader,
|
||||
4 to R.string.search_specify_tag,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun NormalSearch(
|
||||
modifier: Modifier = Modifier,
|
||||
category: Int,
|
||||
onCategoryChanged: (Int) -> Unit,
|
||||
searchMode: Int,
|
||||
onSearchModeChanged: (Int) -> Unit,
|
||||
isAdvanced: Boolean,
|
||||
onAdvancedChanged: (Boolean) -> Unit,
|
||||
showInfo: () -> Unit,
|
||||
maxItemsInEachRow: Int,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier.wrapContentHeight(align = Alignment.Top),
|
||||
maxItemsInEachRow = maxItemsInEachRow,
|
||||
) {
|
||||
categoryTable.forEach {
|
||||
val selected = category and it.first != 0
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onCategoryChanged(if (selected) category xor it.first else category or it.first) },
|
||||
label = { Text(text = stringResource(id = it.second)) },
|
||||
leadingIcon = {
|
||||
AnimatedVisibility(selected) {
|
||||
Icon(imageVector = Icons.Default.Check, contentDescription = null)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 4.dp).widthIn(min = 142.dp)
|
||||
.align(alignment = Alignment.CenterVertically).weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
maxItemsInEachRow = maxItemsInEachRow,
|
||||
) {
|
||||
modeTable.forEach {
|
||||
Row(
|
||||
modifier = Modifier.clip(MaterialTheme.shapes.small)
|
||||
.clickable { onSearchModeChanged(it.first) }.padding(end = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = it.first == searchMode,
|
||||
onClick = { onSearchModeChanged(it.first) },
|
||||
)
|
||||
Text(text = stringResource(id = it.second))
|
||||
}
|
||||
}
|
||||
IconButton(onClick = showInfo) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Default.Help, contentDescription = null)
|
||||
}
|
||||
LabeledCheckbox(
|
||||
checked = isAdvanced,
|
||||
onCheckedChange = onAdvancedChanged,
|
||||
label = stringResource(id = R.string.search_enable_advance),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FilterAlt
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.outlined.FilterAlt
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.asMutableState
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.util.toIntOrDefault
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val categoryTable = arrayOf(
|
||||
EhUtils.DOUJINSHI to R.string.doujinshi,
|
||||
EhUtils.MANGA to R.string.manga,
|
||||
EhUtils.ARTIST_CG to R.string.artist_cg,
|
||||
EhUtils.GAME_CG to R.string.game_cg,
|
||||
EhUtils.WESTERN to R.string.western,
|
||||
EhUtils.NON_H to R.string.non_h,
|
||||
EhUtils.IMAGE_SET to R.string.image_set,
|
||||
EhUtils.COSPLAY to R.string.cosplay,
|
||||
EhUtils.ASIAN_PORN to R.string.asian_porn,
|
||||
EhUtils.MISC to R.string.misc,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SearchFilter(
|
||||
modifier: Modifier = Modifier,
|
||||
category: Int,
|
||||
onCategoryChanged: (Int) -> Unit,
|
||||
advancedOption: AdvancedSearchOption,
|
||||
onAdvancedOptionChanged: (AdvancedSearchOption) -> Unit,
|
||||
) = Column(modifier) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val dialogState = LocalDialogState.current
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
categoryTable.forEach {
|
||||
val selected = category and it.first != 0
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onCategoryChanged(category xor it.first) },
|
||||
label = { Text(text = stringResource(id = it.second)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
var languageFilter by Settings.languageFilter.asMutableState()
|
||||
val languages = remember {
|
||||
GalleryInfo.S_LANG_TAGS.map { tag ->
|
||||
tag.substringAfter(':').let {
|
||||
if (EhTagDatabase.initialized && EhTagDatabase.isTranslatable(context)) {
|
||||
EhTagDatabase.getTranslation("l", it) ?: it
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}.toTypedArray()
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val any = stringResource(id = R.string.any)
|
||||
val languageStr = stringResource(id = R.string.key_language)
|
||||
FilterChip(
|
||||
selected = languageFilter != -1,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
languageFilter = dialogState.showSingleChoice(any, *languages, selected = languageFilter + 1) - 1
|
||||
}
|
||||
},
|
||||
label = {
|
||||
Text(text = if (languageFilter == -1) languageStr else languages[languageFilter])
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = if (languageFilter == -1) Icons.Outlined.FilterAlt else Icons.Default.FilterAlt,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
val pageErr1 = stringResource(R.string.search_sp_err1)
|
||||
val pageErr2 = stringResource(R.string.search_sp_err2)
|
||||
val pages = stringResource(id = R.string.key_pages)
|
||||
val pagesText = remember(advancedOption.fromPage, advancedOption.toPage) {
|
||||
advancedOption.run {
|
||||
val hasFrom = fromPage > 0
|
||||
val hasTo = toPage > 0
|
||||
if (hasFrom && hasTo) {
|
||||
"$fromPage - $toPage P"
|
||||
} else if (hasFrom) {
|
||||
"$fromPage+ P"
|
||||
} else if (hasTo) {
|
||||
"$toPage- P"
|
||||
} else {
|
||||
pages
|
||||
}
|
||||
}
|
||||
}
|
||||
FilterChip(
|
||||
selected = advancedOption.fromPage != 0 || advancedOption.toPage != 0,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val (from, to) = dialogState.awaitResult(
|
||||
initial = advancedOption.fromPage to advancedOption.toPage,
|
||||
title = R.string.key_pages,
|
||||
invalidator = { (from, to) ->
|
||||
if (to != 0 && to < 10) {
|
||||
pageErr1
|
||||
} else if (from != 0 && to != 0 && to - from < 20) {
|
||||
pageErr2
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
) { error ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
value = expectedValue.first.takeIf { it > 0 }?.toString().orEmpty(),
|
||||
onValueChange = {
|
||||
expectedValue =
|
||||
expectedValue.copy(first = it.toIntOrDefault(0).coerceAtLeast(0))
|
||||
},
|
||||
modifier = Modifier.width(112.dp).padding(16.dp),
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
)
|
||||
Text(text = stringResource(id = R.string.search_sp_to))
|
||||
OutlinedTextField(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
value = expectedValue.second.takeIf { it > 0 }?.toString().orEmpty(),
|
||||
onValueChange = {
|
||||
expectedValue =
|
||||
expectedValue.copy(second = it.toIntOrDefault(0).coerceAtLeast(0))
|
||||
},
|
||||
modifier = Modifier.width(112.dp).padding(16.dp),
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
)
|
||||
Text(text = stringResource(id = R.string.search_sp_suffix))
|
||||
}
|
||||
if (error != null) {
|
||||
ListItem(
|
||||
headlineContent = { Text(text = error) },
|
||||
leadingContent = {
|
||||
Icon(imageVector = Icons.Default.Info, contentDescription = null)
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
headlineColor = MaterialTheme.colorScheme.error,
|
||||
leadingIconColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
onAdvancedOptionChanged(advancedOption.copy(fromPage = from, toPage = to))
|
||||
}
|
||||
},
|
||||
label = { Text(text = pagesText) },
|
||||
)
|
||||
val minRatingItems = stringArrayResource(id = R.array.search_min_rating)
|
||||
val minRatingStr = stringResource(id = R.string.search_sr)
|
||||
val minRatingIndex = (advancedOption.minRating - 1).coerceAtLeast(0)
|
||||
FilterChip(
|
||||
selected = advancedOption.minRating != 0,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val selected = dialogState.showSingleChoice(*minRatingItems, selected = minRatingIndex)
|
||||
onAdvancedOptionChanged(advancedOption.copy(minRating = if (selected == 0) 0 else selected + 1))
|
||||
}
|
||||
},
|
||||
label = { Text(text = minRatingStr + minRatingItems[minRatingIndex]) },
|
||||
)
|
||||
fun checked(bit: Int) = advancedOption.advanceSearch and bit != 0
|
||||
fun AdvancedSearchOption.inv(bit: Int) = onAdvancedOptionChanged(copy(advanceSearch = advanceSearch xor bit))
|
||||
FilterChip(
|
||||
selected = checked(AdvanceTable.SH),
|
||||
onClick = { advancedOption.inv(AdvanceTable.SH) },
|
||||
label = { Text(text = stringResource(id = R.string.search_sh)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = checked(AdvanceTable.STO),
|
||||
onClick = { advancedOption.inv(AdvanceTable.STO) },
|
||||
label = { Text(text = stringResource(id = R.string.search_sto)) },
|
||||
)
|
||||
val disableFilter = stringResource(id = R.string.search_sf)
|
||||
FilterChip(
|
||||
selected = checked(AdvanceTable.SFL),
|
||||
onClick = { advancedOption.inv(AdvanceTable.SFL) },
|
||||
label = { Text(text = disableFilter + stringResource(id = R.string.search_sfl)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = checked(AdvanceTable.SFU),
|
||||
onClick = { advancedOption.inv(AdvanceTable.SFU) },
|
||||
label = { Text(text = disableFilter + stringResource(id = R.string.search_sfu)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = checked(AdvanceTable.SFT),
|
||||
onClick = { advancedOption.inv(AdvanceTable.SFT) },
|
||||
label = { Text(text = disableFilter + stringResource(id = R.string.search_sft)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,10 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissValue
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberSwipeToDismissState
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -111,7 +111,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
|
||||
var label by Settings::recentDownloadLabel.observed
|
||||
var keyword by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var filterType by rememberSaveable { mutableStateOf(-1) }
|
||||
var searchBarOffsetY by remember { mutableStateOf(0) }
|
||||
var searchBarOffsetY by remember(label) { mutableStateOf(0) }
|
||||
val searchFieldState = rememberTextFieldState()
|
||||
val checkedInfoMap = remember { mutableStateMapOf<Long, DownloadInfo>() }
|
||||
val selectMode by rememberUpdatedState(checkedInfoMap.isNotEmpty())
|
||||
@@ -235,9 +235,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
|
||||
)
|
||||
}
|
||||
items(labelsList, key = { it.label }) { (item) ->
|
||||
val dismissState = rememberSwipeToDismissState(
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = {
|
||||
if (it == SwipeToDismissValue.EndToStart) {
|
||||
if (it == SwipeToDismissBoxValue.EndToStart) {
|
||||
coroutineScope.launch {
|
||||
DownloadManager.deleteLabel(item)
|
||||
label = ""
|
||||
@@ -327,7 +327,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
|
||||
onClick = {
|
||||
expanded = false
|
||||
coroutineScope.launch {
|
||||
filterType = dialogState.showSingleChoice(states, filterType + 1) - 1
|
||||
filterType = dialogState.showSingleChoice(*states, selected = filterType + 1) - 1
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -433,6 +433,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
|
||||
checkedInfoMap[info.gid] = info
|
||||
}
|
||||
} else {
|
||||
coroutineScope.launchIO {
|
||||
EhDB.putHistoryInfo(info.galleryInfo)
|
||||
}
|
||||
context.navToReader(info.galleryInfo)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -122,10 +122,11 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
|
||||
val data = rememberInVM(urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
|
||||
if (urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
|
||||
Pager(PagingConfig(20, jumpThreshold = 40)) {
|
||||
if (keyword.isBlank()) {
|
||||
val keywordNow = urlBuilder.keyword.orEmpty()
|
||||
if (keywordNow.isBlank()) {
|
||||
EhDB.localFavLazyList
|
||||
} else {
|
||||
EhDB.searchLocalFav(keyword)
|
||||
EhDB.searchLocalFav(keywordNow)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -307,6 +308,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
},
|
||||
refreshState = refreshState,
|
||||
scrollToTopOnRefresh = urlBuilder.favCat != FavListUrlBuilder.FAV_CAT_LOCAL,
|
||||
onRefresh = { refresh() },
|
||||
onLoading = { searchBarOffsetY = 0 },
|
||||
)
|
||||
|
||||
@@ -108,7 +108,6 @@ import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.replace
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import rikka.core.res.resolveColor
|
||||
|
||||
@@ -174,6 +173,9 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
|
||||
val url = EhUrl.getGalleryDetailUrl(galleryDetail.gid, galleryDetail.token, 0, showAll)
|
||||
val detail = EhEngine.getGalleryDetail(url)
|
||||
comments = detail.comments
|
||||
|
||||
// Refresh comments of GalleryDetail cache
|
||||
galleryDetail.comments = comments
|
||||
}
|
||||
|
||||
val copyComment = stringResource(R.string.copy_comment_text)
|
||||
@@ -303,10 +305,15 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
|
||||
bottom = paddingValues.calculateBottomPadding() + additionalPadding,
|
||||
),
|
||||
) {
|
||||
items(comments.comments) { item ->
|
||||
items(
|
||||
items = comments.comments,
|
||||
key = { it.id },
|
||||
) { item ->
|
||||
suspend fun voteComment(comment: GalleryComment, isUp: Boolean) {
|
||||
galleryDetail.runSuspendCatching {
|
||||
EhEngine.voteComment(apiUid, apiKey, gid, token, comment.id, if (isUp) 1 else -1)
|
||||
EhEngine.voteComment(apiUid, apiKey, gid, token, comment.id, if (isUp) 1 else -1).also {
|
||||
refreshComment(true)
|
||||
}
|
||||
}.onSuccess { result ->
|
||||
activity.showTip(
|
||||
if (isUp) {
|
||||
@@ -315,13 +322,6 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
|
||||
if (0 != result.vote) R.string.vote_down_successfully else R.string.cancel_vote_down_successfully
|
||||
},
|
||||
)
|
||||
val newComment = comment.copy(
|
||||
score = result.score,
|
||||
voteUpEd = 0 != result.vote && isUp,
|
||||
voteDownEd = 0 != result.vote && !isUp,
|
||||
)
|
||||
val list = comments.comments.replace(comment, newComment)
|
||||
comments = comments.copy(comments = list)
|
||||
}.onFailure {
|
||||
activity.showTip(R.string.vote_failed)
|
||||
}
|
||||
@@ -359,6 +359,7 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
|
||||
}
|
||||
|
||||
GalleryCommentCard(
|
||||
modifier = Modifier.animateItemPlacement(),
|
||||
comment = item,
|
||||
onUserClick = {
|
||||
val lub = ListUrlBuilder(
|
||||
|
||||
@@ -51,7 +51,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.ModalBottomSheetFix
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
@@ -79,8 +79,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import arrow.core.partially1
|
||||
import coil.imageLoader
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import coil3.imageLoader
|
||||
import com.hippo.ehviewer.EhApplication.Companion.galleryDetailCache
|
||||
import com.hippo.ehviewer.EhApplication.Companion.imageCache
|
||||
import com.hippo.ehviewer.EhDB
|
||||
@@ -120,6 +119,7 @@ import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.spider.SpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.Companion.MODE_READ
|
||||
import com.hippo.ehviewer.ui.GalleryInfoBottomSheet
|
||||
import com.hippo.ehviewer.ui.LocalSnackbarHostState
|
||||
import com.hippo.ehviewer.ui.LockDrawer
|
||||
import com.hippo.ehviewer.ui.MainActivity
|
||||
import com.hippo.ehviewer.ui.confirmRemoveDownload
|
||||
@@ -145,7 +145,6 @@ import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton
|
||||
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconToggleButton
|
||||
import com.hippo.ehviewer.ui.tools.GalleryDetailRating
|
||||
import com.hippo.ehviewer.ui.tools.IconFix
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.rememberLambda
|
||||
import com.hippo.ehviewer.util.AppHelper
|
||||
@@ -170,7 +169,6 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import splitties.systemservices.downloadManager
|
||||
import splitties.systemservices.layoutInflater
|
||||
|
||||
@@ -232,15 +230,23 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
val galleryDetailUrl = remember { EhUrl.getGalleryDetailUrl(gid, token, 0, false) }
|
||||
val context = LocalContext.current
|
||||
val activity = remember(context) { context.findActivity<MainActivity>() }
|
||||
val snackbarState = LocalSnackbarHostState.current
|
||||
var showReadAction by rememberSaveable { mutableStateOf(true) }
|
||||
LaunchedEffect(args, galleryInfo) {
|
||||
val page = (args as? TokenArgs)?.page ?: 0
|
||||
val gi = galleryInfo
|
||||
if (page != 0 && gi != null) {
|
||||
Snackbar.make(
|
||||
activity.findViewById(R.id.content),
|
||||
context.getString(R.string.read_from, page),
|
||||
Snackbar.LENGTH_LONG,
|
||||
).setAction(R.string.read) { context.navToReader(gi.findBaseInfo(), page) }.show()
|
||||
if (showReadAction) {
|
||||
val page = (args as? TokenArgs)?.page ?: 0
|
||||
val gi = galleryInfo
|
||||
if (page != 0 && gi != null) {
|
||||
showReadAction = false
|
||||
val result = snackbarState.showSnackbar(
|
||||
context.getString(R.string.read_from, page),
|
||||
context.getString(R.string.read),
|
||||
true,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
context.navToReader(gi.findBaseInfo(), page)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
with(activity) {
|
||||
@@ -587,7 +593,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
}
|
||||
},
|
||||
) {
|
||||
IconFix(
|
||||
Icon(
|
||||
imageVector = getFavoriteIcon(favSlot != NOT_FAVORITED),
|
||||
contentDescription = null,
|
||||
)
|
||||
@@ -673,7 +679,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
FileUtils.sanitizeFilename("$name.torrent"),
|
||||
)
|
||||
r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
r.addRequestHeader("Cookie", EhCookieStore.getCookieHeader(itemUrl.toHttpUrl()))
|
||||
r.addRequestHeader("Cookie", EhCookieStore.getCookieHeader(itemUrl))
|
||||
try {
|
||||
downloadManager.enqueue(r)
|
||||
activity.showTip(R.string.download_torrent_started)
|
||||
@@ -1211,23 +1217,21 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
)
|
||||
},
|
||||
) {
|
||||
Surface {
|
||||
val gi = galleryInfo
|
||||
if (gi != null) {
|
||||
GalleryDetailContent(
|
||||
galleryInfo = gi,
|
||||
contentPadding = it,
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
)
|
||||
} else if (getDetailError.isNotBlank()) {
|
||||
GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" })
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
val gi = galleryInfo
|
||||
if (gi != null) {
|
||||
GalleryDetailContent(
|
||||
galleryInfo = gi,
|
||||
contentPadding = it,
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
)
|
||||
} else if (getDetailError.isNotBlank()) {
|
||||
GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" })
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -36,25 +35,19 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.automirrored.filled.LastPage
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ImageSearch
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material.icons.outlined.Bookmarks
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LeadingIconTab
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.SwipeToDismissValue
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.material3.rememberSwipeToDismissState
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
@@ -109,7 +102,6 @@ import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_NORMAL
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_SUBSCRIPTION
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_TAG
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_TOPLIST
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_UPLOADER
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_WHATS_HOT
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
|
||||
@@ -124,7 +116,6 @@ import com.hippo.ehviewer.ui.destinations.GalleryDetailScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.GalleryListScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.ProgressScreenDestination
|
||||
import com.hippo.ehviewer.ui.doGalleryInfoAction
|
||||
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
|
||||
import com.hippo.ehviewer.ui.main.AdvancedSearchOption
|
||||
import com.hippo.ehviewer.ui.main.FAB_ANIMATE_TIME
|
||||
import com.hippo.ehviewer.ui.main.FabLayout
|
||||
@@ -132,12 +123,10 @@ import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
|
||||
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
|
||||
import com.hippo.ehviewer.ui.main.GalleryList
|
||||
import com.hippo.ehviewer.ui.main.ImageSearch
|
||||
import com.hippo.ehviewer.ui.main.NormalSearch
|
||||
import com.hippo.ehviewer.ui.main.SearchAdvanced
|
||||
import com.hippo.ehviewer.ui.main.SearchFilter
|
||||
import com.hippo.ehviewer.ui.showDatePicker
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.DragHandle
|
||||
import com.hippo.ehviewer.ui.tools.IconFix
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
|
||||
@@ -145,6 +134,7 @@ import com.hippo.ehviewer.ui.tools.draggingHapticFeedback
|
||||
import com.hippo.ehviewer.ui.tools.observed
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.findActivity
|
||||
import com.hippo.ehviewer.util.pickVisualMedia
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
@@ -191,8 +181,6 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
|
||||
var searchBarOffsetY by remember { mutableStateOf(0) }
|
||||
var showSearchLayout by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
var searchNormalMode by rememberSaveable { mutableStateOf(true) }
|
||||
var searchAdvancedMode by rememberSaveable { mutableStateOf(false) }
|
||||
var category by rememberSaveable { mutableIntStateOf(Settings.searchCategory) }
|
||||
var searchMethod by rememberSaveable { mutableIntStateOf(1) }
|
||||
var advancedSearchOption by rememberSaveable { mutableStateOf(AdvancedSearchOption()) }
|
||||
@@ -227,7 +215,6 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
|
||||
) { showSearchLayout = false }
|
||||
val context = LocalContext.current
|
||||
val activity = remember { context.findActivity<MainActivity>() }
|
||||
val windowSizeClass = calculateWindowSizeClass(activity)
|
||||
val density = LocalDensity.current
|
||||
val dialogState = LocalDialogState.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -292,6 +279,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
|
||||
}
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
}.collectAsLazyPagingItems()
|
||||
FavouriteStatusRouter.observe(data)
|
||||
val listMode by Settings.listMode.collectAsState()
|
||||
|
||||
val quickSearchList = remember { mutableStateListOf<QuickSearch>() }
|
||||
@@ -440,9 +428,9 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
|
||||
}
|
||||
items(quickSearchList, key = { it.id!! }) { item ->
|
||||
ReorderableItem(reorderableLazyListState, key = item.id!!) { isDragging ->
|
||||
val dismissState = rememberSwipeToDismissState(
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = {
|
||||
if (it == SwipeToDismissValue.EndToStart) {
|
||||
if (it == SwipeToDismissBoxValue.EndToStart) {
|
||||
quickSearchList.remove(item)
|
||||
coroutineScope.launchIO {
|
||||
EhDB.deleteQuickSearch(item)
|
||||
@@ -534,8 +522,6 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val searchErr1 = stringResource(R.string.search_sp_err1)
|
||||
val searchErr2 = stringResource(R.string.search_sp_err2)
|
||||
val selectImageFirst = stringResource(R.string.select_image_first)
|
||||
SearchBarScreen(
|
||||
title = suitableTitle,
|
||||
@@ -550,45 +536,24 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
|
||||
val newMode = if (oldMode == MODE_SUBSCRIPTION) MODE_SUBSCRIPTION else MODE_NORMAL
|
||||
builder.mode = newMode
|
||||
builder.keyword = query
|
||||
builder.category = category
|
||||
builder.advanceSearch = advancedSearchOption.advanceSearch
|
||||
builder.minRating = advancedSearchOption.minRating
|
||||
builder.pageFrom = advancedSearchOption.fromPage
|
||||
builder.pageTo = advancedSearchOption.toPage
|
||||
} else {
|
||||
if (searchNormalMode) {
|
||||
when (searchMethod) {
|
||||
1 -> builder.mode = MODE_NORMAL
|
||||
2 -> builder.mode = MODE_SUBSCRIPTION
|
||||
3 -> builder.mode = MODE_UPLOADER
|
||||
4 -> builder.mode = MODE_TAG
|
||||
}
|
||||
builder.keyword = query
|
||||
builder.category = category
|
||||
if (searchAdvancedMode) {
|
||||
builder.advanceSearch = advancedSearchOption.advanceSearch
|
||||
builder.minRating = advancedSearchOption.minRating
|
||||
val pageFrom = advancedSearchOption.fromPage
|
||||
val pageTo = advancedSearchOption.toPage
|
||||
if (pageTo != -1 && pageTo < 10) {
|
||||
activity.showTip(searchErr1)
|
||||
return@SearchBarScreen
|
||||
} else if (pageFrom != -1 && pageTo != -1 && pageTo - pageFrom < 20) {
|
||||
activity.showTip(searchErr2)
|
||||
return@SearchBarScreen
|
||||
}
|
||||
builder.pageFrom = pageFrom
|
||||
builder.pageTo = pageTo
|
||||
}
|
||||
} else {
|
||||
builder.mode = MODE_IMAGE_SEARCH
|
||||
if (imagePath.isBlank()) {
|
||||
activity.showTip(selectImageFirst)
|
||||
return@SearchBarScreen
|
||||
}
|
||||
val uri = Uri.parse(imagePath)
|
||||
val temp = AppConfig.createTempFile() ?: return@SearchBarScreen
|
||||
val bitmap = context.decodeBitmap(uri) ?: return@SearchBarScreen
|
||||
temp.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||
builder.imagePath = temp.path
|
||||
builder.isUseSimilarityScan = useSimilarityScan
|
||||
builder.isOnlySearchCovers = searchCoverOnly
|
||||
builder.mode = MODE_IMAGE_SEARCH
|
||||
if (imagePath.isBlank()) {
|
||||
activity.showTip(selectImageFirst)
|
||||
return@SearchBarScreen
|
||||
}
|
||||
val uri = Uri.parse(imagePath)
|
||||
val temp = AppConfig.createTempFile() ?: return@SearchBarScreen
|
||||
val bitmap = context.decodeBitmap(uri) ?: return@SearchBarScreen
|
||||
temp.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||
builder.imagePath = temp.path
|
||||
builder.isUseSimilarityScan = useSimilarityScan
|
||||
builder.isOnlySearchCovers = searchCoverOnly
|
||||
}
|
||||
when (oldMode) {
|
||||
MODE_TOPLIST, MODE_WHATS_HOT -> {
|
||||
@@ -629,6 +594,17 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
|
||||
)
|
||||
}
|
||||
},
|
||||
filter = {
|
||||
SearchFilter(
|
||||
category = category,
|
||||
onCategoryChanged = {
|
||||
Settings.searchCategory = it
|
||||
category = it
|
||||
},
|
||||
advancedOption = advancedSearchOption,
|
||||
onAdvancedOptionChanged = { advancedSearchOption = it },
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
|
||||
@@ -648,113 +624,35 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
|
||||
alpha = 1 - animatedSearchLayout
|
||||
},
|
||||
) {
|
||||
AnimatedVisibility(visible = searchNormalMode) {
|
||||
ElevatedCard(modifier = Modifier.padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
|
||||
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
|
||||
).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.search_normal),
|
||||
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
NormalSearch(
|
||||
category = category,
|
||||
onCategoryChanged = {
|
||||
Settings.searchCategory = it
|
||||
category = it
|
||||
},
|
||||
searchMode = searchMethod,
|
||||
onSearchModeChanged = { searchMethod = it },
|
||||
isAdvanced = searchAdvancedMode,
|
||||
onAdvancedChanged = { searchAdvancedMode = it },
|
||||
showInfo = { BaseDialogBuilder(context).setMessage(R.string.search_tip).show() },
|
||||
maxItemsInEachRow = when (windowSizeClass.widthSizeClass) {
|
||||
WindowWidthSizeClass.Compact -> 2
|
||||
WindowWidthSizeClass.Medium -> 3
|
||||
else -> 5
|
||||
},
|
||||
)
|
||||
}
|
||||
ElevatedCard(modifier = Modifier.padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
|
||||
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
|
||||
).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.search_image),
|
||||
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
ImageSearch(
|
||||
imagePath = imagePath,
|
||||
onSelectImage = {
|
||||
coroutineScope.launch {
|
||||
val image = context.pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
if (image != null) imagePath = image.toString()
|
||||
}
|
||||
},
|
||||
uss = useSimilarityScan,
|
||||
onUssChecked = { useSimilarityScan = it },
|
||||
osc = searchCoverOnly,
|
||||
onOscChecked = { searchCoverOnly = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = searchNormalMode && searchAdvancedMode) {
|
||||
ElevatedCard(modifier = Modifier.padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
|
||||
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
|
||||
).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.search_advance),
|
||||
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
SearchAdvanced(
|
||||
state = advancedSearchOption,
|
||||
onStateChanged = { advancedSearchOption = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = !searchNormalMode) {
|
||||
ElevatedCard(modifier = Modifier.padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
|
||||
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
|
||||
).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.search_image),
|
||||
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
ImageSearch(
|
||||
imagePath = imagePath,
|
||||
onSelectImage = {
|
||||
coroutineScope.launch {
|
||||
val image = context.pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
if (image != null) imagePath = image.toString()
|
||||
}
|
||||
},
|
||||
uss = useSimilarityScan,
|
||||
onUssChecked = { useSimilarityScan = it },
|
||||
osc = searchCoverOnly,
|
||||
onOscChecked = { searchCoverOnly = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PrimaryTabRow(
|
||||
selectedTabIndex = if (searchNormalMode) 0 else 1,
|
||||
divider = {},
|
||||
) {
|
||||
LeadingIconTab(
|
||||
selected = searchNormalMode,
|
||||
onClick = { searchNormalMode = true },
|
||||
text = { Text(text = stringResource(id = R.string.keyword_search)) },
|
||||
icon = { IconFix(imageVector = Icons.Default.Translate, contentDescription = null) },
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
LeadingIconTab(
|
||||
selected = !searchNormalMode,
|
||||
onClick = { searchNormalMode = false },
|
||||
text = { Text(text = stringResource(id = R.string.search_image)) },
|
||||
icon = { IconFix(imageVector = Icons.Default.ImageSearch, contentDescription = null) },
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val height by collectListThumbSizeAsState()
|
||||
|
||||
@@ -39,7 +39,7 @@ import androidx.room.paging.util.getClippedRefreshKey
|
||||
import androidx.room.paging.util.getLimit
|
||||
import androidx.room.paging.util.getOffset
|
||||
import arrow.fx.coroutines.parMap
|
||||
import coil.imageLoader
|
||||
import coil3.imageLoader
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
|
||||
@@ -14,11 +14,11 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SwipeToDismissValue
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberSwipeToDismissState
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -49,6 +49,7 @@ import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
@@ -67,6 +68,7 @@ fun HistoryScreen(navigator: DestinationsNavigator) {
|
||||
EhDB.historyLazyList
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
}.collectAsLazyPagingItems()
|
||||
FavouriteStatusRouter.observe(historyData)
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
@@ -109,9 +111,9 @@ fun HistoryScreen(navigator: DestinationsNavigator) {
|
||||
) { index ->
|
||||
val info = historyData[index]
|
||||
if (info != null) {
|
||||
val dismissState = rememberSwipeToDismissState(
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = {
|
||||
if (it == SwipeToDismissValue.EndToStart) {
|
||||
if (it == SwipeToDismissBoxValue.EndToStart) {
|
||||
coroutineScope.launchIO {
|
||||
EhDB.deleteHistoryInfo(info)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -55,7 +56,10 @@ fun ProgressScreen(gid: Long, token: String, page: Int, navigator: DestinationsN
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (error.isNotBlank()) {
|
||||
Column(
|
||||
modifier = Modifier.clickable { error = "" },
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -15,10 +16,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text2.input.TextFieldState
|
||||
@@ -44,10 +47,12 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -56,11 +61,12 @@ import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.core.view.SoftwareKeyboardControllerCompat
|
||||
import com.hippo.ehviewer.EhApplication.Companion.searchDatabase
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -109,29 +115,16 @@ fun SearchBarScreen(
|
||||
suggestionProvider: SuggestionProvider? = null,
|
||||
searchBarOffsetY: () -> Int,
|
||||
trailingIcon: @Composable () -> Unit,
|
||||
filter: @Composable (() -> Unit)? = null,
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
) {
|
||||
var mSuggestionList by remember { mutableStateOf(emptyList<Suggestion>()) }
|
||||
val mSearchDatabase = searchDatabase.searchDao()
|
||||
var active by remember { mutableStateOf(false) }
|
||||
var active by rememberSaveable { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope { Dispatchers.IO }
|
||||
val context = LocalContext.current
|
||||
val dialogState = LocalDialogState.current
|
||||
|
||||
// Workaround for BTF2 cursor not showing
|
||||
// We can't use an always focused WindowInfo because callbacks won't be called once
|
||||
// the window regained focus after losing it (e.g. showing a dialog on top of it)
|
||||
// https://issuetracker.google.com/issues/307323842
|
||||
val windowInfo = LocalWindowInfo.current
|
||||
remember {
|
||||
val clazz = Class.forName("androidx.compose.ui.platform.WindowInfoImpl")
|
||||
clazz.cast(windowInfo).let {
|
||||
clazz.getDeclaredMethod("setWindowFocused", Boolean::class.javaPrimitiveType).apply {
|
||||
invoke(it, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TagSuggestion(
|
||||
override val hint: String?,
|
||||
override val keyword: String,
|
||||
@@ -239,6 +232,15 @@ fun SearchBarScreen(
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
// Workaround IME not hiding after performing search
|
||||
// https://github.com/FooIbar/EhViewer/pull/412
|
||||
val view = LocalView.current
|
||||
val keyboardManager = remember { SoftwareKeyboardControllerCompat(view) }
|
||||
SideEffect {
|
||||
if (!active) keyboardManager.hide()
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -271,7 +273,8 @@ fun SearchBarScreen(
|
||||
)
|
||||
}
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter) then scrollAwayModifier,
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter) then scrollAwayModifier
|
||||
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)),
|
||||
state = searchFieldState,
|
||||
onSearch = {
|
||||
hideSearchView()
|
||||
@@ -314,8 +317,13 @@ fun SearchBarScreen(
|
||||
}
|
||||
},
|
||||
) {
|
||||
LazyColumn(contentPadding = WindowInsets.navigationBars.union(WindowInsets.ime).asPaddingValues()) {
|
||||
items(mSuggestionList) {
|
||||
filter?.invoke()
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = WindowInsets.navigationBars.union(WindowInsets.ime)
|
||||
.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
) {
|
||||
items(mSuggestionList, key = { it.keyword }) {
|
||||
ListItem(
|
||||
headlineContent = { Text(text = it.keyword) },
|
||||
supportingContent = it.hint.ifNotNullThen { Text(text = it.hint!!) },
|
||||
@@ -334,7 +342,7 @@ fun SearchBarScreen(
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable { it.onClick() },
|
||||
modifier = Modifier.clickable { it.onClick() }.animateItemPlacement(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import android.net.Uri
|
||||
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
|
||||
import android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -15,6 +17,9 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
@@ -25,6 +30,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -42,6 +48,7 @@ import com.hippo.ehviewer.ui.tools.observed
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.Crash
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.isCronetAvailable
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import java.io.File
|
||||
@@ -132,11 +139,38 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
entryValueRes = R.array.app_language_entry_values,
|
||||
value = Settings::language,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.settings_advanced_cronet_integration),
|
||||
summary = stringResource(id = R.string.settings_advanced_cronet_integration_summary),
|
||||
value = Settings::enableCronet,
|
||||
)
|
||||
var enableCronet by Settings::enableCronet.observed
|
||||
Preference(
|
||||
title = stringResource(id = R.string.settings_advanced_http_engine),
|
||||
summary = if (enableCronet) "Cronet" else "Apache 5",
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
dialogState.awaitPermissionOrCancel(
|
||||
title = R.string.settings_advanced_http_engine,
|
||||
showCancelButton = false,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.align(Alignment.Center)) {
|
||||
SegmentedButton(
|
||||
selected = !enableCronet,
|
||||
onClick = { enableCronet = false },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
|
||||
) {
|
||||
Text("Apache 5")
|
||||
}
|
||||
SegmentedButton(
|
||||
selected = enableCronet,
|
||||
onClick = { enableCronet = true },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
|
||||
enabled = isCronetAvailable,
|
||||
) {
|
||||
Text("Cronet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.preload_thumb_aggressively),
|
||||
value = Settings::preloadThumbAggressively,
|
||||
|
||||
@@ -54,11 +54,11 @@ import com.hippo.ehviewer.util.copyTextToClipboard
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import io.ktor.http.Url
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
@@ -91,8 +91,8 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
title = stringResource(id = R.string.account_name),
|
||||
summary = Settings.displayName ?: touristMode,
|
||||
) {
|
||||
val eCookies = EhCookieStore.loadForRequest(EhUrl.HOST_E.toHttpUrl())
|
||||
val exCookies = EhCookieStore.loadForRequest(EhUrl.HOST_EX.toHttpUrl())
|
||||
val eCookies = EhCookieStore.load(Url(EhUrl.HOST_E))
|
||||
val exCookies = EhCookieStore.load(Url(EhUrl.HOST_EX))
|
||||
var ipbMemberId: String? = null
|
||||
var ipbPassHash: String? = null
|
||||
var igneous: String? = null
|
||||
@@ -218,7 +218,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.black_dark_theme),
|
||||
value = Settings::blackDarkTheme,
|
||||
value = Settings.blackDarkTheme::value,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.harmonize_category_color),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.hippo.ehviewer.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
|
||||
@Composable
|
||||
fun EhTheme(content: @Composable () -> Unit) {
|
||||
val useDarkTheme = isSystemInDarkTheme()
|
||||
val amoled by Settings.blackDarkTheme.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val colors = if (useDarkTheme) {
|
||||
if (isAtLeastS) {
|
||||
dynamicDarkColorScheme(context)
|
||||
} else {
|
||||
darkColorScheme()
|
||||
}.let {
|
||||
if (amoled) {
|
||||
it.copy(
|
||||
surface = Color.Black,
|
||||
onSurface = Color.White,
|
||||
background = Color.Black,
|
||||
onBackground = Color.White,
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isAtLeastS) {
|
||||
dynamicLightColorScheme(context)
|
||||
} else {
|
||||
lightColorScheme()
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
object CropDefaults {
|
||||
private const val CROP_MIN_ASPECT = 0.5F
|
||||
private const val CROP_MAX_ASPECT = 0.8f
|
||||
private val CROP_GOOD_RANGE = CROP_MIN_ASPECT..CROP_MAX_ASPECT
|
||||
fun shouldCrop(width: Int, height: Int) = width.toFloat() / height in CROP_GOOD_RANGE
|
||||
}
|
||||
import coil3.Image
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
|
||||
private const val CROP_MIN_ASPECT = 0.5F
|
||||
private const val CROP_MAX_ASPECT = 0.8f
|
||||
private val CROP_GOOD_RANGE = CROP_MIN_ASPECT..CROP_MAX_ASPECT
|
||||
private fun shouldCrop(width: Int, height: Int) = (width.toFloat() / height) in CROP_GOOD_RANGE
|
||||
|
||||
val NormalGalleryPreview.shouldCrop: Boolean
|
||||
get() = shouldCrop(clipWidth, clipHeight)
|
||||
|
||||
val Image.shouldCrop: Boolean
|
||||
get() = shouldCrop(width, height)
|
||||
@@ -18,8 +18,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
@@ -107,9 +109,16 @@ class DialogState {
|
||||
content = { block(realContinuation) }
|
||||
}
|
||||
|
||||
suspend fun <R> awaitResult(initial: R, @StringRes title: Int? = null, block: @Composable DialogScope<R>.() -> Unit): R {
|
||||
suspend fun <R> awaitResult(
|
||||
initial: R,
|
||||
@StringRes title: Int? = null,
|
||||
invalidator: (suspend (R) -> String?)? = null,
|
||||
block: @Composable DialogScope<R>.(String?) -> Unit,
|
||||
): R {
|
||||
return dialog { cont ->
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val state = remember(cont) { mutableStateOf(initial) }
|
||||
var error by remember(cont) { mutableStateOf<String?>(null) }
|
||||
val impl = remember(cont) {
|
||||
object : DialogScope<R> {
|
||||
override var expectedValue by state
|
||||
@@ -118,12 +127,21 @@ class DialogState {
|
||||
AlertDialog(
|
||||
onDismissRequest = { cont.cancel() },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { cont.resume(state.value) }) {
|
||||
TextButton(onClick = {
|
||||
if (invalidator == null) {
|
||||
cont.resume(state.value)
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
error = invalidator.invoke(state.value)
|
||||
error ?: cont.resume(state.value)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text(text = stringResource(id = android.R.string.ok))
|
||||
}
|
||||
},
|
||||
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
|
||||
text = { block(impl) },
|
||||
text = { block(impl, error) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -389,10 +407,10 @@ class DialogState {
|
||||
}
|
||||
|
||||
suspend fun showSingleChoice(
|
||||
items: Array<String>,
|
||||
vararg items: String,
|
||||
selected: Int,
|
||||
): Int = showNoButton {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) {
|
||||
items.forEachIndexed { index, text ->
|
||||
Row(
|
||||
modifier = Modifier.clickable { dismissWith(index) }.fillMaxWidth()
|
||||
|
||||
@@ -20,6 +20,43 @@ import kotlin.math.roundToInt
|
||||
|
||||
private val colorYellow800 = Color(0xfff9a825)
|
||||
|
||||
@Composable
|
||||
fun MetaRatingWidgetReuse(rating: Float, ratingSize: Dp, ratingInterval: Dp, modifier: Modifier = Modifier) {
|
||||
val r = (rating * 2).roundToInt().coerceIn(0, 10)
|
||||
val fullStar = r / 2
|
||||
val halfStar = r % 2
|
||||
val outlineStar = 5 - fullStar - halfStar
|
||||
Row(modifier = modifier) {
|
||||
repeat(fullStar) {
|
||||
IconCached(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ratingSize),
|
||||
tint = colorYellow800,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ratingInterval))
|
||||
}
|
||||
repeat(halfStar) {
|
||||
IconCached(
|
||||
imageVector = Icons.AutoMirrored.Default.StarHalf,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ratingSize),
|
||||
tint = colorYellow800,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ratingInterval))
|
||||
}
|
||||
repeat(outlineStar) {
|
||||
IconCached(
|
||||
imageVector = Icons.Default.StarOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ratingSize),
|
||||
tint = colorYellow800,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ratingInterval))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MetaRatingWidget(rating: Float, ratingSize: Dp, ratingInterval: Dp, modifier: Modifier = Modifier) {
|
||||
val r = (rating * 2).roundToInt().coerceIn(0, 10)
|
||||
@@ -59,7 +96,7 @@ fun MetaRatingWidget(rating: Float, ratingSize: Dp, ratingInterval: Dp, modifier
|
||||
|
||||
@Composable
|
||||
fun GalleryListCardRating(rating: Float, modifier: Modifier = Modifier) {
|
||||
MetaRatingWidget(
|
||||
MetaRatingWidgetReuse(
|
||||
rating = rating,
|
||||
ratingSize = dimensionResource(id = R.dimen.rating_size),
|
||||
ratingInterval = dimensionResource(id = R.dimen.rating_interval),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.currentCompositeKeyHash
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -13,9 +16,9 @@ class StateMapViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T : Any> rememberInVM(
|
||||
inline fun <T : Any> rememberInVM(
|
||||
vararg inputs: Any?,
|
||||
init: ViewModel.() -> T,
|
||||
crossinline init: @DisallowComposableCalls ViewModel.() -> T,
|
||||
): T {
|
||||
val vm: StateMapViewModel = viewModel()
|
||||
val key = currentCompositeKeyHash
|
||||
@@ -32,3 +35,8 @@ fun <T : Any> rememberInVM(
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> rememberUpdatedStateInVM(newValue: T): State<T> = rememberInVM {
|
||||
mutableStateOf(newValue)
|
||||
}.apply { value = newValue }
|
||||
@@ -2,7 +2,7 @@ package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissState
|
||||
import androidx.compose.material3.SwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -11,7 +11,7 @@ import com.hippo.ehviewer.Settings
|
||||
|
||||
@Composable
|
||||
fun SwipeToDismissBox2(
|
||||
state: SwipeToDismissState,
|
||||
state: SwipeToDismissBoxState,
|
||||
backgroundContent: @Composable RowScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
|
||||
@@ -2,67 +2,50 @@ package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Applier
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.ReusableComposition
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCompositionContext
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.VectorPainter
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
|
||||
private inline fun <K, V> MutableMap<K, V>.computeIfAbsentInline(key: K, compute: () -> V) = get(key) ?: compute().also { put(key, it) }
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
private fun rememberNeverRecomposeVectorPainter(image: ImageVector): VectorPainter {
|
||||
val context = rememberCompositionContext()
|
||||
var b by remember(image) { mutableStateOf<VectorPainter?>(null) }
|
||||
val composition = remember(image) {
|
||||
val composition = ReusableComposition(
|
||||
object : Applier<Unit> {
|
||||
override val current = Unit
|
||||
override fun clear() = Unit
|
||||
override fun move(from: Int, to: Int, count: Int) = Unit
|
||||
override fun remove(index: Int, count: Int) = Unit
|
||||
override fun up() = Unit
|
||||
override fun insertTopDown(index: Int, instance: Unit) = Unit
|
||||
override fun insertBottomUp(index: Int, instance: Unit) = Unit
|
||||
override fun down(node: Unit) = Unit
|
||||
},
|
||||
context,
|
||||
)
|
||||
composition.apply {
|
||||
setContent {
|
||||
b = rememberVectorPainter(image = image)
|
||||
composition.deactivate()
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(composition) {
|
||||
onDispose {
|
||||
composition.dispose()
|
||||
}
|
||||
}
|
||||
return b!!
|
||||
private fun rememberCachedVectorPainter(image: ImageVector): VectorPainter {
|
||||
val cache = LocalVectorPainterCache.current
|
||||
return cache.computeIfAbsentInline(image) { rememberVectorPainter(image = image) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconFix(
|
||||
fun IconCached(
|
||||
imageVector: ImageVector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = LocalContentColor.current,
|
||||
) {
|
||||
Icon(
|
||||
painter = rememberNeverRecomposeVectorPainter(imageVector),
|
||||
painter = rememberCachedVectorPainter(imageVector),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProvideVectorPainterCache(content: @Composable () -> Unit) {
|
||||
val cache = remember { mutableMapOf<ImageVector, VectorPainter>() }
|
||||
CompositionLocalProvider(
|
||||
LocalVectorPainterCache provides cache,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
typealias VectorPainterCache = MutableMap<ImageVector, VectorPainter>
|
||||
|
||||
val LocalVectorPainterCache = compositionLocalOf<VectorPainterCache> { error("CompositionLocal LocalSideSheetState not present!") }
|
||||
@@ -1,18 +1,23 @@
|
||||
package com.hippo.ehviewer.updater
|
||||
|
||||
import com.hippo.ehviewer.BuildConfig
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.execute
|
||||
import com.hippo.ehviewer.client.executeAndParseAs
|
||||
import com.hippo.ehviewer.util.copyTo
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.utils.io.jvm.javaio.toInputStream
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.zip.ZipInputStream
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okhttp3.Request
|
||||
import okio.sink
|
||||
import org.json.JSONObject
|
||||
import tachiyomi.data.release.GithubArtifacts
|
||||
import tachiyomi.data.release.GithubCommitComparison
|
||||
@@ -24,35 +29,33 @@ private const val LATEST_RELEASE_URL = "$API_URL/releases/latest"
|
||||
|
||||
object AppUpdater {
|
||||
suspend fun checkForUpdate(forceCheck: Boolean = false): Release? {
|
||||
val now = Instant.now()
|
||||
val lastChecked = Instant.ofEpochSecond(Settings.lastUpdateDay)
|
||||
val now = Clock.System.now()
|
||||
val last = Instant.fromEpochSeconds(Settings.lastUpdateTime)
|
||||
val interval = Settings.updateIntervalDays
|
||||
if (forceCheck || interval != 0 && now.isAfter(lastChecked.plus(interval.toLong(), ChronoUnit.DAYS))) {
|
||||
Settings.lastUpdateDay = now.epochSecond
|
||||
if (forceCheck || interval != 0 && now > last + interval.days) {
|
||||
Settings.lastUpdateTime = now.epochSeconds
|
||||
if (Settings.useCIUpdateChannel) {
|
||||
val curSha = BuildConfig.COMMIT_SHA
|
||||
val branch = ghRequest(API_URL).execute {
|
||||
JSONObject(body.string()).getString("default_branch")
|
||||
}
|
||||
val branch = ghStatement(API_URL).execute { JSONObject(it.bodyAsText()).getString("default_branch") }
|
||||
val workflowRunsUrl = "$API_URL/actions/workflows/ci.yml/runs?branch=$branch&event=push&status=success&per_page=1"
|
||||
val workflowRun = ghRequest(workflowRunsUrl).executeAndParseAs<GithubWorkflowRuns>().workflowRuns[0]
|
||||
val workflowRun = ghStatement(workflowRunsUrl).executeAndParseAs<GithubWorkflowRuns>().workflowRuns[0]
|
||||
val shortSha = workflowRun.headSha.take(7)
|
||||
if (shortSha != curSha) {
|
||||
val artifacts = ghRequest(workflowRun.artifactsUrl).executeAndParseAs<GithubArtifacts>()
|
||||
val artifacts = ghStatement(workflowRun.artifactsUrl).executeAndParseAs<GithubArtifacts>()
|
||||
val archiveUrl = artifacts.getDownloadLink()
|
||||
val changelog = runSuspendCatching {
|
||||
val commitComparisonUrl = "$API_URL/compare/$curSha...$shortSha"
|
||||
val result = ghRequest(commitComparisonUrl).executeAndParseAs<GithubCommitComparison>()
|
||||
val result = ghStatement(commitComparisonUrl).executeAndParseAs<GithubCommitComparison>()
|
||||
// TODO: Prettier format, Markdown?
|
||||
result.commits.joinToString("\n") { commit ->
|
||||
"${commit.commit.message.takeWhile { it != '\n' }} (@${commit.author.name})"
|
||||
"${commit.commit.message.takeWhile { it != '\n' }} (@${commit.commit.author.name})"
|
||||
}
|
||||
}.getOrDefault(workflowRun.title)
|
||||
return Release(shortSha, changelog, archiveUrl)
|
||||
}
|
||||
} else {
|
||||
val curVersion = BuildConfig.VERSION_NAME.substringBefore('-')
|
||||
val release = ghRequest(LATEST_RELEASE_URL).executeAndParseAs<GithubRelease>()
|
||||
val release = ghStatement(LATEST_RELEASE_URL).executeAndParseAs<GithubRelease>()
|
||||
val latestVersion = release.version
|
||||
val description = release.info
|
||||
val downloadUrl = release.getDownloadLink()
|
||||
@@ -65,29 +68,29 @@ object AppUpdater {
|
||||
}
|
||||
|
||||
suspend fun downloadUpdate(url: String, file: File) =
|
||||
ghRequest(url).execute {
|
||||
ghStatement(url).execute { response ->
|
||||
if (url.endsWith("zip")) {
|
||||
ZipInputStream(body.byteStream()).use { zip ->
|
||||
zip.nextEntry
|
||||
file.outputStream().use {
|
||||
zip.copyTo(it)
|
||||
response.bodyAsChannel().toInputStream().use { stream ->
|
||||
ZipInputStream(stream).use { zip ->
|
||||
zip.nextEntry
|
||||
file.outputStream().use {
|
||||
zip.copyTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
file.sink().use {
|
||||
body.source().readAll(it)
|
||||
}
|
||||
response.bodyAsChannel().copyTo(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private inline fun ghRequest(url: String, builder: Request.Builder.() -> Unit = {}) = Request.Builder().url(url).apply {
|
||||
private suspend inline fun ghStatement(url: String) = ktorClient.prepareGet(url) {
|
||||
val token = "github_" + "pat_11AXZS" + "T4A0k3TArCGakP3t_7DzUE5S" + "mr1zw8rmmzVtCeRq62" + "A4qkuDMw6YQm5ZUtHSLZ2MLI3J4VSifLXZ"
|
||||
val user = "nullArrayList"
|
||||
val base64 = Base64.encode("$user:$token".toByteArray())
|
||||
addHeader("Authorization", "Basic $base64")
|
||||
}.apply(builder).build()
|
||||
header("Authorization", "Basic $base64")
|
||||
}
|
||||
|
||||
data class Release(
|
||||
val version: String,
|
||||
|
||||
@@ -59,14 +59,5 @@ object AppConfig {
|
||||
return FileUtils.createTempFile(tempDir, null)
|
||||
}
|
||||
|
||||
fun saveParseErrorBody(e: Throwable, body: String) {
|
||||
val dir = externalParseErrorDir ?: return
|
||||
File(dir, ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".txt").writer().use {
|
||||
it.write(e.message)
|
||||
it.appendLine()
|
||||
it.append(body)
|
||||
}
|
||||
}
|
||||
|
||||
fun getFilesDir(name: String) = File(appCtx.filesDir, name).takeIf { it.ensureDirectory() }
|
||||
}
|
||||
@@ -19,9 +19,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.ui.tools.rememberUpdatedStateInVM
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -51,4 +56,18 @@ object FavouriteStatusRouter {
|
||||
inline fun <R> collectAsState(initial: GalleryInfo, crossinline transform: @DisallowComposableCalls (Int) -> R) = remember {
|
||||
globalFlow.transform { (gid, slot) -> if (initial.gid == gid) emit(transform(slot)) }
|
||||
}.collectAsState(transform(initial.favoriteSlot))
|
||||
|
||||
@Composable
|
||||
fun observe(list: LazyPagingItems<out GalleryInfo>) {
|
||||
val realList by rememberUpdatedStateInVM(newValue = list.itemSnapshotList.items)
|
||||
rememberInVM {
|
||||
viewModelScope.launch {
|
||||
globalFlow.collect { (gid, newSlot) ->
|
||||
realList.forEach { info ->
|
||||
if (info.gid == gid) info.favoriteSlot = newSlot
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,10 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.jvm.nio.copyTo
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.util.Locale
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.pow
|
||||
@@ -153,3 +156,9 @@ object FileUtils {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ByteReadChannel.copyTo(file: File) {
|
||||
RandomAccessFile(file, "rw").use {
|
||||
copyTo(it.channel)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
import java.io.File
|
||||
import okhttp3.ResponseBody
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
|
||||
fun ResponseBody.copyToFile(file: File) {
|
||||
file.outputStream().use { os ->
|
||||
// Prior to the adoption of OpenJDK, transferFrom will call ByteBuffer.allocate((int) count)
|
||||
if (isAtLeastN) {
|
||||
os.channel.transferFrom(source(), 0, Long.MAX_VALUE)
|
||||
} else {
|
||||
os.sink().buffer().use { it.writeAll(source()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.exception.CloudflareBypassException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class CloudflareInterceptor(context: Context) : WebViewInterceptor(context) {
|
||||
private val executor = ContextCompat.getMainExecutor(context)
|
||||
|
||||
override fun shouldIntercept(response: Response): Boolean {
|
||||
return response.header(HEADER_NAME) == HEADER_VALUE
|
||||
}
|
||||
|
||||
override fun intercept(
|
||||
chain: Interceptor.Chain,
|
||||
request: Request,
|
||||
response: Response,
|
||||
): Response {
|
||||
response.close()
|
||||
EhCookieStore.deleteCookie(request.url, COOKIE_NAME)
|
||||
resolveWithWebView(request)
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun resolveWithWebView(originalRequest: Request) {
|
||||
// We need to lock this thread until the WebView finds the challenge solution url, because
|
||||
// OkHttp doesn't support asynchronous interceptors.
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
var webview: WebView? = null
|
||||
|
||||
var challengeFound = false
|
||||
var cloudflareBypassed = false
|
||||
|
||||
val origRequestUrl = originalRequest.url.toString()
|
||||
val headers = parseHeaders(originalRequest.headers)
|
||||
|
||||
executor.execute {
|
||||
webview = createWebView()
|
||||
|
||||
webview?.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
fun isCloudFlareBypassed(): Boolean {
|
||||
return EhCookieStore.get(origRequestUrl.toHttpUrl())
|
||||
.any { it.name == COOKIE_NAME }
|
||||
}
|
||||
|
||||
if (isCloudFlareBypassed()) {
|
||||
cloudflareBypassed = true
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
if (url == origRequestUrl && !challengeFound) {
|
||||
// The first request didn't return the challenge, abort.
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedHttpError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
errorResponse: WebResourceResponse,
|
||||
) {
|
||||
if (request.isForMainFrame) {
|
||||
if (errorResponse.responseHeaders[HEADER_NAME] == HEADER_VALUE) {
|
||||
// Found the Cloudflare challenge page.
|
||||
challengeFound = true
|
||||
} else {
|
||||
// Unlock thread, the challenge wasn't found.
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webview?.loadUrl(origRequestUrl, headers)
|
||||
}
|
||||
|
||||
latch.awaitFor30Seconds()
|
||||
|
||||
executor.execute {
|
||||
webview?.run {
|
||||
stopLoading()
|
||||
destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Throw exception if we failed to bypass Cloudflare
|
||||
if (!cloudflareBypassed) {
|
||||
throw CloudflareBypassException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val COOKIE_NAME = "cf_clearance"
|
||||
private const val HEADER_NAME = "cf-mitigated"
|
||||
private const val HEADER_VALUE = "challenge"
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import java.io.IOException
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Catches any uncaught exceptions from later in the chain and rethrows as a non-fatal
|
||||
* IOException to avoid catastrophic failure.
|
||||
*
|
||||
* This should be the first interceptor in the client.
|
||||
*
|
||||
* See [Interceptor](https://square.github.io/okhttp/5.x/okhttp/okhttp3/-interceptor/index.html)
|
||||
*/
|
||||
class UncaughtExceptionInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return try {
|
||||
chain.proceed(chain.request())
|
||||
} catch (e: Exception) {
|
||||
if (e is IOException) {
|
||||
throw e
|
||||
} else {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import com.hippo.ehviewer.util.setDefaultSettings
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
abstract class WebViewInterceptor(private val context: Context) : Interceptor {
|
||||
/**
|
||||
* When this is called, it initializes the WebView if it wasn't already. We use this to avoid
|
||||
* blocking the main thread too much. If used too often we could consider moving it to the
|
||||
* Application class.
|
||||
*/
|
||||
private val initWebView by lazy {
|
||||
try {
|
||||
WebSettings.getDefaultUserAgent(context)
|
||||
} catch (_: Exception) {
|
||||
// Avoid some crashes like when Chrome/WebView is being updated.
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun shouldIntercept(response: Response): Boolean
|
||||
|
||||
abstract fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
if (!shouldIntercept(response)) {
|
||||
return response
|
||||
}
|
||||
|
||||
initWebView
|
||||
|
||||
return intercept(chain, request, response)
|
||||
}
|
||||
|
||||
fun parseHeaders(headers: Headers): Map<String, String> {
|
||||
return headers
|
||||
// Keeping unsafe header makes webview throw [net::ERR_INVALID_ARGUMENT]
|
||||
.filter { (name, value) ->
|
||||
isRequestHeaderSafe(name, value)
|
||||
}
|
||||
.groupBy(keySelector = { (name, _) -> name }) { (_, value) -> value }
|
||||
.mapValues { it.value.getOrNull(0).orEmpty() }
|
||||
}
|
||||
|
||||
fun CountDownLatch.awaitFor30Seconds() {
|
||||
await(30, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
fun createWebView(): WebView {
|
||||
return WebView(context).apply {
|
||||
setDefaultSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Based on [IsRequestHeaderSafe] in https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/cpp/header_util.cc
|
||||
private fun isRequestHeaderSafe(_name: String, _value: String): Boolean {
|
||||
val name = _name.lowercase(Locale.ENGLISH)
|
||||
val value = _value.lowercase(Locale.ENGLISH)
|
||||
if (name in unsafeHeaderNames || name.startsWith("proxy-")) return false
|
||||
return !(name == "connection" && value == "upgrade")
|
||||
}
|
||||
|
||||
private val unsafeHeaderNames = arrayOf(
|
||||
"content-length",
|
||||
"host",
|
||||
"trailer",
|
||||
"te",
|
||||
"upgrade",
|
||||
"cookie2",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"set-cookie",
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
|
||||
@Composable
|
||||
fun BottomReaderBar(onClickSettings: () -> Unit) {
|
||||
// Match with toolbar background color set in ReaderActivity
|
||||
val backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp).copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f)
|
||||
|
||||
val readingMode by Settings.readingMode.collectAsState { ReadingModeType.fromPreference(it) }
|
||||
var readingModeExpanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
DropdownMenu(
|
||||
readingModeExpanded,
|
||||
onDismissRequest = { readingModeExpanded = false },
|
||||
) {
|
||||
ReadingModeType.entries.forEach {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(it.stringRes)) },
|
||||
onClick = {
|
||||
Settings.readingMode.value = it.flagValue
|
||||
readingModeExpanded = false
|
||||
},
|
||||
modifier = Modifier.width(192.dp),
|
||||
leadingIcon = {
|
||||
if (readingMode == it) {
|
||||
Icon(imageVector = Icons.Default.Check, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val orientationMode by Settings.orientationMode.collectAsState { OrientationType.fromPreference(it) }
|
||||
var orientationModeExpanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
DropdownMenu(
|
||||
orientationModeExpanded,
|
||||
onDismissRequest = { orientationModeExpanded = false },
|
||||
offset = DpOffset(320.dp, 0.dp),
|
||||
) {
|
||||
OrientationType.entries.forEach {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(it.stringRes)) },
|
||||
onClick = {
|
||||
Settings.orientationMode.value = it.flagValue
|
||||
orientationModeExpanded = false
|
||||
},
|
||||
modifier = Modifier.width(192.dp),
|
||||
leadingIcon = {
|
||||
if (orientationMode == it) {
|
||||
Icon(imageVector = Icons.Default.Check, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(backgroundColor).padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = { readingModeExpanded = true }) {
|
||||
Icon(
|
||||
painter = painterResource(readingMode.iconRes),
|
||||
contentDescription = stringResource(R.string.viewer),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { orientationModeExpanded = true }) {
|
||||
Icon(
|
||||
painter = painterResource(orientationMode.iconRes),
|
||||
contentDescription = stringResource(R.string.pref_rotation_type),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClickSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Settings,
|
||||
contentDescription = stringResource(R.string.action_settings),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,14 +48,15 @@ fun ChapterNavigator(
|
||||
val maxTickCount = configuration.screenWidthDp / 6
|
||||
val showTicks = totalPages < maxTickCount
|
||||
|
||||
// Match with toolbar background color set in ReaderActivity
|
||||
val backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp).copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f)
|
||||
|
||||
// We explicitly handle direction based on the reader viewer rather than the system direction
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = horizontalPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Match with toolbar background color set in ReaderActivity
|
||||
val backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp).copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f)
|
||||
if (totalPages > 1) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
|
||||
Row(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun PageIndicatorText(
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
) {
|
||||
if (currentPage <= 0 || totalPages <= 0) return
|
||||
|
||||
val text = "$currentPage / $totalPages"
|
||||
|
||||
val style = TextStyle(
|
||||
color = Color(235, 235, 235),
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
)
|
||||
val strokeStyle = style.copy(
|
||||
color = Color(45, 45, 45),
|
||||
drawStyle = Stroke(width = 4f),
|
||||
)
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = text, style = strokeStyle)
|
||||
Text(text = text, style = style)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.style.ScaleXSpan
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import eu.kanade.tachiyomi.widget.OutlineSpan
|
||||
|
||||
/**
|
||||
* Page indicator found at the bottom of the reader
|
||||
*/
|
||||
class PageIndicatorTextView(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : AppCompatTextView(context, attrs) {
|
||||
|
||||
init {
|
||||
setTextColor(fillColor)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun setText(text: CharSequence?, type: BufferType?) {
|
||||
// Add spaces at the start & end of the text, otherwise the stroke is cut-off because it's
|
||||
// not taken into account when measuring the text (view's padding doesn't help).
|
||||
val currText = " $text "
|
||||
|
||||
// Also add a bit of spacing between each character, as the stroke overlaps them
|
||||
val finalText = SpannableString(currText.asIterable().joinToString("\u00A0")).apply {
|
||||
// Apply text outline
|
||||
setSpan(spanOutline, 1, length - 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
|
||||
for (i in 1..lastIndex step 2) {
|
||||
setSpan(ScaleXSpan(0.2f), i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
||||
|
||||
super.setText(finalText, BufferType.SPANNABLE)
|
||||
}
|
||||
}
|
||||
|
||||
private val fillColor = Color.rgb(235, 235, 235)
|
||||
private val strokeColor = Color.rgb(45, 45, 45)
|
||||
|
||||
// A span object with text outlining properties
|
||||
private val spanOutline = OutlineSpan(
|
||||
strokeColor = strokeColor,
|
||||
strokeWidth = 4f,
|
||||
)
|
||||
@@ -23,7 +23,6 @@ import android.content.ClipData
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.graphics.Color
|
||||
import android.graphics.ColorMatrix
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.Paint
|
||||
@@ -38,18 +37,27 @@ import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View.LAYER_TYPE_HARDWARE
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.ChecksSdkIntAtLeast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheetFix
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.WindowCompat
|
||||
@@ -61,8 +69,10 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.hippo.ehviewer.BuildConfig
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.databinding.ReaderActivityBinding
|
||||
import com.hippo.ehviewer.gallery.ArchivePageLoader
|
||||
import com.hippo.ehviewer.gallery.EhPageLoader
|
||||
@@ -96,21 +106,16 @@ import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.applySystemAnimatorScale
|
||||
import eu.kanade.tachiyomi.util.system.isNightMode
|
||||
import eu.kanade.tachiyomi.util.view.copy
|
||||
import eu.kanade.tachiyomi.util.view.popupMenu
|
||||
import eu.kanade.tachiyomi.util.view.setTooltip
|
||||
import eu.kanade.tachiyomi.widget.listener.SimpleAnimationListener
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
import splitties.init.appCtx
|
||||
import splitties.systemservices.clipboardManager
|
||||
|
||||
@@ -137,8 +142,7 @@ class ReaderActivity : EhActivity() {
|
||||
/**
|
||||
* Whether the menu is currently visible.
|
||||
*/
|
||||
var menuVisible = false
|
||||
private set
|
||||
var menuVisible by mutableStateOf(false)
|
||||
|
||||
private var saveImageToLauncher = registerForActivityResult(
|
||||
CreateDocument("todo/todo"),
|
||||
@@ -301,7 +305,41 @@ class ReaderActivity : EhActivity() {
|
||||
buildProvider()
|
||||
binding = ReaderActivityBinding.inflate(layoutInflater)
|
||||
binding.dialogStub.setMD3Content {
|
||||
val brightness by Settings.customBrightness.collectAsState()
|
||||
val brightnessValue by Settings.customBrightnessValue.collectAsState()
|
||||
val colorOverlayEnabled by Settings.colorFilter.collectAsState()
|
||||
val colorOverlay by Settings.colorFilterValue.collectAsState()
|
||||
val colorOverlayMode by Settings.colorFilterMode.collectAsState {
|
||||
when (it) {
|
||||
1 -> BlendMode.Multiply
|
||||
2 -> BlendMode.Screen
|
||||
3 -> BlendMode.Overlay
|
||||
4 -> BlendMode.Lighten
|
||||
5 -> BlendMode.Darken
|
||||
else -> BlendMode.SrcOver
|
||||
}
|
||||
}
|
||||
dialogState.Intercept()
|
||||
|
||||
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
|
||||
ReaderAppBars(
|
||||
visible = menuVisible,
|
||||
isRtl = isRtl,
|
||||
showSeekBar = showSeekbar,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalPage,
|
||||
onSliderValueChange = {
|
||||
isScrollingThroughPages = true
|
||||
moveToPageIndex(it)
|
||||
},
|
||||
onClickSettings = { readerSettingSheetDialog?.show() },
|
||||
)
|
||||
|
||||
ReaderContentOverlay(
|
||||
brightness = { brightnessValue }.takeIf { brightness && brightnessValue < 0 },
|
||||
color = { colorOverlay }.takeIf { colorOverlayEnabled },
|
||||
colorBlendMode = colorOverlayMode,
|
||||
)
|
||||
}
|
||||
setContentView(binding.root)
|
||||
mGalleryProvider.let {
|
||||
@@ -327,8 +365,6 @@ class ReaderActivity : EhActivity() {
|
||||
mCurrentIndex = if (mPage >= 0) mPage else mGalleryProvider!!.startPage
|
||||
}
|
||||
totalPage = mGalleryProvider!!.size
|
||||
val viewerMode = ReadingModeType.fromPreference(ReaderPreferences.defaultReadingMode().get())
|
||||
binding.actionReadingMode.setImageResource(viewerMode.iconRes)
|
||||
viewer?.destroy()
|
||||
viewer = ReadingModeType.toViewer(ReaderPreferences.defaultReadingMode().get(), this)
|
||||
isRtl = viewer is R2LPagerViewer
|
||||
@@ -594,17 +630,10 @@ class ReaderActivity : EhActivity() {
|
||||
* Sets the visibility of the menu according to [visible] and with an optional parameter to
|
||||
* [animate] the views.
|
||||
*/
|
||||
fun setMenuVisibility(visible: Boolean, animate: Boolean = true) {
|
||||
fun setMenuVisibility(visible: Boolean) {
|
||||
menuVisible = visible
|
||||
if (visible) {
|
||||
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
|
||||
binding.readerMenu.isVisible = true
|
||||
|
||||
if (animate) {
|
||||
val bottomAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_bottom)
|
||||
bottomAnimation.applySystemAnimatorScale(this)
|
||||
binding.readerMenuBottom.startAnimation(bottomAnimation)
|
||||
}
|
||||
|
||||
if (ReaderPreferences.showPageNumber().get()) {
|
||||
config?.setPageNumberVisibility(false)
|
||||
@@ -616,19 +645,6 @@ class ReaderActivity : EhActivity() {
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
|
||||
if (animate) {
|
||||
val bottomAnimation = AnimationUtils.loadAnimation(this, R.anim.exit_to_bottom)
|
||||
bottomAnimation.applySystemAnimatorScale(this)
|
||||
bottomAnimation.setAnimationListener(
|
||||
object : SimpleAnimationListener() {
|
||||
override fun onAnimationEnd(animation: Animation) {
|
||||
binding.readerMenu.isVisible = false
|
||||
}
|
||||
},
|
||||
)
|
||||
binding.readerMenuBottom.startAnimation(bottomAnimation)
|
||||
}
|
||||
|
||||
if (ReaderPreferences.showPageNumber().get()) {
|
||||
config?.setPageNumberVisibility(true)
|
||||
}
|
||||
@@ -644,32 +660,29 @@ class ReaderActivity : EhActivity() {
|
||||
*/
|
||||
@SuppressLint("PrivateResource")
|
||||
private fun initializeMenu() {
|
||||
binding.readerMenuBottom.applyInsetter {
|
||||
binding.dialogStub.applyInsetter {
|
||||
type(navigationBars = true) {
|
||||
margin(bottom = true, horizontal = true)
|
||||
margin(vertical = true, horizontal = true)
|
||||
}
|
||||
}
|
||||
|
||||
// Init listeners on bottom menu
|
||||
binding.readerNav.setMD3Content {
|
||||
ChapterNavigator(
|
||||
isRtl = isRtl,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalPage,
|
||||
onSliderValueChange = {
|
||||
isScrollingThroughPages = true
|
||||
moveToPageIndex(it)
|
||||
},
|
||||
)
|
||||
binding.pageNumber.setMD3Content {
|
||||
CompositionLocalProvider(
|
||||
LocalTextStyle provides MaterialTheme.typography.bodySmall,
|
||||
) {
|
||||
PageIndicatorText(
|
||||
currentPage = currentPage,
|
||||
totalPages = totalPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
initBottomShortcuts()
|
||||
readerSettingSheetDialog = ReaderSettingsSheet(this@ReaderActivity)
|
||||
|
||||
val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay(this).apply {
|
||||
elevation = resources.getDimension(com.google.android.material.R.dimen.m3_sys_elevation_level2)
|
||||
alpha = if (isNightMode()) 230 else 242 // 90% dark 95% light
|
||||
}
|
||||
binding.toolbarBottom.background = toolbarBackground.copy(this@ReaderActivity)
|
||||
|
||||
val toolbarColor = ColorUtils.setAlphaComponent(
|
||||
toolbarBackground.resolvedTintColor,
|
||||
@@ -683,59 +696,6 @@ class ReaderActivity : EhActivity() {
|
||||
setMenuVisibility(menuVisible)
|
||||
}
|
||||
|
||||
private fun initBottomShortcuts() {
|
||||
// Reading mode
|
||||
with(binding.actionReadingMode) {
|
||||
setTooltip(R.string.viewer)
|
||||
|
||||
setOnClickListener {
|
||||
popupMenu(
|
||||
items = ReadingModeType.entries.map { it.flagValue to it.stringRes },
|
||||
selectedItemId = ReaderPreferences.defaultReadingMode().get(),
|
||||
) {
|
||||
val newReadingMode = ReadingModeType.fromPreference(itemId)
|
||||
ReaderPreferences.defaultReadingMode().set(newReadingMode.flagValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation
|
||||
with(binding.actionRotation) {
|
||||
setTooltip(R.string.rotation_type)
|
||||
|
||||
setOnClickListener {
|
||||
popupMenu(
|
||||
items = OrientationType.entries.map { it.flagValue to it.stringRes },
|
||||
selectedItemId = ReaderPreferences.defaultOrientationType().get(),
|
||||
) {
|
||||
val newOrientation = OrientationType.fromPreference(itemId)
|
||||
ReaderPreferences.defaultOrientationType().set(newOrientation.flagValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings sheet
|
||||
with(binding.actionSettings) {
|
||||
setTooltip(R.string.action_settings)
|
||||
readerSettingSheetDialog = ReaderSettingsSheet(this@ReaderActivity)
|
||||
setOnClickListener {
|
||||
if (!readerSettingSheetDialog!!.isShowing) {
|
||||
readerSettingSheetDialog!!.show()
|
||||
}
|
||||
}
|
||||
|
||||
setOnLongClickListener {
|
||||
ReaderSettingsSheet(this@ReaderActivity, showColorFilterSettings = true).show()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateOrientationShortcut(preference: Int) {
|
||||
val orientation = OrientationType.fromPreference(preference)
|
||||
binding.actionRotation.setImageResource(orientation.iconRes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a key event. If the viewer doesn't handle it, call the default implementation.
|
||||
*/
|
||||
@@ -771,7 +731,6 @@ class ReaderActivity : EhActivity() {
|
||||
fun onPageSelected(page: ReaderPage) {
|
||||
// Set bottom page number
|
||||
currentPage = page.number
|
||||
binding.pageNumber.text = "$currentPage/$totalPage"
|
||||
|
||||
mCurrentIndex = page.index
|
||||
mGalleryProvider?.putStartPage(mCurrentIndex)
|
||||
@@ -782,7 +741,23 @@ class ReaderActivity : EhActivity() {
|
||||
* actions to perform is shown.
|
||||
*/
|
||||
fun onPageLongTap(page: ReaderPage) {
|
||||
ReaderPageSheet(this, page).show()
|
||||
lifecycleScope.launch {
|
||||
dialogState.dialog {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
fun dismiss() = it.cancel()
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
ModalBottomSheetFix(
|
||||
onDismissRequest = { dismiss() },
|
||||
sheetState = sheetState,
|
||||
windowInsets = WindowInsets(0),
|
||||
) {
|
||||
ReaderPageSheet(page) {
|
||||
coroutineScope.launch { sheetState.hide() }
|
||||
}
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -793,15 +768,6 @@ class ReaderActivity : EhActivity() {
|
||||
setMenuVisibility(!menuVisible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the viewer to show the menu.
|
||||
*/
|
||||
fun showMenu() {
|
||||
if (!menuVisible) {
|
||||
setMenuVisibility(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the viewer to hide the menu.
|
||||
*/
|
||||
@@ -819,7 +785,6 @@ class ReaderActivity : EhActivity() {
|
||||
if (newOrientation.flag != requestedOrientation) {
|
||||
requestedOrientation = newOrientation.flag
|
||||
}
|
||||
updateOrientationShortcut(ReaderPreferences.defaultOrientationType().get())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -895,14 +860,6 @@ class ReaderActivity : EhActivity() {
|
||||
.onEach { setPageNumberVisibility(it) }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
ReaderPreferences.showReaderSeekbar().changes()
|
||||
.onEach { setReaderSeekbarVisibility(it) }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
ReaderPreferences.trueColor().changes()
|
||||
.onEach { setTrueColor(it) }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
if (hasCutout) {
|
||||
setCutoutShort(ReaderPreferences.cutoutShort().get())
|
||||
ReaderPreferences.cutoutShort().changes()
|
||||
@@ -923,14 +880,6 @@ class ReaderActivity : EhActivity() {
|
||||
.onEach { setCustomBrightness(it) }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
ReaderPreferences.colorFilter().changes()
|
||||
.onEach { setColorFilter(it) }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
ReaderPreferences.colorFilterMode().changes()
|
||||
.onEach { setColorFilter(ReaderPreferences.colorFilter().get()) }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
merge(
|
||||
ReaderPreferences.grayscale().changes(),
|
||||
ReaderPreferences.invertedColors().changes(),
|
||||
@@ -969,17 +918,6 @@ class ReaderActivity : EhActivity() {
|
||||
binding.pageNumber.isVisible = visible
|
||||
}
|
||||
|
||||
fun setReaderSeekbarVisibility(visible: Boolean) {
|
||||
binding.readerNav.isVisible = visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the 32-bit color mode according to [enabled].
|
||||
*/
|
||||
private fun setTrueColor(enabled: Boolean) {
|
||||
// TODO()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.P)
|
||||
private fun setCutoutShort(enabled: Boolean) {
|
||||
window.attributes.layoutInDisplayCutoutMode = when (enabled) {
|
||||
@@ -1014,21 +952,6 @@ class ReaderActivity : EhActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the color filter overlay according to [enabled].
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun setColorFilter(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
ReaderPreferences.colorFilterValue().changes()
|
||||
.sample(100)
|
||||
.onEach { setColorFilterValue(it) }
|
||||
.launchIn(lifecycleScope)
|
||||
} else {
|
||||
binding.colorOverlay.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the brightness of the screen. Range is [-75, 100].
|
||||
* From -75 to -1 a semi-transparent black view is overlaid with the minimum brightness.
|
||||
@@ -1050,23 +973,6 @@ class ReaderActivity : EhActivity() {
|
||||
}
|
||||
|
||||
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
|
||||
|
||||
// Set black overlay visibility.
|
||||
if (value < 0) {
|
||||
binding.brightnessOverlay.isVisible = true
|
||||
val alpha = (abs(value) * 2.56).toInt()
|
||||
binding.brightnessOverlay.setBackgroundColor(Color.argb(alpha, 0, 0, 0))
|
||||
} else {
|
||||
binding.brightnessOverlay.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the color filter [value].
|
||||
*/
|
||||
private fun setColorFilterValue(value: Int) {
|
||||
binding.colorOverlay.isVisible = true
|
||||
binding.colorOverlay.setFilterColor(value, ReaderPreferences.colorFilterMode().get())
|
||||
}
|
||||
|
||||
private fun setLayerPaint(grayscale: Boolean, invertedColors: Boolean) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val animationSpec = tween<IntOffset>(200)
|
||||
|
||||
@Composable
|
||||
fun ReaderAppBars(
|
||||
visible: Boolean,
|
||||
isRtl: Boolean,
|
||||
showSeekBar: Boolean,
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
onSliderValueChange: (Int) -> Unit,
|
||||
onClickSettings: () -> Unit,
|
||||
) = Column(modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInVertically(initialOffsetY = { it }, animationSpec = animationSpec),
|
||||
exit = slideOutVertically(targetOffsetY = { it }, animationSpec = animationSpec),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (showSeekBar) {
|
||||
ChapterNavigator(
|
||||
isRtl = isRtl,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalPages,
|
||||
onSliderValueChange = onSliderValueChange,
|
||||
)
|
||||
}
|
||||
BottomReaderBar(onClickSettings = onClickSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.core.graphics.toXfermode
|
||||
|
||||
class ReaderColorFilterView(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : View(context, attrs) {
|
||||
|
||||
private val colorFilterPaint: Paint = Paint()
|
||||
|
||||
fun setFilterColor(color: Int, filterMode: Int) {
|
||||
colorFilterPaint.color = color
|
||||
colorFilterPaint.xfermode = when (filterMode) {
|
||||
1 -> PorterDuff.Mode.MULTIPLY
|
||||
2 -> PorterDuff.Mode.SCREEN
|
||||
3 -> PorterDuff.Mode.OVERLAY
|
||||
4 -> PorterDuff.Mode.LIGHTEN
|
||||
5 -> PorterDuff.Mode.DARKEN
|
||||
else -> PorterDuff.Mode.SRC_OVER
|
||||
}.toXfermode()
|
||||
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
canvas.drawPaint(colorFilterPaint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.abs
|
||||
|
||||
@Composable
|
||||
fun ReaderContentOverlay(
|
||||
brightness: (() -> Int)?,
|
||||
color: (() -> Int)?,
|
||||
colorBlendMode: BlendMode = BlendMode.SrcOver,
|
||||
) {
|
||||
if (brightness != null) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val alpha = abs(brightness()) / 100f
|
||||
drawRect(Color.Black, alpha = alpha)
|
||||
}
|
||||
}
|
||||
|
||||
if (color != null) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawRect(
|
||||
color = Color(color()),
|
||||
blendMode = colorBlendMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,63 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import com.hippo.ehviewer.databinding.ReaderPageSheetBinding
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FileCopy
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.util.findActivity
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
|
||||
import moe.tarsin.kt.andThen
|
||||
|
||||
/**
|
||||
* Sheet to show when a page is long clicked.
|
||||
*/
|
||||
class ReaderPageSheet(
|
||||
private val activity: ReaderActivity,
|
||||
private val page: ReaderPage,
|
||||
) : BaseBottomSheetDialog(activity) {
|
||||
@Composable
|
||||
fun ReaderPageSheet(page: ReaderPage, dismiss: () -> Unit) {
|
||||
val activity = LocalContext.current.run { remember { findActivity<ReaderActivity>() } }
|
||||
|
||||
private lateinit var binding: ReaderPageSheetBinding
|
||||
|
||||
override fun createView(inflater: LayoutInflater): View {
|
||||
binding = ReaderPageSheetBinding.inflate(activity.layoutInflater, null, false)
|
||||
|
||||
binding.share.setOnClickListener { share() }
|
||||
binding.copy.setOnClickListener { copy() }
|
||||
binding.save.setOnClickListener { save() }
|
||||
binding.saveTo.setOnClickListener { saveTo() }
|
||||
binding.refresh.setOnClickListener { refresh() }
|
||||
binding.refreshOrgImg.setOnClickListener { refreshOrgImg() }
|
||||
|
||||
return binding.root
|
||||
@Composable
|
||||
fun Item(icon: ImageVector, @StringRes text: Int, onClick: () -> Unit) = Row(
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp)
|
||||
.clickable(onClick = onClick andThen dismiss).padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
Spacer(modifier = Modifier.size(32.dp))
|
||||
Text(text = stringResource(id = text))
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares the image of this page with external apps.
|
||||
*/
|
||||
private fun share() {
|
||||
activity.shareImage(page.index)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the image of this page with external apps.
|
||||
*/
|
||||
private fun copy() {
|
||||
activity.copyImage(page.index)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the image of this page on external storage.
|
||||
*/
|
||||
private fun save() {
|
||||
activity.saveImage(page.index)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the image of this page to a custom location on external storage.
|
||||
*/
|
||||
private fun saveTo() {
|
||||
activity.saveImageTo(page.index)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
Item(icon = Icons.Default.Refresh, text = R.string.refresh) {
|
||||
activity.mGalleryProvider?.retryPage(page.index)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private fun refreshOrgImg() {
|
||||
Item(icon = Icons.Default.Refresh, text = R.string.refresh_original) {
|
||||
activity.mGalleryProvider?.retryPage(page.index, true)
|
||||
dismiss()
|
||||
}
|
||||
Item(icon = Icons.Default.Share, text = R.string.action_share) {
|
||||
activity.shareImage(page.index)
|
||||
}
|
||||
Item(icon = Icons.Default.FileCopy, text = R.string.action_copy) {
|
||||
activity.copyImage(page.index)
|
||||
}
|
||||
Item(icon = Icons.Default.Save, text = R.string.action_save) {
|
||||
activity.saveImage(page.index)
|
||||
}
|
||||
Item(icon = Icons.Default.Save, text = R.string.action_save_to) {
|
||||
activity.saveImageTo(page.index)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.accompanist.themeadapter.material3.Mdc3Theme
|
||||
import com.hippo.ehviewer.ui.theme.EhTheme
|
||||
|
||||
class ReaderProgressIndicator @JvmOverloads constructor(
|
||||
context: Context,
|
||||
@@ -32,7 +32,7 @@ class ReaderProgressIndicator @JvmOverloads constructor(
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
Mdc3Theme {
|
||||
EhTheme {
|
||||
if (visible) CombinedCircularProgressIndicator(progress = progress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ class PagerPageHolder(
|
||||
super.onDetachedFromWindow()
|
||||
cancelProgressJob()
|
||||
unsubscribeStatus()
|
||||
progressIndicator.hide()
|
||||
}
|
||||
|
||||
private fun launchProgressJob() {
|
||||
|
||||
@@ -111,6 +111,7 @@ class WebtoonPageHolder(
|
||||
override fun recycle() {
|
||||
statusJob?.cancel()
|
||||
cancelProgressJob()
|
||||
progressIndicator.hide()
|
||||
|
||||
removeErrorLayout()
|
||||
frame.recycle()
|
||||
@@ -211,6 +212,7 @@ class WebtoonPageHolder(
|
||||
* Called when the page has an error.
|
||||
*/
|
||||
private fun setError() {
|
||||
progressIndicator.hide()
|
||||
progressContainer.isVisible = false
|
||||
initErrorLayout()
|
||||
}
|
||||
|
||||
@@ -10,17 +10,6 @@ import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
|
||||
*
|
||||
* **Possible replacements**
|
||||
* - suspend function
|
||||
* - custom scope like view or presenter scope
|
||||
*/
|
||||
@DelicateCoroutinesApi
|
||||
fun launchUI(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block)
|
||||
|
||||
/**
|
||||
* Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
|
||||
*
|
||||
@@ -32,17 +21,6 @@ fun launchUI(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
fun launchIO(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block)
|
||||
|
||||
/**
|
||||
* Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
|
||||
*
|
||||
* **Possible replacements**
|
||||
* - suspend function
|
||||
* - custom scope like view or presenter scope
|
||||
*/
|
||||
@DelicateCoroutinesApi
|
||||
fun launchNow(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block)
|
||||
|
||||
fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
launch(Dispatchers.Main, block = block)
|
||||
|
||||
|
||||
@@ -26,8 +26,3 @@ operator fun <T> Preference<Set<T>>.plusAssign(item: T) {
|
||||
operator fun <T> Preference<Set<T>>.minusAssign(item: T) {
|
||||
set(get() - item)
|
||||
}
|
||||
|
||||
fun Preference<Boolean>.toggle(): Boolean {
|
||||
set(!get())
|
||||
return get()
|
||||
}
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user