Compare commits

..
88 Commits
Author SHA1 Message Date
FooIbar 37eb2f1aed Prepare for release 1.10.0
Releases / Build (push) Has been cancelled
2023-12-14 22:50:41 +08:00
renovate[bot] c70ec5f354 Update dependency dev.chrisbanes.compose:compose-bom to v2023.12.00-alpha04 2023-12-14 14:42:27 +00:00
FooIbar 7139af4629 Update renovate.json
[no ci]
2023-12-14 22:25:27 +08:00
FooIbar a2bac029f5 Update renovate.json
[no ci]
2023-12-14 22:21:04 +08:00
FooIbar 6c68074e8b Update CHANGELOG.zh-CN.MD (#503)
[no ci]
2023-12-14 22:12:55 +08:00
FooIbar 991caf73f4 Fix ReaderPageSheet padding and ripple 2023-12-14 20:18:40 +08:00
FooIbar 0ba11641fa Update compose compiler 2023-12-14 19:45:32 +08:00
FooIbar b51120e914 Drop deprecated Accompanist libraries (#500)
* Migrate XML themes to Compose

* Vendor Accompanist WebView
2023-12-14 18:04:51 +08:00
GG Bond b02fb2d1d7 PagingSourceFactory capture mutableState only, Resolve #491 2023-12-14 12:46:42 +08:00
renovate[bot] c6247230da Update androidx.navigation to v2.7.6 2023-12-14 04:43:19 +00:00
renovate[bot] d64be78b4e Update dependency androidx.activity:activity-compose to v1.8.2 2023-12-14 04:19:48 +00:00
renovate[bot] e75f633165 Update androidx.lifecycle to v2.7.0-rc02 2023-12-14 04:18:36 +00:00
GG Bond 27c5598133 Fix build 2023-12-14 12:00:02 +08:00
renovate[bot] a9449bfbc7 Update dependency sh.calvin.reorderable:reorderable to v1.2.0 2023-12-13 07:32:39 +00:00
GG Bond 4fecbe259b Update compose snapshot, drop outdated workaround 2023-12-13 11:51:12 +08:00
GG Bond 81d72edbf0 Update compose snapshot 2023-12-12 16:04:32 +08:00
GG Bond ea7e6e4624 Support OutgoingContent.WriteChannelContent in CronetEngine 2023-12-10 21:40:27 +08:00
GG Bond 6335d5bca4 Fix search image 2023-12-10 21:40:27 +08:00
GG Bond 227b430dbb Animate comments update, Resolve #450 2023-12-10 11:36:14 +08:00
GG Bond 0bba56a29f Let GalleryList aware of favSlot update 2023-12-10 11:21:16 +08:00
FooIbar c07d9da7b0 Reduce image target size since we are using fill 2023-12-09 17:04:54 +08:00
Vladimir DostoyevskiyandGG Bond 900c5ed007 Remove unnecessary Image -> Drawable (#484)
* Remove unnecessary Image -> Drawable

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-09 08:11:22 +00:00
Vladimir DostoyevskiyandGG Bond ba0788f6df Move to Coil 3 (#482)
* Move to Coil 3

* Fix R8

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-09 07:37:44 +00:00
Vladimir DostoyevskiyandGG Bond c517856e89 Convert ReaderPageSheet to Compose (#481)
* Convert ReaderPageSheet to Compose

* Cleanup unused

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-09 06:39:46 +00:00
Vladimir DostoyevskiyandGG Bond 791ee0ddbf Drop icon recompose fix (#480)
* Update snapshot and drop unused workaround

* Drop unnecessary fix

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-09 02:27:28 +00:00
Vladimir DostoyevskiyandGG Bond 0746938cb9 Compose bottom reader menu (#479)
* Migrate bottom reader menu to Compose

* Use Compose to animate bottom reader menu bars

* Cleanup unused

* Refine

* Cleanup to use DropdownMenu

* Cleanup unused

* Refine BottomReaderBar layout

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-08 17:23:51 +00:00
FooIbar bbae24aba9 Follow API rate limit to avoid IP ban (#478)
* Follow API rate limit to avoid IP ban

* Delay for bulk favorite adding to prevent flooding

* Revert "Delay for bulk favorite adding to prevent flooding"

This reverts commit 0c175453101aab2d6d200a661259906291ceac36.

* Make requests in parallel
2023-12-08 16:09:40 +00:00
FooIbar 677edb0e02 Make blur radius discrete to reduce overhead (#476)
* Make blur radius discrete to reduce overhead

* Refine
2023-12-08 14:22:51 +00:00
GG Bond a4328c5111 Workaround rating icon 2023-12-08 21:46:06 +08:00
GG Bond 27cf341fef Drop paging runtime 2023-12-08 21:46:06 +08:00
GG Bond bc654e98b8 Drop explicitly coordinatorlayout usage 2023-12-08 21:46:06 +08:00
FooIbar 588f78e598 Fix search bar padding in landscape mode 2023-12-08 16:40:42 +08:00
FooIbar 70f952285a Don't scroll to top on refresh in local favorites 2023-12-08 16:08:55 +08:00
GG Bond a6f5e4f98d Make sure computeIfAbsent inlined 2023-12-08 13:27:01 +08:00
FooIbar 6fe5c1d666 Use commit author name instead of login name
Fix parse error caused by invalid GitHub account
2023-12-08 13:08:31 +08:00
GG Bond 1dba12ec94 Cache VectorPainter for MetaRatingWidget 2023-12-08 12:50:19 +08:00
GG Bond aeb3f689d1 Fix to provide it with setMD3Content 2023-12-08 12:50:19 +08:00
GG Bond 0a2268d4de Allow VectorPainter to be cached 2023-12-08 12:50:19 +08:00
GG Bond 9a32b020cf Let GalleryTags prefer next column not multiple lines 2023-12-08 00:35:20 +08:00
GG Bond c5b01a7089 Update compose snapshot for lazygrid performance 2023-12-08 00:00:28 +08:00
GG Bond 0634fbb724 Optimize to use constant ConstraintSet 2023-12-07 22:48:06 +08:00
FooIbar 445e9511a0 Flatten layout 2023-12-07 20:20:10 +08:00
renovate[bot] ac532acb5b Update Rust crate once_cell to 1.19 2023-12-07 12:03:34 +00:00
FooIbar 7a8e21fbef Keep search bar state across configuration changes 2023-12-07 15:09:59 +08:00
GG Bond 58b0f3a41e Avoid graphicsLayer overhead 2023-12-07 13:24:31 +08:00
GG Bond ab373990cd Following tachiyomi 4146c4c31d069ce9fb7ab36d72f1f8fa2be1e050 2023-12-07 12:48:50 +08:00
GG Bond 272c157fe0 Compose PageIndicatorText, revert 3744018af8 2023-12-07 12:48:50 +08:00
GG Bond e5e047ee10 Move to Compose ReaderContentOverlay 2023-12-07 12:48:50 +08:00
renovate[bot] bf0b529018 Update dependency com.google.android.material:material to v1.12.0-alpha02 2023-12-06 19:19:11 +00:00
FooIbar acf2392406 Avoid progress indicator recomposing in background 2023-12-07 00:05:50 +08:00
FooIbar cecd6fb359 [no ci] Remove deprecated JCenter repository 2023-12-06 13:31:00 +08:00
Vladimir DostoyevskiyandGG Bond 7b67210876 Fallback engine should be Apache5 HC (#456)
* Drop explicitly okhttp dependency

* Move to Apache5 HC instead

* Fix build

* Credit to Ktor instead

* Refactor Cronet Preference

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-06 04:42:17 +00:00
Vladimir DostoyevskiyandGG Bond e40c4f2c71 Save body for ByteBuffer as well (#455)
* Save parseErrorBody for ByteBuffer as well

* Fix

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-05 13:52:08 +00:00
GG Bond b0717b38b1 Cleanup okhttp urlbuilder usage 2023-12-05 18:17:33 +08:00
GG Bond 18c9b95b9b Convert FavListUrlBuilder to use ktor UrlBuilder 2023-12-05 18:17:33 +08:00
GG Bond fc63c41ab0 Cleanup okhttp usage 2023-12-04 20:30:24 +08:00
GG Bond d1c7434993 Drop CloudflareInterceptor to synchronize behaviour 2023-12-04 20:30:24 +08:00
GG Bond da15ba8b9a Always use KtorHttpUriFetcher 2023-12-04 20:30:24 +08:00
GG Bond 1d2a2a3608 Fix to copy to FileChannel directly 2023-12-04 20:30:24 +08:00
renovate[bot] 145cd7a7c0 Update plugin spotless to v6.23.3 2023-12-04 08:06:29 +00:00
FooIbar 048415f3e0 Readd QUIC hints as cronet engine is now preferred 2023-12-04 12:41:39 +08:00
GG Bond 95ccabc756 Fix download origin image 2023-12-04 01:18:25 +08:00
FooIbar 1f08c0e08c Fix ProgressScreen layout 2023-12-04 01:09:49 +08:00
GG Bond b036aeef23 Refine 2023-12-04 00:46:58 +08:00
GG Bond d469a8404c Refine 2023-12-04 00:46:58 +08:00
GG Bond 5b16b6aed9 Hint the scheduler that we are mostly non-blocking 2023-12-04 00:46:58 +08:00
GG Bond 151949aecf Move SpiderDen to use ktor directly
Should have no performance regression, though benchmark is unavailable

Cache should be managed on ktor layer anyway
2023-12-04 00:46:58 +08:00
FooIbar de2c4ca3f5 Move category filters below search bar 2023-12-03 23:03:52 +08:00
FooIbar e5bebcfe80 [no ci] Update renovate.json 2023-12-03 21:50:00 +08:00
FooIbar 0080a482e4 [no ci] Don't update to SNAPSHOTs 2023-12-03 21:48:41 +08:00
FooIbar 756867416b Fix crash on clicking gallery link with page number 2023-12-03 21:43:05 +08:00
FooIbar 124ff2f190 Fix intent emitted before collection being missed 2023-12-03 21:16:16 +08:00
FooIbar 828a3d09b6 Move advanced options below search bar 2023-12-03 19:39:58 +08:00
FooIbar fc3b9d73ec Add language filter for search (#428)
* Add language filter for search

* Refine

* Refine
2023-12-03 06:44:00 +00:00
FooIbar ba943a31d4 [no ci] Update ci.yml 2023-12-03 13:35:29 +08:00
renovate[bot] 8ee1ac7617 Update dependency sh.calvin.reorderable:reorderable to v1.1.0 2023-12-03 05:30:30 +00:00
FooIbar 113f2a1794 Fix NPE (#433)
* Fix NPE

* Enable strict mode in debug environment
2023-12-03 05:14:09 +00:00
GG Bond f6028813e2 Limit CronetEngine to use 1 thread since we are totally async & non-blocking 2023-12-03 11:57:40 +08:00
GG Bond b094a32d12 Convert to use async Channel for reuse of CancellableContinuation 2023-12-03 11:57:40 +08:00
FooIbar fd3e4bdc60 Fix search bar offset on label change 2023-12-02 22:10:06 +08:00
FooIbarandVladimir Dostoyevskiy 142092d930 Add missing inline modifier (#426)
* Add missing inline modifier

* Update EhRequestBuilder.kt

* Update EhRequestBuilder.kt

---------

Co-authored-by: Vladimir Dostoyevskiy <99822064+VDostoyevskiy@users.noreply.github.com>
2023-12-02 12:15:30 +00:00
FooIbar 75e3e78ecb Update history when viewing downloaded galleries 2023-12-02 17:11:16 +08:00
FooIbar 053832681f Add search suggestion item placement animation 2023-12-02 15:54:20 +08:00
Vladimir DostoyevskiyandGG Bond 29e33d6e69 Initial CronetEngine (#421)
* Initial CronetEngine without UploadDataProvider support

* Support ByteArrayContent

* Cleanup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-02 07:50:47 +00:00
FooIbar 2d878e2f1c Migrate to kotlinx datetime [1/2] 2023-12-02 15:34:51 +08:00
Vladimir DostoyevskiyandGG Bond 0fdbfbb1ae Move to Ktor Cookie handle, Part 2 (#419)
* Migration

* Migration

* Migration

* Migration

* Make EhCookieStore a CookiesStorage

* Drop CookieDB migration

* Drop one Okhttp cookie usage

* Drop one Okhttp cookie usage

* Remove typealias

* Move EhTagDatabase to use ktor

* Make cronet just use Dispatchers.IO

* Make all okhttpClient internally used

* Cleanup

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-02 05:42:02 +00:00
Vladimir DostoyevskiyandGG Bond 52f314f6b0 Move to Ktor, Part 1 (#415)
* Ktor 3.0.0-beta-1

* Drop chunker since we no more se okhttp

* Move getGalleryDetail to ktor

* Migration

* Migration

* Migration

* Migration

* Migration

* Migration

* Migration

* Migration

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-02 09:33:46 +08:00
renovate[bot] ff8d8916aa Update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.5.0 2023-12-01 19:02:36 +00:00
136 changed files with 2909 additions and 3183 deletions

No files matched your search

-1
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- '*'
- 'renovate/**'
pull_request:
workflow_dispatch:
+24
View File
@@ -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
### 重大变更
+2 -2
View File
@@ -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/)
+4 -18
View File
@@ -64,8 +64,8 @@ android {
applicationId = "moe.tarsin.ehviewer"
minSdk = 28
targetSdk = 34
versionCode = 180046
versionName = "1.9.0"
versionCode = 180047
versionName = "1.10.0"
resourceConfigurations.addAll(
listOf(
"zh",
@@ -131,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",
@@ -147,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",
)
}
@@ -212,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)
@@ -222,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)
@@ -233,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)
@@ -268,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)
@@ -278,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")
}
}
+12 -4
View File
@@ -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 {
@@ -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"
@@ -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