Compare commits

...
565 Commits
Author SHA1 Message Date
FooIbar 2c155fe599 Prepare for release 1.14.1
Releases / build (push) Has been cancelled
2025-07-31 18:34:50 +08:00
renovate[bot] 323de69ba0 Update dependency androidx.test.ext:junit to v1.3.0 (#2527)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-31 01:52:48 +00:00
renovate[bot] 0cdc6be34f Update dependency androidx.browser:browser to v1.9.0 (#2526)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-31 01:52:18 +00:00
FooIbar 36c1703763 Renew GitHub token and prepare for a point release 2025-07-31 09:43:10 +08:00
renovate[bot] ee3d2f681c Update dependency androidx.work:work-runtime to v2.10.3 (#2525)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-30 23:05:17 +00:00
renovate[bot] 11ce900868 Update dependency androidx.compose:compose-bom-alpha to v2025.07.01 (#2523)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-30 19:59:25 +00:00
renovate[bot] b936dd2533 Update benchmark to v1.4.0 (#2522)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-30 19:58:11 +00:00
renovate[bot] fab8cd0c1f Update dependency com.squareup.okio:okio-bom to v3.16.0 (#2520)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-30 00:35:57 +00:00
renovate[bot] ec3a962ec8 Update dependency com.squareup.logcat:logcat to v0.4 (#2519)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-29 13:56:37 +00:00
FooIbar 348813109b Clean up context parameters 2025-07-29 21:46:57 +08:00
renovate[bot] 59598094df Update kotlin monorepo to v2.2.20-Beta2 (#2517)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-29 10:18:22 +00:00
renovate[bot] 8b6ae59eda Update dependency io.ktor:ktor-client-okhttp to v3.2.3 (#2516)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-29 10:17:55 +00:00
FooIbar b3d8653882 Speed up dependency downloading 2025-07-29 18:17:42 +08:00
renovate[bot] 719d1ebbb8 Update xmlutil to v0.91.2 (#2514)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-29 00:21:18 +00:00
FooIbar a23f902e9a Revert "[no ci] Allow blank issue"
This reverts commit 24e36b6283.
2025-07-28 20:25:31 +08:00
renovate[bot] 79559635e1 Lock file maintenance (#2512)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-28 03:06:58 +00:00
FooIbar c57a4e4132 Kotlin 2.2.20-Beta1 and remove custom impl 2025-07-26 23:55:25 +08:00
renovate[bot] c6f7d9d869 Update dependency com.squareup.logcat:logcat to v0.3 (#2509)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-25 08:47:54 +00:00
renovate[bot] 9fcddd06e4 Update dependency com.materialkolor:material-kolor to v3.0.1 (#2508)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-24 20:54:47 +00:00
FooIbar 3526a2b0e9 [no ci] Add comment about KSP version check 2025-07-24 22:58:06 +08:00
FooIbar 56187aa4dc Prepare for next iteration
(cherry picked from commit 2d02eda9e3)
2025-07-23 23:25:15 +08:00
FooIbar 9e698f382d Release 1.14.0
Releases / build (push) Has been cancelled
2025-07-23 22:39:34 +08:00
FooIbar d2583a6d96 Add a workflow to test baseline profile generation 2025-07-23 22:37:44 +08:00
FooIbar 250e4fddd9 Refine build scripts 2025-07-23 22:37:44 +08:00
FooIbar 2f274d969e Fix release 2025-07-23 22:37:44 +08:00
FooIbar 2d02eda9e3 Prepare for next iteration 2025-07-23 17:40:43 +08:00
FooIbar f26a9518e0 Prepare for release 1.14.0 2025-07-23 17:40:02 +08:00
FooIbar 2b92bcf0a9 Prepare changelog for 1.14.0 2025-07-23 17:27:59 +08:00
renovate[bot] 0ce6296318 Update dependency io.coil-kt.coil3:coil-bom to v3.3.0 (#2504)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 08:39:02 +00:00
renovate[bot]andFooIbar 2c90ceb5ca Update dependency com.pinterest.ktlint:ktlint-cli to v1.7.1 (#2503)
* Update dependency com.pinterest.ktlint:ktlint-cli to v1.7.1

* Fix spotless

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2025-07-22 06:23:35 +00:00
renovate[bot] 2fde327195 Update plugin spotless to v7.2.1 (#2502)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 01:57:37 +00:00
renovate[bot] 3aee440f67 Lock file maintenance (#2501)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 03:07:36 +00:00
renovate[bot] 290c1777b3 Update plugin spotless to v7.2.0 (#2500)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-20 21:20:25 +00:00
FooIbar e0c8b64c65 Release URI permission when changing download location 2025-07-21 00:12:14 +08:00
FooIbar 810d339b4e Fix downloading notification stuck when nothing to download 2025-07-21 00:12:14 +08:00
FooIbar 53ce1e6934 Fix crash when clearing notification while app is in background 2025-07-21 00:12:14 +08:00
FooIbar ae4d3b2d65 Use fixed width for archive download button 2025-07-19 21:48:35 +08:00
renovate[bot] 1cdd51de21 Update dependency me.zhanghai.compose.preference:preference to v2.1.0 (#2497)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-19 13:22:40 +00:00
FooIbar 9cf4702f69 Migrate to Compose Preference (#2496) 2025-07-19 21:13:29 +08:00
FooIbar cb5455c990 Fix corrupted tag database file not updating 2025-07-17 19:21:05 +08:00
FooIbar db86fef717 Fix history entry not fully dismissed on swipe 2025-07-17 19:21:05 +08:00
FooIbar 8bffeb52a7 Remove explicit dependency on graphics-path 2025-07-17 09:45:10 +08:00
FooIbar 9d2bbd164b Migrate common buttons to M3 Expressive 2025-07-17 09:13:59 +08:00
FooIbar a045766c7a Migrate icon buttons to M3 Expressive 2025-07-17 09:13:59 +08:00
FooIbar 34cff8c8cb Extract NavigationIcon 2025-07-17 09:03:55 +08:00
FooIbar 03ec2af92e Remove redundant PreferenceHeader overload 2025-07-17 09:03:55 +08:00
FooIbar c2a8e27875 Clean up SnackbarHostState usage 2025-07-17 09:03:55 +08:00
FooIbar 3fae5129d8 Revert "Update dependency androidx.compose.material3:material3-android to v1.…"
This reverts commit 17e39f94cc.
2025-07-17 09:03:35 +08:00
renovate[bot] 8311f7baaa Update dependency androidx.compose:compose-bom-alpha to v2025.07.00 (#2491)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-16 23:02:36 +00:00
renovate[bot] 17e39f94cc Update dependency androidx.compose.material3:material3-android to v1.4.0-alpha18 (#2490)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-16 17:54:31 +00:00
renovate[bot] cd1e10c17c Update androidx.lifecycle to v2.9.2 (#2489)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-16 17:54:12 +00:00
renovate[bot] 5defb428ff Update dependency libwebp to v1.6.0 (#2486)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-16 11:50:43 +00:00
FooIbar 79a066e68a Use GitHub repository for renovate to update 2025-07-16 19:42:09 +08:00
FooIbar 3e34bdf7fc Draw scrim behind status bar to improve a11y
Changes toolbar color to the default as surfaceContainerHighest doesn't
look well in light theme.
2025-07-16 10:11:59 +08:00
FooIbar bdf31231f0 Update NDK to r28c 2025-07-15 20:59:32 +08:00
FooIbar 17de611a22 Actually fix the UAF
The archive may be closed before the decoding job finishes.

This reverts commit 4b25fb8363.
2025-07-15 10:09:08 +08:00
renovate[bot] 4744191242 Update dependency io.ktor:ktor-client-okhttp to v3.2.2 (#2480)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 13:15:32 +00:00
renovate[bot] c81be6734a Lock file maintenance (#2478)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 02:57:20 +00:00
FooIbar 663fedb81b Improve tag suggestion hit rate 2025-07-13 21:00:53 +08:00
renovate[bot] 200d29dc1e Update Rust crate rxing to 0.8 (#2476)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-12 18:43:45 +00:00
renovate[bot] 41f6948f13 Update AGP to v8.11.1 (#2474)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 22:41:37 +00:00
FooIbar 24e36b6283 [no ci] Allow blank issue 2025-07-09 20:29:09 +08:00
renovate[bot] 1b1df14937 Update dependency com.materialkolor:material-kolor to v3 (#2472)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 21:04:00 +00:00
FooIbar 895f75a3c5 Fix navigation drawer overlapping side sheet 2025-07-08 21:58:34 +08:00
FooIbar e428a8be57 Sync SwipeToDismissBox with upstream 2025-07-08 21:58:34 +08:00
renovate[bot] a438d2dd88 Update plugin spotless to v7.1.0 (#2470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 03:18:51 +00:00
renovate[bot] f2107edaa3 Update dependency com.squareup.okhttp3:okhttp-bom to v5.1.0 (#2469)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 22:53:53 +00:00
renovate[bot] 644f93a46d Update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.7.1 (#2468)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 22:52:51 +00:00
renovate[bot] dc52e0534e Lock file maintenance (#2466)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 00:37:56 +00:00
FooIbar 15e63eb86c Fix text overflow
`maxLines = 1` should always be used with `softWrap = false` or
`overflow = Ellipsis`
2025-07-06 13:25:33 +08:00
FooIbar 842e59e893 Remove deprecated SaveBodyPlugin 2025-07-05 18:51:06 +08:00
FooIbar 2b7d6745ea Simplify native build 2025-07-05 11:27:56 +08:00
renovate[bot] 3c41063a38 Update dependency gradle to v8.14.3 (#2463)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 16:24:28 +00:00
renovate[bot] da8e84acad Update dependency io.ktor:ktor-client-okhttp to v3.2.1 (#2461)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-03 22:34:46 +00:00
renovate[bot] 0b7658eb9b Update dependency com.squareup.okhttp3:okhttp-bom to v5.0.0 (#2460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-03 17:33:08 +00:00
renovate[bot]andFooIbar 2d7bda3407 Update dependency androidx.compose:compose-bom-alpha to v2025.06.02 (#2457)
* Update dependency androidx.compose:compose-bom-alpha to v2025.06.02

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2025-07-03 02:53:34 +00:00
renovate[bot] a7d11d70d4 Update dependency androidx.core:core-splashscreen to v1.2.0-rc01 (#2458)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 22:19:47 +00:00
FooIbar 4b25fb8363 Fix a potential UAF
`dispose` may get called while decoding.
2025-07-03 00:09:12 +08:00
FooIbar b1aca0578c Build a single shared library 2025-07-02 23:58:31 +08:00
renovate[bot] 78d3021616 Update dependency com.squareup.okio:okio-jvm to v3.15.0 (#2454)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 03:13:56 +00:00
renovate[bot] 14a7a64af2 Update aboutlibraries to v12.2.4 (#2453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 20:37:45 +00:00
FooIbar 6156b30a36 Remove experimental animated WebP decoder option 2025-06-30 17:09:28 +08:00
renovate[bot]andFooIbar 231afd3e71 Update dependency com.squareup.okhttp3:okhttp-bom to v5.0.0-alpha.17 (#2451)
* Update dependency com.squareup.okhttp3:okhttp-bom to v5.0.0-alpha.17

* Vendor AsyncDns stuff as they're removed in OkHttp

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2025-06-30 09:06:19 +00:00
renovate[bot] 14803997ba Lock file maintenance (#2452)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 03:20:05 +00:00
FooIbar 9a99597d4e Pause animated WebP when it becomes invisible 2025-06-29 21:01:41 +08:00
renovate[bot] 7079c3814b Update Rust crate quick-xml to 0.38 (#2448)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-28 21:18:45 +00:00
renovate[bot] 35a95e2b9b Update dependency com.squareup.okio:okio-jvm to v3.14.0 (#2447)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-28 17:39:21 +00:00
FooIbar 99d63c2396 Revert "Update dependency me.saket.telephoto:zoomable to v0.16.0 (#2336)"
This reverts commit fe371f1d66.
2025-06-28 22:41:37 +08:00
FooIbar 62383ed31b Address kotlinx-datetime 0.7.x deprecation 2025-06-28 17:20:24 +08:00
FooIbar a10f9f2ce8 Refine and enable AnimatedWebPDrawable by default (#2442) 2025-06-28 16:10:29 +08:00
renovate[bot] 924d3c4ea3 Update kotlinx.serialization to v1.9.0 (#2443)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-27 16:37:09 +00:00
renovate[bot] be83b53941 Update dependency nettle to nettle_3.10.2_release_20250626 (#2441)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-26 23:18:48 +00:00
renovate[bot] 63657d5286 Update dependency com.google.devtools.ksp to v2.2.0-2.0.2 (#2440)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 23:19:00 +00:00
FooIbar 9bd670cfd3 Revert "Update dependency androidx.compose:compose-bom-alpha to v2025.06.01 (…"
This reverts commit 2753804652.
2025-06-26 01:04:13 +08:00
renovate[bot]andFooIbar 2753804652 Update dependency androidx.compose:compose-bom-alpha to v2025.06.01 (#2434)
* Update dependency androidx.compose:compose-bom-alpha to v2025.06.01

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2025-06-25 02:41:56 +00:00
renovate[bot] 971c75e754 Update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.7.0 (#2433) 2025-06-25 02:41:20 +00:00
renovate[bot] bbe29cbd72 Update dependency chrome to v138 (#2436)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 22:32:39 +00:00
renovate[bot] f91deda5ba Update AGP to v8.11.0 (#2435)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 20:04:27 +00:00
FooIbar bd11a7fcc2 AnimatedWebPDecoder POC (#2426) 2025-06-24 23:58:52 +08:00
renovate[bot] 6edc8f06f3 Update kotlin monorepo to v2.2.0 (#2432)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 12:38:38 +00:00
renovate[bot] 7e3d170c83 Lock file maintenance (#2431)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 02:06:07 +00:00
renovate[bot] cde3ea0c95 Update dependency androidx.work:work-runtime to v2.10.2 (#2430)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 23:15:37 +00:00
renovate[bot] 5a34fb3443 Update dependency androidx.test.uiautomator:uiautomator to v2.4.0-alpha05 (#2429)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 03:35:54 +00:00
renovate[bot] c000617928 Update dependency androidx.room:room-compiler to v2.7.2 (#2428)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 23:56:49 +00:00
renovate[bot] 01e4747483 Update benchmark to v1.4.0-rc01 (#2427)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 23:56:22 +00:00
FooIbar 77eb761fc3 Fix torrent url 2025-06-18 16:56:56 +08:00
renovate[bot] 64c95c1bc5 Update dependency com.google.devtools.ksp to v2.2.0-RC3-2.0.2 (#2423)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-16 20:02:09 +00:00
renovate[bot] 5e5f9ae4bc Update kotlin monorepo to v2.2.0-RC3 (#2422)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-16 11:39:11 +00:00
FooIbar 015059321a Fix comment vote status parsing 2025-06-16 11:26:08 +08:00
renovate[bot] cc6ae217a4 Lock file maintenance (#2421)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-16 02:46:24 +00:00
FooIbar a1c730caac Fix PullToRefresh when content doesn't fill screen 2025-06-16 10:35:36 +08:00
FooIbar 365f6936a3 Stop image animation on off-screen pages 2025-06-16 10:35:13 +08:00
renovate[bot] a6f2034377 Update aboutlibraries to v12.2.3 (#2416)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-15 09:46:30 +00:00
FooIbar 95631ef468 Remove jsoup usage and clean up 2025-06-14 19:21:35 +08:00
renovate[bot] 5dc02c0bd5 Update dependency com.squareup.okio:okio-jvm to v3.13.0 (#2413)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-13 14:34:24 +00:00
FooIbar ea43837e62 Migrate GalleryDetailParser to Rust Part 4/N 2025-06-13 22:25:47 +08:00
FooIbar 4c359e50f8 Migrate GalleryDetailParser to Rust Part 3/N 2025-06-13 22:25:47 +08:00
FooIbar 1feca411b6 Migrate GalleryDetailParser to Rust Part 2/N 2025-06-12 00:33:33 +08:00
FooIbar 4dfcf96897 Clean up rust code
* Make `img` and `parser` public to get rid of dead code warnings
* Remove redundant `no_mangle` and `allow(non_snake_case)` as `jni_fn`
already adds them
2025-06-11 17:40:08 +08:00
renovate[bot] 90568f2216 Lock file maintenance (#2407)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-11 06:31:05 +00:00
FooIbar 4440763f44 Rust 2024 2025-06-11 13:40:05 +08:00
Re*Index. (ot_inc) 5b80fee02e Update Japanese 2025-06-11 09:23:13 +08:00
FooIbar e21a0eca5c Optimize rust binary size (#2403)
* -10.3% vs nightly-2024-08-30 with backtrace
* +2.8% vs nightly-2024-08-30 without backtrace
2025-06-11 09:22:36 +08:00
renovate[bot] 83a1096c08 Update dependency com.google.devtools.ksp to v2.2.0-RC2-2.0.2 (#2405)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 23:23:44 +00:00
renovate[bot] 59f1e7f471 Update aboutlibraries to v12.2.2 (#2404)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 23:23:09 +00:00
FooIbar 12374fb573 Migrate GalleryDetailParser to Rust Part 1/N 2025-06-10 11:39:42 +08:00
renovate[bot] 734bffd279 Lock file maintenance (#2400)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-09 00:48:45 +00:00
renovate[bot] 478d6c9035 Update dependency sh.calvin.reorderable:reorderable to v2.5.1 (#2399)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-08 21:44:56 +00:00
renovate[bot] a6048c84bd Update dependency sh.calvin.reorderable:reorderable to v2.5.0 (#2398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-07 07:00:06 +00:00
renovate[bot] c0ab14e241 Update dependency com.google.devtools.ksp to v2.2.0-RC2-2.0.1 (#2394)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 07:59:42 +00:00
renovate[bot] 23332bf4a6 Update dependency com.squareup.logcat:logcat to v0.2.3 (#2395)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 07:59:26 +00:00
renovate[bot]andDude so hot a55293448e Convert awaitSelectTime to use TimePickerDialog (#2393)
* Convert awaitSelectTime to use TimePickerDialog

* fixup! Convert awaitSelectTime to use TimePickerDialog

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2025-06-06 15:53:11 +08:00
FooIbar f809d724a0 Reapply "Store pToken in GalleryPreview and refine parser" (#2390)
This reverts commit 5b8d3a2824.

Reason for revert: Reland with fix

Also clear invalid pToken
2025-06-05 22:36:57 +08:00
renovate[bot] 32e2e91013 Update dependency gradle to v8.14.2 (#2392)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-05 14:03:29 +00:00
renovate[bot] 5b8d3a2824 Revert "Store pToken in GalleryPreview and refine parser" (#2390) 2025-06-05 07:17:26 +00:00
renovate[bot] d4b152e7c0 Update dependency androidx.compose:compose-bom-alpha to v2025.06.00 (#2388)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-05 01:58:41 +00:00
renovate[bot] c62c84ae4d Update dependency com.squareup.logcat:logcat to v0.2.2 (#2387)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-05 01:58:06 +00:00
renovate[bot] 9e1869edde Update dependency androidx.test.uiautomator:uiautomator to v2.4.0-alpha04 (#2386)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-04 21:12:45 +00:00
renovate[bot] 1a164ff8f1 Update dependency androidx.lifecycle:lifecycle-process to v2.9.1 (#2385)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-04 21:12:28 +00:00
renovate[bot] 6b3a4aec16 Update benchmark to v1.4.0-beta02 (#2383)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-04 18:36:38 +00:00
renovate[bot] 150a687f15 Update dependency androidx.appcompat:appcompat to v1.7.1 (#2384)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-04 18:36:08 +00:00
FooIbar fc1d514475 Store pToken in GalleryPreview and refine parser
We no longer save previews in bundle.
2025-06-04 23:04:30 +08:00
renovate[bot] 5528bef09a Update kotlin monorepo to v2.2.0-RC2 (#2382)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-04 14:06:19 +00:00
FooIbar 4013740429 Enable simd feature for tl
Since we use nightly anyway.
2025-06-04 21:57:05 +08:00
FooIbar 000a932ff1 Migrate profile parser to rust 2025-06-03 23:58:52 +08:00
FooIbar 60cd7728e6 Migrate archive parser to rust 2025-06-02 22:52:40 +08:00
renovate[bot] 4f37088e20 Lock file maintenance (#2377)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 00:30:31 +00:00
renovate[bot] d7dc6487d0 Update dependency libarchive to v3.8.1 (#2376)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-01 21:41:37 +00:00
renovate[bot] 61c9dcbd1d Update dependency io.github.petertrr:kotlin-multiplatform-diff to v1.1.0 (#2374)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-01 13:59:15 +00:00
FooIbar 8f0404242d Remove ConstraintLayout usage 2025-06-01 16:03:29 +08:00
FooIbar 286af0e5bf Do not package OkHttp public suffix list
We handle cookies in ktor.
2025-05-31 15:24:34 +08:00
renovate[bot]andFooIbar 6c49904bf8 Update aboutlibraries to v12.2.0 (#2370)
* Update dependency com.mikepenz.aboutlibraries.plugin to v12.2.0

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2025-05-31 04:55:01 +00:00
renovate[bot] 509ee08e8e Update dependency com.squareup.okhttp3:okhttp-bom to v5.0.0-alpha.16 (#2369)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-29 18:20:34 +00:00
renovate[bot] fdae6f52cc Update dependency com.squareup.okhttp3:okhttp-bom to v5.0.0-alpha.15 (#2368)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-29 16:04:01 +00:00
renovate[bot] 52ef7cd956 Update AGP to v8.10.1 (#2367)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 22:54:12 +00:00
renovate[bot] d8bd81a0b6 Update plugin spotless to v7.0.4 (#2365)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 03:06:30 +00:00
renovate[bot] c2e33c7f50 Update dependency com.squareup.okio:okio-jvm to v3.12.0 (#2366)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 03:06:07 +00:00
renovate[bot] a9498a1b92 Update dependency chrome to v137 (#2364)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-27 20:44:45 +00:00
FooIbar f74b8373e3 Fix predictive back crash after launch page change
https://issuetracker.google.com/384186542
2025-05-27 22:34:41 +08:00
FooIbar 809f628bc0 Fix stop all shortcut with no active downloads
The downloading notification would keep showing.
2025-05-26 23:38:46 +08:00
FooIbar 54a2b28219 Auto backup improvements
- Fix backup data size quota exceed
- Backup cookies and preferences on Android 9+ (require E2EE)
2025-05-26 21:20:47 +08:00
FooIbar 0719c53e86 Restore accidently removed @Serializable on Limits 2025-05-26 15:23:29 +08:00
renovate[bot] e9f3deaec0 Lock file maintenance (#2359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-26 02:38:06 +00:00
FooIbar 8207be5940 Reapply "Migrate from Parcelize to kotlinx.serialization" (#2357)
* Reapply "Migrate from Parcelize to kotlinx.serialization" (#2356)

This reverts commit c2e53cc8b9.

* Fix stale states captured by lambdas in ViewModels
2025-05-25 22:27:16 +08:00
FooIbar c2e53cc8b9 Revert "Migrate from Parcelize to kotlinx.serialization" (#2356)
This reverts commit 857b1d9b57.

Reason for revert: https://github.com/FooIbar/EhViewer/issues/2355
2025-05-23 16:21:11 +00:00
FooIbar 251b99f76d Fix snackbar blocking progress dialog dismissal 2025-05-23 23:21:01 +08:00
Dude so hot c4c86104b5 Revert "MetaState trigger refresh implicitly (#2350)"
This reverts commit 277deace47.
Revert reason: `ListUrlBuilder` doesn't have a qualified `equals` semantics

# Conflicts:
#	app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt
#	app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt
2025-05-23 20:20:47 +08:00
FooIbar 857b1d9b57 Migrate from Parcelize to kotlinx.serialization
Also remove some unnecessary Parcelables.
2025-05-23 18:22:10 +08:00
renovate[bot] 277deace47 MetaState trigger refresh implicitly (#2350) 2025-05-23 17:09:08 +08:00
renovate[bot] 30c659110f Migrate to context parameters 2025-05-23 04:57:48 +00:00
renovate[bot] a8b6adfba2 Update dependency gradle to v8.14.1 (#2349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-22 14:27:56 +00:00
FooIbar 1548e16fbe Avoid drawing invisible slider ticks and cleanup (#2346) 2025-05-22 10:03:14 +08:00
FooIbar 0e34baf1bf Enable composition stack traces in debug builds 2025-05-21 20:48:45 +08:00
renovate[bot] 2c461b5ccf Update dependency androidx.datastore:datastore to v1.1.7 (#2315)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-21 02:03:56 +00:00
renovate[bot] 2adbeddde3 Update dependency androidx.test.uiautomator:uiautomator to v2.4.0-alpha03 (#2344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-20 22:31:59 +00:00
renovate[bot] e0a79fcc63 Update dependency androidx.compose:compose-bom-alpha to v2025.05.01 (#2342)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-20 18:04:47 +00:00
renovate[bot] 5e07813a57 Update dependency androidx.biometric:biometric to v1.4.0-alpha04 (#2341)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-20 18:04:15 +00:00
FooIbar 400d478bca Update build scripts for Kotlin 2.2
- Don't generate compatibility bridges and `DefaultImpls` classes
- Remove when guards compiler option as it became stable
- Address deprecation of `kotlinOptions`
2025-05-21 00:58:26 +08:00
renovate[bot]andrenovate[bot] 786ea2ca37 Update dependency libarchive to v3.8.0 (#2338)
* Update dependency libarchive to v3.8.0

* Update 0002-Fix-zip_time-performance.patch

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <99822064+revonateB0T@users.noreply.github.com>
2025-05-20 15:52:52 +00:00
Dude so hot ba169d35a4 Kotlin 2.2.0-RC 2025-05-20 23:22:23 +08:00
FooIbar f2dbb31951 Reader app bars improvements (#2337)
* Flatten reader app bars layout

* Fix reader app bars contract on grey background

* Fix unexpected reader app bars visibility changes
2025-05-20 20:01:24 +08:00
renovate[bot] fe371f1d66 Update dependency me.saket.telephoto:zoomable to v0.16.0 (#2336)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-20 07:31:22 +00:00
renovate[bot]andFooIbar 1b8ae87b08 Update dependency com.pinterest.ktlint:ktlint-cli to v1.6.0 (#2335)
* Update dependency com.pinterest.ktlint:ktlint-cli to v1.6.0

* Fix spotless

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2025-05-20 05:27:44 +00:00
renovate[bot] fece5f64ce Update dependency com.squareup.logcat:logcat to v0.2.1 (#2334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 21:01:00 +00:00
FooIbar bcedbd85ef Image loading optimizations
- Fix potential memory cache miss
- Reduce `SizeResolver` allocations
- Make data source type more accurate
2025-05-19 21:36:27 +08:00
renovate[bot]andFooIbar dc4ea5e96d Update dependency com.squareup.logcat:logcat to v0.2 (#2331)
* Update dependency com.squareup.logcat:logcat to v0.2

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2025-05-19 04:02:24 +00:00
renovate[bot] 7e0e01295f Lock file maintenance (#2332)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 02:42:29 +00:00
FooIbar 88268a22a9 Migrate to M3 Expressive medium flexible app bar 2025-05-18 23:51:49 +08:00
FooIbar 428a1cb0d2 Fix download progress indicator recomposition 2025-05-18 19:11:23 +08:00
FooIbar b3dda2a1ff Migrate to M3 Expressive docked toolbar 2025-05-17 23:10:17 +08:00
FooIbar 74f9330c38 Migrate to M3 Expressive progress indicators 2025-05-17 23:10:17 +08:00
FooIbar ebf99eda55 Migrate to M3 Expressive loading indicator 2025-05-17 23:10:17 +08:00
FooIbar 7d05e5382a Use LazyLayoutCacheWindow API for prefetching (#2325)
* Use `LazyLayoutCacheWindow` API for prefetching

With the old strategy, prefetched images may be evicted from cache.

* Make decoding process non-cancellable
2025-05-17 23:10:05 +08:00
FooIbar e559520df3 Minor cleanup 2025-05-17 14:39:36 +08:00
renovate[bot] a9b300d357 Update dependency io.arrow-kt:arrow-stack to v2.1.2 (#2326)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-16 14:55:38 +00:00
renovate[bot] f58f930446 Update xmlutil to v0.91.1 (#2324)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-15 20:17:07 +00:00
FooIbar f10bf6d714 Add back desktop site option to bypass Cloudflare (#2323) 2025-05-15 21:49:21 +08:00
FooIbar 6898300c30 Fix quick search import 2025-05-14 21:42:46 +08:00
FooIbar 85bc6fdd28 Show favorite status in grid view 2025-05-14 18:49:13 +08:00
renovate[bot] 4cdf5a8ba6 Update dependency com.google.devtools.ksp to v2.1.21-2.0.1 (#2320)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 23:28:34 +00:00
renovate[bot] 8ca220e98a Update kotlin monorepo to v2.1.21 (#2318)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 13:10:29 +00:00
renovate[bot] ec2d37b295 Update dependency io.coil-kt.coil3:coil-bom to v3.2.0 (#2317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 07:15:26 +00:00
renovate[bot] 3b2118331b Lock file maintenance (#2316)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-12 02:28:01 +00:00
renovate[bot] ed89e504b0 Update dependency androidx.compose:compose-bom-alpha to v2025.05.00 2025-05-08 08:57:53 +08:00
renovate[bot] 30eec80177 Revert "Update dependency androidx.datastore:datastore to v1.1.6 (#2311)"
This reverts commit 07f730aba6.
2025-05-08 08:39:34 +08:00
renovate[bot] 07f730aba6 Update dependency androidx.datastore:datastore to v1.1.6 (#2311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-07 23:06:32 +00:00
renovate[bot] 3bfd282614 Update dependency androidx.test.uiautomator:uiautomator to v2.4.0-alpha02 (#2312)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-07 23:06:13 +00:00
renovate[bot] d031954976 Update androidx.lifecycle to v2.9.0 (#2309)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-07 20:03:27 +00:00
renovate[bot] 86f9b9d0f7 Update benchmark to v1.4.0-beta01 (#2310)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-07 20:02:42 +00:00
renovate[bot] 3b1f98d675 Update aboutlibraries to v12.1.2 (#2308)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-07 12:32:00 +00:00
renovate[bot] 66e37c8f6b Update AGP to v8.10.0 (#2307)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 20:14:29 +00:00
renovate[bot] 3fc6adb2b7 Update aboutlibraries to v12.1.1 (#2306)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 20:10:53 +00:00
renovate[bot] 1c971c161f Update dependency io.ktor:ktor-client-okhttp to v3.1.3 (#2305)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 12:35:34 +00:00
renovate[bot] 8883d048a7 Lock file maintenance (#2304)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 02:27:47 +00:00
renovate[bot] 66dbbb1eee Update compose.destinations to v2.2.0 (#2303)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-03 14:28:14 +00:00
renovate[bot] cb7bbeba99 Update compose.destinations to v2.1.1 (#2302)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-01 15:38:02 +00:00
renovate[bot] 7bf8de139f Update aboutlibraries to v12.1.0 (#2301)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-01 07:56:40 +00:00
renovate[bot] b7ffc255f5 Update dependency com.google.devtools.ksp to v2.1.20-2.0.1 (#2300)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-01 01:45:22 +00:00
renovate[bot] 06aa4c60c8 Update dependency chrome to v136 (#2299)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 19:49:06 +00:00
renovate[bot] 07e4db9240 Update dependency io.arrow-kt:arrow-stack to v2.1.1 (#2298)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 15:06:38 +00:00
renovate[bot] c0144809c6 Update dependency org.jsoup:jsoup to v1.20.1 (#2297)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 06:41:59 +00:00
renovate[bot] ffeadc9e44 Update dependency com.google.accompanist:accompanist-drawablepainter to v0.37.3 (#2296)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-28 12:50:41 +00:00
renovate[bot] a15ed69624 Lock file maintenance (#2295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-28 02:03:03 +00:00
renovate[bot] b0fd40444f Update aboutlibraries to v12.1.0-rc04 (#2294)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-27 19:12:27 +00:00
Dude so hot 8cddc0d974 Fix DownloadScreen thumb flickering on select state changed 2025-04-27 16:31:49 +08:00
renovate[bot] 08b76b643d Update aboutlibraries to v12.1.0-rc03 (#2291)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-26 18:52:25 +00:00
renovate[bot] 13c623b788 Update aboutlibraries to v12.1.0-rc02 (#2286) 2025-04-26 13:04:04 +00:00
renovate[bot] b77bd6b3f9 Update dependency gradle to v8.14 (#2290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-25 12:14:05 +00:00
FooIbar d3dec6981a Update aboutlibraries to v12.1.0-rc01 2025-04-25 20:10:26 +08:00
renovate[bot] 5dd617be69 Update dependency Corrosion to v0.5.2 (#2288)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-25 03:03:58 +00:00
FooIbar 1b093469bf Update NDK to r28b 2025-04-24 23:14:18 +08:00
FooIbar 7286c81885 Address deprecations 2025-04-24 23:06:28 +08:00
FooIbar 4ef7707736 Address warnings 2025-04-24 23:06:28 +08:00
renovate[bot] c06a9f915d Update dependency androidx.compose:compose-bom-alpha to v2025.04.01 (#2283)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-24 03:08:28 +00:00
renovate[bot] fe2907974a Update dependency androidx.work:work-runtime to v2.10.1 (#2282)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-24 03:08:09 +00:00
renovate[bot] 3e79617fc9 Update dependency androidx.core:core-splashscreen to v1.2.0-beta02 (#2280)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-23 23:23:19 +00:00
renovate[bot] 52e2fe6fed Update androidx.room to v2.7.1 (#2279)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-23 20:12:58 +00:00
renovate[bot] 8e4265a5f8 Update androidx.lifecycle to v2.9.0-rc01 (#2278)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-23 20:12:19 +00:00
renovate[bot] 623de00376 Update AGP to v8.9.2 (#2276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-21 19:53:25 +00:00
renovate[bot] 6320900d7c Lock file maintenance (#2274)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-21 03:50:56 +00:00
FooIbar ab1ff284ca [no ci] Fix renovate config 2025-04-19 00:10:49 +08:00
renovate[bot]andFooIbar 37ad3d0882 Update dependency com.mikepenz.aboutlibraries.plugin to v12.0.0 (#2269)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2025-04-18 16:04:54 +00:00
renovate[bot] c94123da06 Update aboutlibraries to v12.0.0 (#2268)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-18 15:50:02 +00:00
FooIbar 7eef1727a2 Revert "Update aboutlibraries to v12.0.0-b03 (#2264)"
This reverts commit aefca644e8.
2025-04-17 20:37:29 +08:00
FooIbar 6f692db069 Clean up renovate config
KSP2 is no longer a compiler plugin so the Kotlin version doesn't matter
2025-04-17 20:36:09 +08:00
renovate[bot] aefca644e8 Update aboutlibraries to v12.0.0-b03 (#2264)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-17 12:29:06 +00:00
FooIbar 99ff71b7e1 Update aboutlibraries to v12.0.0-b03 2025-04-17 20:21:00 +08:00
FooIbar 430ee19dd3 Allow users to disable QUIC support 2025-04-15 21:20:06 +08:00
renovate[bot] d9dbafb51b Lock file maintenance (#2260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 02:57:32 +00:00
FooIbar d78b3a7fa0 Revert "Update dependency androidx.compose:compose-bom-alpha to v2025.04.00 (…"
This reverts commit d753c36195.
2025-04-10 15:36:20 +08:00
renovate[bot] 424dc6dd49 Update dependency com.squareup.okio:okio-jvm to v3.11.0 (#2256)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-10 07:05:21 +00:00
renovate[bot] 1f4347a316 Update dependency androidx.core:core-ktx to v1.16.0 (#2255)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-10 07:05:04 +00:00
renovate[bot] acd9f2ae1c Update benchmark to v1.4.0-alpha11 (#2253)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-10 04:02:17 +00:00
renovate[bot] d753c36195 Update dependency androidx.compose:compose-bom-alpha to v2025.04.00 (#2254)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-10 04:01:51 +00:00
renovate[bot] 7dd0deb931 Update androidx.room to v2.7.0 (#2251)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-09 22:39:22 +00:00
renovate[bot] ba1126db0e Update androidx.lifecycle to v2.9.0-beta01 (#2250)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-09 22:38:47 +00:00
renovate[bot] 359a417692 Drop obsolete argument 2025-04-09 13:36:40 +08:00
renovate[bot] 42ac0055c2 Update dependency com.google.devtools.ksp to v2.1.20-2.0.0 (#2248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-09 03:34:53 +00:00
renovate[bot] c0dd03e3f7 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.10.2 (#2247)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 12:17:46 +00:00
renovate[bot] 6b1bda629f Update plugin spotless to v7.0.3 (#2246)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 03:35:18 +00:00
renovate[bot] 0b43daf507 Lock file maintenance (#2245)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 01:50:31 +00:00
renovate[bot] 894038280e Update plugin composeCompilerReportGenerator to v1.4.2 (#2244)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-06 13:38:49 +00:00
renovate[bot] 2704ab9afb Update dependency liblzma to v5.8.1 (#2241)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 15:41:55 +00:00
FooIbar 8d604848da Revert "Update dependency androidx.compose:compose-bom-alpha to v2025.03.01 (#2222)"
This reverts commit ef2cfec1a2.

Reason for revert: https://issuetracker.google.com/408118041
2025-04-03 23:41:36 +08:00
renovate[bot] 6f6f123880 Update dependency chrome to v135 (#2239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-01 22:17:50 +00:00
renovate[bot] 620b037a17 Update xmlutil to v0.91.0 (#2237)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-01 16:43:56 +00:00
renovate[bot] a653454e7d Update kotlinx.serialization to v1.8.1 (#2236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-01 16:43:29 +00:00
renovate[bot] 38a8c9eb34 Lock file maintenance (#2235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 03:47:06 +00:00
renovate[bot] 8cdf16a272 Update dependency libarchive to v3.7.9 (#2234)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-30 22:28:13 +00:00
renovate[bot] 24333a24ae Lock file maintenance (#2231)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-29 14:37:55 +00:00
FooIbar e83c542886 Update androidx.biometric to v1.4.0-alpha03 2025-03-28 19:30:03 +08:00
FooIbar c61824b362 Update xmlutil to 0.91.0-RC1 2025-03-28 17:07:40 +08:00
renovate[bot] 591f918412 Update dependency io.ktor:ktor-client-okhttp to v3.1.2 (#2226)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-27 12:10:24 +00:00
renovate[bot] ab1335102a Update dependency com.google.devtools.ksp to v2.1.20-1.0.32 (#2225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-27 06:49:34 +00:00
renovate[bot] 41cdc33432 Update dependency androidx.datastore:datastore to v1.1.4 (#2224)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-27 06:49:09 +00:00
renovate[bot] ef2cfec1a2 Update dependency androidx.compose:compose-bom-alpha to v2025.03.01 (#2222)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-27 02:34:31 +00:00
renovate[bot] 6f2a5d8cdb Update benchmark to v1.4.0-alpha10 (#2221)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-27 02:33:52 +00:00
renovate[bot] c3d52d430a Update androidx.room to v2.7.0-rc03 (#2220)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 23:08:55 +00:00
renovate[bot] 806897adf8 Update androidx.lifecycle to v2.9.0-alpha13 (#2219)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 23:08:40 +00:00
renovate[bot] c9b1bc175a Update dependency com.materialkolor:material-kolor to v2.1.1 (#2218)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 17:03:09 +00:00
renovate[bot]andFooIbar f4d9d9795a Update dependency liblzma to v5.8.0 (#2216)
* Update dependency liblzma to v5.8.0

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2025-03-26 11:35:47 +00:00
renovate[bot] 08e6d670de Update dependency com.materialkolor:material-kolor to v2.1.0 (#2217)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-25 22:12:34 +00:00
renovate[bot] a2848d52fc Update AGP to v8.9.1 (#2214)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 20:43:22 +00:00
FooIbar fa219a674e Address warnings 2025-03-24 21:15:41 +08:00
renovate[bot] 0d0faa3218 Lock file maintenance (#2212)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 04:00:10 +00:00
renovate[bot] 09ab930c56 Revert "Disable shared element transition for DownloadCard"
This reverts commit e5361949df.
2025-03-21 23:58:25 +08:00
renovate[bot] 9b7b411825 Inline PageLoader user (#2209) 2025-03-21 22:03:37 +08:00
renovate[bot] aacde8871e Migrate j.u.c.a atomics usages to k.c.a (#2207) 2025-03-21 12:27:38 +00:00
Dude so hot 1aa1addcf7 Address NamedMutex warning 2025-03-21 11:12:06 +08:00
Dude so hot a305980a78 Address telephoto deprecated API 2025-03-21 11:12:06 +08:00
Dude so hot 0def055e8b Avoid contextual classes for incoming context parameters 2025-03-21 11:12:06 +08:00
Dude so hot 29e9291030 Update benchmark module sdk to 36 2025-03-21 10:41:33 +08:00
renovate[bot] e9015d95e4 Update dependency com.google.devtools.ksp to v2.1.20-1.0.31 (#2203)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-21 02:27:38 +00:00
renovate[bot] 9cedcc4395 Update Kotlin & KSP to v2.1.20 (#2200)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-20 12:53:37 +00:00
renovate[bot] 744d8f4262 Update dependency libarchive to v3.7.8 (#2201)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-20 12:53:07 +00:00
renovate[bot] d9efc7aa19 Lock file maintenance (#2199)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-17 03:12:28 +00:00
FooIbar 53525cffe9 Fix renovate config 2025-03-14 20:01:47 +08:00
renovate[bot] c1269ede63 Update dependency com.pinterest.ktlint:ktlint-cli to v1.5.0 (#2198)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-14 12:01:38 +00:00
FooIbar 6102a33397 Fix renovate config 2025-03-14 19:54:42 +08:00
FooIbar 192e8ef664 Target SDK 36 2025-03-14 17:41:45 +08:00
renovate[bot] 5778230ee4 Update dependency androidx.compose:compose-bom-alpha to v2025.03.00 (#2196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-14 02:21:50 +00:00
renovate[bot] 9e959306b1 Update benchmark to v1.4.0-alpha09 (#2195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-14 02:21:09 +00:00
renovate[bot] 7a7f4eb458 Update androidx.room to v2.7.0-rc02 (#2193)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-14 00:22:36 +00:00
renovate[bot] 050545411d Update androidx.lifecycle to v2.9.0-alpha12 (#2192)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-14 00:22:08 +00:00
FooIbar 28b394d5ea Tweak side sheet item style 2025-03-11 22:39:06 +08:00
renovate[bot] a3761dab35 Lock file maintenance (#2190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-10 01:46:33 +00:00
FooIbar 79ae51a973 Use KSP2 2025-03-08 14:30:16 +08:00
FooIbar b724286886 Drop obsolete code as HTTP/2 is fully supported (#2185) 2025-03-08 14:23:10 +08:00
renovate[bot] 9c52e789b3 Update Rust crate android_logger to 0.15 (#2188)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-06 22:52:03 +00:00
renovate[bot] 717a3740c6 Update compose.destinations to v2.1.0 (#2187)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-06 19:59:31 +00:00
renovate[bot] a8738b9ca9 Update dependency chrome to v134 (#2184)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 21:39:34 +00:00
renovate[bot] ea5dba02b1 Update AGP to v8.9.0 (#2183)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 20:03:51 +00:00
renovate[bot] 9fbcb59f32 Update dependency org.jsoup:jsoup to v1.19.1 (#2182)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 07:42:28 +00:00
renovate[bot] 9b312d4a71 Lock file maintenance (#2180)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 03:13:52 +00:00
renovate[bot] 480002e50d Update dependency com.google.devtools.ksp to v2.1.10-1.0.31 (#2179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-27 19:30:44 +00:00
renovate[bot] 9cc775f548 Update dependency io.github.petertrr:kotlin-multiplatform-diff to v1 (#2178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-27 07:14:57 +00:00
renovate[bot] 514e50da74 Update dependency androidx.datastore:datastore to v1.1.3 (#2177)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-27 07:14:10 +00:00
renovate[bot] 9dea931db0 Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.1 (#2175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-27 03:37:24 +00:00
renovate[bot] 12c60715d2 Update dependency androidx.core:core-splashscreen to v1.2.0-beta01 (#2176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-27 03:37:11 +00:00
renovate[bot] 23e0c45a52 Update androidx.room to v2.7.0-rc01 (#2173)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-26 22:34:54 +00:00
renovate[bot] c0c9c834e7 Update dependency androidx.activity:activity-compose to v1.10.1 (#2174)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-26 22:34:30 +00:00
renovate[bot] 9aaa0e8d36 Update AGP to v8.8.2 (#2171)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-26 19:16:57 +00:00
renovate[bot] 7bb4daabe2 Update androidx.lifecycle to v2.9.0-alpha11 (#2172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-26 19:13:22 +00:00
renovate[bot] b0203874af Update dependency gradle to v8.13 (#2170)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-25 12:13:03 +00:00
renovate[bot] 2ee757e0b2 Update dependency com.android.tools:desugar_jdk_libs to v2.1.5 (#2169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-25 12:08:33 +00:00
renovate[bot] b4db2885aa Update dependency io.ktor:ktor-client-okhttp to v3.1.1 (#2168)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 22:09:30 +00:00
FooIbar 4381237eb3 Use different color for selected items 2025-02-24 17:09:53 +08:00
renovate[bot] d91d333baa Lock file maintenance (#2165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 01:59:26 +00:00
FooIbar 5f6f979bce Comment about rememberSwipeToDismissBoxState usage 2025-02-21 20:32:15 +08:00
FooIbar 881b76fa3e Only add quick search when is not loading or error 2025-02-21 20:32:15 +08:00
renovate[bot] e92dc29896 Update dependency com.google.accompanist:accompanist-drawablepainter to v0.37.2 (#2161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-21 02:38:29 +00:00
renovate[bot] 07e64a30e4 Update dependency com.google.accompanist:accompanist-drawablepainter to v0.37.1 (#2159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-20 06:50:04 +00:00
FooIbar ac8765655f Fix lambdas in side sheet capturing outdated value 2025-02-19 22:13:47 +08:00
renovate[bot] b5c8cfd9f8 Lock file maintenance (#2158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-17 03:19:50 +00:00
renovate[bot] 71b11a3708 Update dependency me.saket.telephoto:zoomable to v0.15.1 (#2157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-16 22:50:56 +00:00
renovate[bot] 8040394150 Update dependency me.saket.telephoto:zoomable to v0.15.0 (#2155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-16 05:39:17 +00:00
FooIbar a07856a877 Revert "Update aboutlibraries to v11.6.0 (#2109)"
This reverts commit 6e990d3359.
2025-02-14 23:03:17 +08:00
renovate[bot] 6e990d3359 Update aboutlibraries to v11.6.0 (#2109)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-14 14:53:53 +00:00
renovate[bot] 031cdbf187 Update dependency com.google.devtools.ksp to v2.1.10-1.0.30 (#2151)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-14 06:30:41 +00:00
renovate[bot] 7198bda02e Update AGP to v8.8.1 (#2150)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-13 20:08:50 +00:00
renovate[bot] 18df20b675 Update dependency androidx.compose:compose-bom-alpha to v2025.02.00 (#2147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-13 00:54:52 +00:00
FooIbar 581e6d2e5c Fix CI channel update 2025-02-13 08:46:00 +08:00
renovate[bot] 91c92f9dac Update benchmark to v1.4.0-alpha08 (#2145)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-12 21:56:09 +00:00
renovate[bot] 886e84a0c5 Update dependency androidx.paging:paging-compose to v3.3.6 (#2146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-12 21:55:39 +00:00
renovate[bot] bfeeb955f0 Update androidx.lifecycle to v2.9.0-alpha10 (#2143)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-12 19:40:18 +00:00
renovate[bot] beebffa5fc Update androidx.room to v2.7.0-beta01 (#2144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-12 19:39:22 +00:00
FooIbar bf6085bdf9 Migrate to the new semantics-based Autofill APIs 2025-02-12 17:42:53 +08:00
FooIbar db064e788b Fix auto logout when logging in through WebView 2025-02-12 17:42:42 +08:00
renovate[bot] 23be3be21e Upgrade build tools to 35.0.1 2025-02-12 13:23:57 +08:00
renovate[bot] 02ab809c34 NDK r28 (#2140)
* Update settings.gradle.kts

* Update build.gradle.kts
2025-02-12 10:22:07 +08:00
renovate[bot] 4d538714e4 Update dependency io.ktor:ktor-client-okhttp to v3.1.0 (#2139)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-11 23:42:02 +00:00
FooIbar 43e968864d Fix app updater to bypass GitHub login requirement 2025-02-11 08:31:57 +08:00
renovate[bot] 03f01bb5a0 Update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.6.2 (#2136)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-10 16:57:44 +00:00
renovate[bot] a7a6f60ebf Lock file maintenance (#2134)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-10 01:13:54 +00:00
FooIbar 4debd0a292 Revert "Migrate to lyricist, Part 1 (#2014)"
This reverts commit 0e608d16
2025-02-08 11:02:50 +08:00
Re*Index. (ot_inc) f0fa010df4 Update Japanese translations (#2130)
* Update strings.xml

* Update strings.xml

* Update ja.md
2025-02-08 10:19:39 +08:00
renovate[bot] 005112043e Update dependency io.coil-kt.coil3:coil-bom to v3.1.0 (#2127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-05 01:15:50 +00:00
renovate[bot] 1d118af0f5 Update dependency chrome to v133 (#2126)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-04 18:53:28 +00:00
renovate[bot] fa0bfc256a Lock file maintenance (#2123)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-03 01:39:44 +00:00
renovate[bot] 602905ef25 Update dependency com.materialkolor:material-kolor to v2.0.2 (#2120)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-30 18:29:39 +00:00
renovate[bot] 1f429e4773 Update compose.destinations to v2.1.0-beta16 (#2119)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-30 18:29:23 +00:00
renovate[bot] 6ce6699355 Update dependency androidx.compose:compose-bom-alpha to v2025.01.01 (#2118)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-30 01:02:31 +00:00
renovate[bot] 3d46ae8440 Update benchmark to v1.4.0-alpha07 (#2117)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-30 01:02:13 +00:00
renovate[bot] bbbea1db9c Update androidx.lifecycle to v2.9.0-alpha09 (#2115)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-29 20:47:54 +00:00
renovate[bot] dc7f353e67 Update androidx.room to v2.7.0-alpha13 (#2116)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-29 20:46:47 +00:00
renovate[bot] 6ca6f14ad9 Update dependency com.google.devtools.ksp to v2.1.10-1.0.29 (#2114)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-29 02:02:30 +00:00
renovate[bot] 03fd2708cd Update Kotlin & KSP to v2.1.10 (#2113)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-27 16:29:40 +00:00
renovate[bot] 6149497885 Update dependency com.materialkolor:material-kolor to v2.0.1 (#2112)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-26 20:28:27 +00:00
FooIbar f810ef03a7 Enable strict resource shrinking mode (#2110) 2025-01-25 14:05:01 +00:00
FooIbar eeef444df7 Revert "Update aboutlibraries to v11.5.0 (#2106)"
This reverts commit 03769e6cf6.
2025-01-25 17:12:02 +08:00
renovate[bot] 03769e6cf6 Update aboutlibraries to v11.5.0 (#2106)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-24 18:07:31 +00:00
renovate[bot] b0bd759035 Update dependency io.arrow-kt:arrow-stack to v2.0.1 (#2105)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-24 18:06:30 +00:00
renovate[bot] eb92b9ca78 Update dependency gradle to v8.12.1 (#2104)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-24 14:13:16 +00:00
renovate[bot] 411ab8db38 Update dependency liblzma to v5.6.4 (#2103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-23 21:14:22 +00:00
renovate[bot] a679c17c64 Lock file maintenance (#2101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-20 01:12:10 +00:00
renovate[bot] 87b7c46eb5 Update dependency sh.calvin.reorderable:reorderable to v2.4.3 (#2099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-19 08:53:29 +00:00
renovate[bot]andDude so hot a8c0a05c28 Make DialogState Screen-Local (#2097)
* Prepare to make DialogState per Screen

* Remove some LocalDialogState usage

* Make DialogState Screen-Local

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2025-01-16 21:08:07 +08:00
Dude so hot 6ac512e749 Address Code Review 2025-01-16 20:22:34 +08:00
Dude so hot be55f3cf90 asyncState takes produce 2025-01-16 20:22:34 +08:00
Dude so hot cbbbc7f211 Rename delegateSnapshotUpdate to asyncState 2025-01-16 20:22:34 +08:00
Dude so hot 623c4c3e39 Remove unused functions 2025-01-16 20:22:34 +08:00
Dude so hot b0ab1f0f2d Make delegateSnapshotUpdate accept two lambda 2025-01-16 20:22:34 +08:00
renovate[bot]andDude so hot 733937ec74 Use androidx SearchBar InputField (#2095)
* Use androidx SearchBar InputField

* Fix

* Fix

* Address CR

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2025-01-16 11:31:30 +00:00
renovate[bot] 14a8818563 Update dependency androidx.compose:compose-bom-alpha to v2025 (#2094)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-16 01:30:16 +00:00
renovate[bot] 695d00506b Update dependency androidx.activity:activity-compose to v1.10.0 (#2092)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-15 20:28:43 +00:00
renovate[bot] e1a90a79ec Update dependency androidx.datastore:datastore to v1.1.2 (#2093)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-15 20:28:22 +00:00
renovate[bot] c6cedf7a02 Update dependency chrome to v132 (#2091)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-15 02:02:49 +00:00
renovate[bot] 9d870a3781 Update plugin spotless to v7.0.2 (#2090)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-14 22:15:09 +00:00
renovate[bot] e0a33f3013 Lock file maintenance (#2089)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-13 01:29:24 +00:00
FooIbar e3e95788b4 Clean up build script and address some warnings 2025-01-12 12:18:28 +08:00
FooIbar 0e87595692 Fix gallery comment style by not reusing Spans 2025-01-11 21:35:54 +08:00
FooIbar e5361949df Disable shared element transition for DownloadCard 2025-01-11 16:39:06 +08:00
FooIbar 62290e2812 Fix favorite names & notes in gallery detail cache 2025-01-10 23:44:20 +08:00
renovate[bot] 489b82e8f4 Update AGP to v8.8.0 (#2083)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-09 22:21:08 +00:00
renovate[bot] 4e8d6e058b Update plugin com.android.settings to v8.8.0 (#2082)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-09 18:05:45 +00:00
renovate[bot] 535e3abe92 Update dependency com.squareup.okio:okio-jvm to v3.10.2 (#2079)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-08 21:45:13 +00:00
FooIbar 35fbd4443e Refine tag database updating 2025-01-08 23:41:16 +08:00
renovate[bot] edf667f462 Update plugin spotless to v7.0.1 (#2077)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-08 02:12:57 +00:00
renovate[bot] a22be909a7 Update dependency com.squareup.okio:okio-jvm to v3.10.1 (#2076)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-08 02:12:14 +00:00
FooIbar 915f6d0b46 Allow archives to have more pages than galleries (#2073) 2025-01-08 10:05:29 +08:00
FooIbar c23e7af24b Remove obsolete workaround
This reverts commit 8811f92aea.
2025-01-07 22:58:42 +08:00
renovate[bot] 91fdbaff4d Update plugin spotless to v7 (#2070)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-07 09:44:35 +00:00
renovate[bot] c6f258f01d Update dependency com.squareup.okio:okio-jvm to v3.10.0 (#2069)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-07 09:44:04 +00:00
FooIbar 8811f92aea Fix build 2025-01-07 17:37:02 +08:00
renovate[bot] 0726505c25 Update kotlinx.serialization to v1.8.0 (#2068)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-06 17:16:33 +00:00
renovate[bot] 4df1d6cb0a Lock file maintenance (#2067)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-06 00:09:20 +00:00
renovate[bot] 773b5d4dab Hide comment which score reaches -100 by default (#2065) 2025-01-04 02:49:42 +00:00
renovate[bot] 660fa66df4 Revert "Update aboutlibraries to v11.3.0 (#2063)"
This reverts commit c2992e3a35.
2025-01-04 10:42:52 +08:00
renovate[bot] c2992e3a35 Update aboutlibraries to v11.3.0 (#2063)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-03 18:35:50 +00:00
Dude so hot d07649bcf8 Cleanup unused code 2025-01-03 12:30:13 +08:00
Dude so hot 5e1baf2104 Recover removed author style 2025-01-03 12:30:13 +08:00
Dude so hot e8701b65c6 Drop HAtHBlacklist
immature implementation
2025-01-03 12:10:42 +08:00
Dude so hot 517d6f6d0b timeoutBySpeed accept OnTimeout handler 2025-01-03 12:10:42 +08:00
renovate[bot]andDude so hot b7c5c15280 Make timeoutSpeed pow of 2 (#2060)
* IntSliderPreference support display transforms

* Make timeoutSpeed pow of 2

* Fix conversion

* Fix conversion

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2025-01-03 11:45:21 +08:00
renovate[bot]andDude so hot 0561a9bcf6 H@H blacklist (#2058)
* Move SpeedTracker to proper place

* SpeedTracker use Instant to avoid value class in generic

* Initial HAtHBlacklist

* H@H blacklist switch

* Allow to trim H@H blacklist

* Don't blacklist if that H@H returned twice

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-12-31 19:46:28 +08:00
Dude so hot 05a14a3610 And balance retry times 2024-12-31 18:15:34 +08:00
Dude so hot b3737448d7 Don't retry at one H@H twice
Now that quota is cheap
2024-12-31 18:15:34 +08:00
renovate[bot]andDude so hot bfa91bf2fb Apply watchdog before send request (#2056)
* Apply watchdog before send request

* Cleanup CronetEngine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-12-31 17:55:39 +08:00
renovate[bot]andDude so hot b7948f7965 Introduce speed based timeout strategy (#2055)
* CronetEngine throw connTimeout properly

* Apply LowSpeedDetector

* Add switch

* Relax

* Refine

* Extract common part

* Apply to AppUpdater

* Fix

* Adjust

* Log conn timeout as well

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-12-31 15:25:00 +08:00
renovate[bot]andDude so hot b0f310b2fe Introduce connection timeout (#2054)
* Introduce connection timeout, Prepare to convert requestTimeout to speed based

* Fix step

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-12-31 13:15:42 +08:00
renovate[bot]andDude so hot ac598fdfab Introduce Windowed SpeedTracker (#2053)
* Remove unreachable cond in `humanReadableByteCount`

* Introduce Windowed SpeedTracker

* Cleanup

* Optimise SpeedTracker for startcase

* Fixup

* Update naming

* Fix

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-12-31 12:11:13 +08:00
renovate[bot] 60e6a04d0d Update dependency nettle to nettle_3.10.1_release_20241230 (#2052)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-30 19:45:06 +00:00
renovate[bot] c4884022af Lock file maintenance (#2051)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-30 01:29:22 +00:00
renovate[bot] a70091bf99 Update dependency Corrosion to v0.5.1 (#2050)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-29 17:46:35 +00:00
renovate[bot]andDude so hot ebaf75df95 Update Rust crate rxing to 0.7 (#2047)
* Update Rust crate rxing to 0.7

* Fix build

Signed-off-by: Dude so hot <djohn@fbi.gov>

---------

Signed-off-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-12-29 07:50:16 +00:00
FooIbar 2f044a17e1 Fix crash when download label query takes too long 2024-12-26 17:56:20 +08:00
renovate[bot] 782dafca33 Lock file maintenance (#2040)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-23 02:02:28 +00:00
FooIbar 7174bbc0bd Clean up build script 2024-12-22 14:59:21 +08:00
renovate[bot] f177045e89 Update compose.destinations to v2.1.0-beta15 (#2038)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-22 04:42:37 +00:00
renovate[bot] e32d66e0aa Update dependency gradle to v8.12 (#2037)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-20 21:06:52 +00:00
renovate[bot] 0237aa31cb Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.10.1 (#2036)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-20 16:23:04 +00:00
renovate[bot] 18921f8750 Update dependency com.android.tools:desugar_jdk_libs to v2.1.4 (#2035)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-20 10:57:58 +00:00
renovate[bot] e848360c80 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.10.0 (#2034)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-19 22:33:59 +00:00
FooIbar a075a7cba8 Make SpiderInfo optional 2024-12-19 21:06:49 +08:00
renovate[bot] f98630cff1 Update dependency io.ktor:ktor-client-okhttp to v3.0.3 (#2030)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-18 23:45:01 +00:00
FooIbar 325e756e4c Fix SpiderInfo serialization 2024-12-16 21:06:05 +08:00
renovate[bot] 556c623e98 Lock file maintenance (#2028)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-16 00:54:09 +00:00
FooIbar de5d74a1f0 Remove more java.io usage 2024-12-13 11:51:48 +08:00
renovate[bot] 41decf85c9 Update dependency com.google.accompanist:accompanist-drawablepainter to v0.37.0 (#2025)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-13 02:34:16 +00:00
renovate[bot] 51fa017b8b Update dependency sh.calvin.reorderable:reorderable to v2.4.2 (#2023)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-12 11:01:49 +00:00
renovate[bot] e78dd6f9dd Update dependency androidx.compose:compose-bom-alpha to v2024.12.01 (#2022)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-12 01:00:10 +00:00
renovate[bot] 0e8fcdd4d1 Update dependency androidx.paging:paging-compose to v3.3.5 (#2021)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-12 00:59:17 +00:00
renovate[bot] 717fc422ff Update benchmark to v1.4.0-alpha06 (#2019)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-11 22:05:33 +00:00
renovate[bot] b07c4b7e6c Update dependency androidx.activity:activity-compose to v1.10.0-rc01 (#2020)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-11 22:05:20 +00:00
renovate[bot] ff2b4cf6e1 Update androidx.room to v2.7.0-alpha12 (#2018)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-11 19:15:18 +00:00
renovate[bot] a131fa3dd7 Update androidx.lifecycle to v2.9.0-alpha08 (#2017)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-11 19:15:01 +00:00
FooIbar b3a212c7fd Match platform language resolution behavior
And add non-Compose accessor for Strings.
2024-12-11 00:06:07 +08:00
0e608d169e Migrate to lyricist, Part 1 (#2014)
* Initial convert xml to kotlin i10n res

* Composing take Translations

* Move HistoryScreen to lyricist

* Move FavouritesScreen to lyricist

* Fix naming

* Fixes

* Fixes

* Convert

* Convert

* Revert

* Fix

* Convert

* Convert

* Formats

* Cleanup

* Cleanup

* Cleanup

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Fixes

* Cleanup

* Cleanup

* Address Code Review

* Address Code Review

* Address Code Review

* Update app/src/main/kotlin/com/hippo/ehviewer/ui/i18n/ThTranslations.kt

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>

* Update app/src/main/kotlin/com/hippo/ehviewer/ui/i18n/KoTranslations.kt

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>

* Address Code Review

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-12-10 12:36:04 +00:00
renovate[bot] c38fa2a70f Drop useK2Uast (#2012)
Enabled by default
2024-12-09 10:46:26 +08:00
renovate[bot] 4c54c4653e Enable one pass resource shrinker 2024-12-09 10:38:29 +08:00
renovate[bot] 5ae5943cc8 Lock file maintenance (#2010)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-09 02:11:37 +00:00
renovate[bot] 857d367d15 Drop obsolete agp flags 2024-12-09 10:11:25 +08:00
renovate[bot]andDude so hot 8c766115db Refine EhTagDatabase & Cleanup java io usage
* Further kill some java io usage

* Further optimize TagDatabase update logic

* Further optimize TagDatabase

* Move some part from okio to kxio

* Address Code Review

* Fix to not reinit with current if inited

* Convert readLegacySpiderInfo to use kxio

* Refine

* Refine

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-12-08 14:30:56 +00:00
FooIbar 7bd36dd274 Fix comment anchors handling and ripple on click 2024-12-08 12:14:15 +08:00
FooIbar bd246300e0 Revert "Kill Legacy TextView (#1786)"
This reverts commit 62927cb6

Reason for revert: Bad performance
2024-12-08 00:07:21 +08:00
renovate[bot] 31be546e8e Update dependency io.arrow-kt:arrow-stack to v2.0.0 (#2003)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-05 16:21:07 +00:00
FooIbar d57983032e Allow using RGB_565 on API < 26
And increase PageLoader cache size on API 26+
2024-12-06 00:13:50 +08:00
FooIbar 53b3190bbf Make image links in comments clickable 2024-12-04 20:53:00 +08:00
FooIbar d924237b7e Update thumbnail key in database on the fly 2024-12-04 20:42:21 +08:00
da29fc2f25 Handle multiple image format (#1988)
* Polymorphic copy_region

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Polymorphic qr_code

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Polymorphic detect_border

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Implement luminance conversion

* Implement RGB_565 and RGBA_F16 support

* Revert "Workaround for high bit depth images"

This reverts commit 12f46e2e4e.

* Add ptr_as_image

Signed-off-by: Dude so hot <djohn@fbi.gov>

* ptr_as_image_mut

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Refine poly

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Refine

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Use fake channel

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Cleanup

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Naming

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Refine

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Cleanup

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Refine

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Use typealias

Signed-off-by: Dude so hot <djohn@fbi.gov>

* just unwarp

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Cleanup

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Just unwarp

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Address code review

Signed-off-by: Dude so hot <djohn@fbi.gov>

---------

Signed-off-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-12-04 19:30:46 +08:00
renovate[bot] 3bbf195870 Update dependency io.ktor:ktor-client-okhttp to v3.0.2 (#1998)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-03 19:14:24 +00:00
renovate[bot] 75dac72640 Update dependency org.jsoup:jsoup to v1.18.3 (#1997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-02 03:50:20 +00:00
renovate[bot] df9af9c35a Lock file maintenance (#1996)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-02 00:40:45 +00:00
FooIbar cccc8e829e Don't retry for some unrecoverable errors 2024-12-01 17:46:25 +08:00
FooIbar e0b1b18299 Support reverse physical key controls in reader 2024-12-01 17:46:13 +08:00
FooIbar 02d607817a Limit the scope of MIUI Cronet workaround
`ERROR_OTHER` is too generic and contains `ERR_CONTENT_LENGTH_MISMATCH`
2024-12-01 17:46:01 +08:00
FooIbar 8ae62e21a7 Clean up redundant v1 thumbnails 2024-12-01 17:45:47 +08:00
FooIbar 14a59262af Add missing .heic to supported file extensions 2024-12-01 13:10:10 +08:00
FooIbar 12f46e2e4e Workaround for high bit depth images 2024-12-01 13:09:47 +08:00
renovate[bot] dec2e6b69f Update dependency io.arrow-kt:arrow-stack to v2.0.0-rc.1 (#1987)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-29 11:25:41 +00:00
renovate[bot] e82327a979 Update dependency com.google.devtools.ksp to v2.1.0-1.0.29 (#1986)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-28 00:18:41 +00:00
renovate[bot] 81fb2ddcd1 Update dependency com.google.devtools.ksp to v2.1.0-1.0.28 (#1985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-27 23:19:58 +00:00
renovate[bot] a8922cd0b2 Update Kotlin & KSP to v2.1.0 (#1984)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-27 13:31:21 +00:00
renovate[bot] 4f3ea48ec7 Update dependency org.jsoup:jsoup to v1.18.2 (#1982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-27 05:11:59 +00:00
renovate[bot] 150265adf6 Update dependency io.coil-kt.coil3:coil-bom to v3.0.4 (#1981)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-26 09:35:14 +00:00
renovate[bot]andDude so hot aff627c0cd Implement standby behaviour (#1980)
* Implement standby behaviour

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-26 09:28:27 +00:00
renovate[bot]andDude so hot 2b803d4193 Draggable Dialog Bubble (#1979)
* Draggable Dialog Bubble

* Cleanup

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-26 14:30:04 +08:00
renovate[bot]andDude so hot 464fa7c0c9 Pausable Tagger Dialog (#1978)
* DialogState center & top to allow non `AlertDialog` content

* Initial DialogContent w/o AlertDialog

* Animate DialogContent bubble

* Simplify

* Protect DialogState with MutatorMutex

* Refine animation

* Refine impl

* Refine impl

* Refine impl

* Refine impl

* Refine impl

* Cleanup

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-26 13:41:14 +08:00
FooIbar e9564b1d33 Don't use all caps for uploader names
Uploader names are in UTF-8, `uppercase` may result in unexpected values
 in some locales.
2024-11-25 23:43:54 +08:00
FooIbar c75929789d Show favorite name in HistoryScreen 2024-11-25 20:13:09 +08:00
renovate[bot] 63b7f47f30 Lock file maintenance (#1975)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-25 02:38:44 +00:00
FooIbar 5650e6ccfe Fix tagger back handler 2024-11-24 19:52:57 +08:00
a0d3706497 Refine TagDatabase Suggestions (#1973)
* Refine rawSuggestSequence

* Lift

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine naming

* Refine naming

* Update app/src/main/kotlin/com/hippo/ehviewer/client/EhTagDatabase.kt

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-11-24 09:27:32 +00:00
FooIbar 0013ef42bd Fix crash when page URL's index is out of bound 2024-11-24 15:03:05 +08:00
Dude so hot 8da5f53856 Just override hue 2024-11-24 14:22:47 +08:00
renovate[bot]andDude so hot d0c150d419 Redesign Vote Status (#1970)
* Redesign Vote Status

* Revert "Redesign Vote Status"

This reverts commit 00295652f8eab9f91c1ef0ec1356e8eff838aaef.

* Redesign Vote Status

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-24 05:57:45 +00:00
f52b828fe1 Refine Tagger Dialog (#1969)
* Refine Tagger Dialog

* Fix limit

* Suggestions use sequence

* Update app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>

* Refine

* Refine

* Refine

* Naming

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-11-23 16:29:52 +00:00
8babadc33e Implement Tagger suggestion (#1968)
* Implement Tagger suggestion

* Refine suggestFlow impl

* Refine suggest

* Revert

* Refine display

* Refine

* Refine

* Refine

* Refine

* Update app/src/main/kotlin/com/hippo/ehviewer/client/EhTagDatabase.kt

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>

* Refine

* Refine

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-11-23 15:19:56 +00:00
7047de4959 Initial User Friendly Tag Inputer (#1966)
* Initial User Friendly Tag Inputer

* Address code review

* Update translation

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-11-23 10:52:30 +00:00
renovate[bot] 9b5e286b90 Update dependency io.arrow-kt:arrow-stack to v2.0.0-beta.3 (#1967)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-23 09:27:24 +00:00
renovate[bot]andDude so hot 99d98b6227 Support display tag vote status (#1965)
* Support parsing GalleryTag vote status

* Append utf-8 emoji vote status

* Refresh tag list after voting

* Refine to use substringAfter

* Fix implicitNulls

* Refine to use withDraw vote

* Recompose first

* Optimize Snackbar to be cancellable for freq action

* Fix GalleryDetail recompose jump

* Refine VoteStatus display

* Support parse WeakStatus Active

* Append instead

* Add switch

* Refine naming

* Fix fillInfo

* Drop correctly

* Refine

* Refine rememberInVM impl with key

* Be type safe

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-23 07:44:23 +00:00
FooIbar 03a343784d Log Cronet errors before restarting app 2024-11-21 15:01:50 +08:00
Dude so hot 046350c0f9 Bump ArrowKt to 2.0.0-beta.2 2024-11-21 14:22:03 +08:00
renovate[bot] a1f267916e Update dependency gradle to v8.11.1 (#1959)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-20 21:46:50 +00:00
Dude so hot a4b0c1fe36 TightRope tolerant cancellation for 200ms 2024-11-20 21:49:55 +08:00
renovate[bot] 6aa0878480 Fix animated image (#1956)
* Fix animated image

* Update PagerItem.kt
2024-11-20 09:51:53 +00:00
FooIbar 897a56fa4a Fix rewrite GIF to actually match browser behavior 2024-11-20 14:51:38 +08:00
renovate[bot] 86a1e405b9 Update Kotlin & KSP 2024-11-20 08:18:33 +08:00
renovate[bot] e33fdaf74d Fix crossfade 2024-11-20 02:34:07 +08:00
renovate[bot]andDude so hot a554982f08 Refine MergeInterceptor to not cancel current job when awaiter exists (#1950)
* Refine MergeInterceptor to not cancel current job when awaiter exists

* Refine

* Cleanup abstraction

* Cleanup abstraction

* Refine

* Refine

* Fix MutableSharedFlow skip f when no awaiters

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-19 18:09:27 +00:00
renovate[bot]andDude so hot 553fa9245c Refine Thumb Preload Strategy (#1948)
* PrefetchAround for GalleryList

* Drop Preload Aggressively

* Refine

* Revert "Drop Preload Aggressively"

This reverts commit 0a8d5d083b509fb95c13c7674eca81f2770a92ab.

* Cleanup

* Fix

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-19 14:59:36 +08:00
renovate[bot]andDude so hot f862013c6a Prefetch around v2 preview image (#1947)
* Drop unused field in GalleryDetail

* Let GalleryPreview url prefetchDistance just be pageSize

* Prefetch around GalleryPreview image

* Optimize

* Optimize

* Refine semantics

* Perform decode for +-20 prefetch

* Refine

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-19 05:50:20 +00:00
renovate[bot]andDude so hot 7dd87c1ff6 Workaround to not invalidate shared element modifier chain (#1944)
* Try to fix SET

* Drop check

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-18 21:58:45 +08:00
renovate[bot] 900da46090 Lock file maintenance (#1943)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 01:45:10 +00:00
Dude so hot 7d54b78521 ColorFilterSetting use RollingNumber 2024-11-17 18:59:14 +08:00
Dude so hot ccc7d467c2 Refine RollingNumber to animate negative when fixed length 2024-11-17 18:59:14 +08:00
FooIbar 8b6c358266 Avoid consuming click event if the image is loaded 2024-11-16 18:53:50 +08:00
renovate[bot] 444388e432 Update dependency io.coil-kt.coil3:coil-bom to v3.0.3 2024-11-16 14:28:24 +08:00
FooIbar b4c00d866c Play safe 2024-11-16 14:21:52 +08:00
Dude so hot b9606fcb49 Fix image jumping 2024-11-14 23:18:55 +08:00
Dude so hot ab6dfa0f08 Fix image recycle racing 2024-11-14 23:02:49 +08:00
FooIbar 81ee3c47f5 Fix reading UTF-8 flagged ZIP archives on API < 26 2024-11-14 21:36:03 +08:00
renovate[bot] 7a59f821cf Update dependency me.saket.telephoto:zoomable to v0.14.0 (#1933)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-14 11:30:50 +00:00
renovate[bot]andDude so hot 6f7276bcdf Revert "Temporarily disable shared element transitions (#1621)" (#1932)
* Revert "Temporarily disable shared element transitions (#1621)"

This reverts commit 42ed51094e.

* Fixes

* Update EhAsyncThumb.kt

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-14 19:24:11 +08:00
renovate[bot]andDude so hot 2cd884bd9d Hack zip_time to fix performance (#1931)
* Hack zip_time to fix performance

* Revert

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-14 06:33:13 +00:00
renovate[bot] 40f4554373 Update dependency androidx.compose:compose-bom-alpha to v2024.11.00 (#1930)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-14 03:12:22 +00:00
renovate[bot] 76321d3491 Update dependency androidx.activity:activity-compose to v1.10.0-beta01 (#1928)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 22:29:30 +00:00
renovate[bot] c3b5db18a7 Update dependency androidx.paging:paging-compose to v3.3.4 (#1929)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 22:28:56 +00:00
renovate[bot] 71afc90b0e Update androidx.lifecycle to v2.9.0-alpha07 (#1926)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 20:05:49 +00:00
renovate[bot] c03d08b9f4 Update benchmark to v1.4.0-alpha05 (#1927)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 20:05:34 +00:00
renovate[bot] b9d8931f3e Update dependency chrome to v131 (#1925)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-12 19:59:48 +00:00
renovate[bot]andDude so hot ec8bd2fc14 Add rust parser test (#1924)
* Add rust parser test

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Update cargo lock

Signed-off-by: Dude so hot <djohn@fbi.gov>

---------

Signed-off-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-12 08:31:49 +00:00
renovate[bot]andDude so hot dd1bbf1226 Ensure PageLoader not closed until decoder jobs completed (#1923)
* Optimize to use autoCloseScope

* Refine log

* Log

* Remove debug logging

* Refine

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-12 12:15:29 +08:00
renovate[bot] c2c86ff58e Update dependency gradle to v8.11 (#1922)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-11 17:02:39 +00:00
FooIbar 2d2840c1bb Prepare for next iteration 2024-11-11 21:14:59 +08:00
FooIbar 571969fca9 Prepare for release 1.13.1
Releases / build (push) Has been cancelled
2024-11-11 21:14:29 +08:00
FooIbar 16fa52790c Misc cleanups (#1921)
* Clean up resources

* Clean up code
2024-11-11 20:39:45 +08:00
FooIbar c88c252587 Update README (#1920)
[no ci]
2024-11-11 17:19:43 +08:00
FooIbar 253cb03de8 Refine AndroidFileSystem exception handling 2024-11-11 17:19:17 +08:00
renovate[bot] 0b74adc13b Lock file maintenance (#1917)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-11 01:17:16 +00:00
FooIbar 6fc2328475 Fix false positive download failures on API 28 2024-11-11 00:52:12 +08:00
renovate[bot] e9c4201acc Update dependency io.coil-kt.coil3:coil-bom to v3.0.2 (#1912)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-10 04:10:41 +00:00
FooIbar 9edb52c909 Remove desktop site option 2024-11-10 00:21:53 +08:00
FooIbar f4c7320bb5 Remove search cover feature due to upstream change 2024-11-09 22:54:07 +08:00
FooIbar 17581ca204 Use desktop User-Agent by default
Since WebP resamples have smaller file size.
2024-11-09 20:56:14 +08:00
FooIbar 11edb8ce5d Error out on cover search for V2 galleries 2024-11-09 20:35:09 +08:00
FooIbar 40caa7b1eb Prefer to get page tokens from MPV 2024-11-09 19:44:12 +08:00
FooIbar e3f4c24983 Extend update download request timeout 2024-11-09 13:59:35 +08:00
renovate[bot]andDude so hot dd0482cb26 Allow ExchangeSite on Http 500 (#1905)
* Allow ExchangeSite on Http 500

* Fixup

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-09 13:38:23 +08:00
FooIbar 4052007de8 Implement retry on click for thumbnails 2024-11-09 11:27:10 +08:00
FooIbar 85b03d9d8a Propagate errors from writeComicInfo to callers 2024-11-08 16:06:55 +08:00
renovate[bot] 36c3fd6f5d Update dependency com.google.devtools.ksp to v2.1.0-RC-1.0.27 (#1902)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-08 07:15:42 +00:00
renovate[bot] d397a5d35e Update dependency com.android.tools:desugar_jdk_libs to v2.1.3 2024-11-07 09:59:04 +00:00
renovate[bot] 43d83ad919 Update dependency io.coil-kt.coil3:coil-bom to v3.0.1 2024-11-07 09:58:36 +00:00
FooIbar e71dd1d576 Improve scrollbar a11y 2024-11-06 23:44:48 +08:00
renovate[bot] 72e28b8e04 Update dependency com.google.devtools.ksp to v2.1.0-RC-1.0.26 2024-11-05 20:05:19 +00:00
renovate[bot] 3432a15c62 Update dependency io.github.pdvrieze.xmlutil:serialization to v0.90.3 2024-11-05 09:46:27 +00:00
renovate[bot]andDude so hot 45a5bae8b2 Group rust ffi code (#1893)
* Group rust ffi code

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Fix

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Fix

Signed-off-by: Dude so hot <djohn@fbi.gov>

* Refine

Signed-off-by: Dude so hot <djohn@fbi.gov>

---------

Signed-off-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-05 09:39:03 +00:00
a566393a89 Optimize to use decode buffer pool (#1890)
* Optimize to use decode buffer poll

* Drop unnecessary logging

* Refine

* Update app/src/main/cpp/archive.c

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-11-05 05:01:15 +00:00
renovate[bot] 5f2f3f9122 Update dependency io.coil-kt.coil3:coil-bom to v3.0.0 2024-11-04 22:53:10 +00:00
renovate[bot] c7fccf7e70 Update Kotlin & KSP to v2.1.0-RC 2024-11-04 20:24:35 +00:00
FooIbar 3d92d061f1 Fix archive size check 2024-11-04 21:15:07 +08:00
Dude so hot f2759675a1 Optimize to not allocate anon mapping when fully zero copy 2024-11-04 21:12:45 +08:00
Dude so hot 2e6ff78662 Fast fix for operator precedence 2024-11-04 20:57:10 +08:00
renovate[bot]andDude so hot 8ca0a32932 Cleanup to not handle memory allocation failure (#1882)
* Cleanup to not handle memory allocation failure

* Handle open failed on first open

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-04 12:06:38 +00:00
FooIbar 72fbcd4e0e Refine Cronet workaround 2024-11-04 18:43:16 +08:00
a5719a9c1d Optimize zero copy case to not iter over entries (#1879)
* Optimie zero copy case to not iter over entries

* Update app/src/main/cpp/archive.c

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-11-04 09:03:41 +00:00
Re*Index. (ot_inc) bad41d5027 Change funds text (#1878)
* Fix Japanese

* Change Funds text
2024-11-04 08:49:11 +00:00
renovate[bot] 3bfb95f190 Lock file maintenance 2024-11-04 00:09:54 +00:00
renovate[bot]andDude so hot f04695feb6 Refresh Avatar Url when load failed (#1873)
* Refresh Avatar Url when load failed

* Check 404

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-03 15:17:50 +00:00
renovate[bot]andDude so hot 94da911f93 Fix DrawerContent ColorScheme
* Fix listItemOnDrawerColor()

* Fix topBarOnDrawerColor

* Refine location

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-11-02 04:51:06 +00:00
Re*Index. (ot_inc) f4771d4ddb Fix Japanese 2024-11-02 11:06:44 +08:00
Re*Index. (ot_inc) 9f7c4adb0f Make Japanese README & Fix Japanese (#1867)
* Update Japanese

* Add Japanese README

* Update ja-jp.md

* Update ja-jp.md

* Update ja-jp.md

* Update ja-jp.md

* Update ja-jp.md

* Update ja-jp.md

* fix text & suffix.

* Update ja.md

* Update ja.md

* Update ja.md

* Update ja.md

* Add Minimum Android Version text
2024-11-01 23:41:58 +08:00
FooIbar 6fb20fc6c1 [no ci] Update README 2024-11-01 21:55:55 +08:00
FooIbar ce8e91aab8 Prepare for next iteration 2024-11-01 21:03:47 +08:00
279 changed files with 9759 additions and 7170 deletions

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
name: Baseline profile generation
on:
schedule:
- cron: '30 0 * * *'
workflow_dispatch:
push:
branches:
- '**'
paths:
- 'benchmark/**'
- '.github/workflows/baseline-profile.yml'
jobs:
baseline-profile:
runs-on: ubuntu-latest
steps:
- name: Free disk space
run: rm -rf $AGENT_TOOLSDIRECTORY
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Install Rust Toolchain
run: rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: "./app/src/main/rust"
- name: CMake Cache
uses: actions/cache/restore@v4
with:
path: app/.cxx
key: cmake-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
cache-read-only: true
- name: Generate Baseline Profile
run: ./gradlew generateBaselineProfile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
- name: Upload reports
if: failure()
uses: actions/upload-artifact@v4
with:
name: reports
path: benchmark/build/reports
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
java-version: 21
- name: Install Rust Toolchain
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
run: rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -61,7 +61,7 @@ jobs:
java-version: 21
- name: Install Rust Toolchain
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
run: rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -129,7 +129,7 @@ jobs:
java-version: 21
- name: Install Rust Toolchain
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
run: rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
- name: Rust Cache
uses: Swatinem/rust-cache@v2
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
java-version: 21
- name: Install Rust Toolchain
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
run: rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -40,12 +40,12 @@ jobs:
with:
path: app/.cxx
key: cmake-
restore-keys: cmake-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
cache-read-only: true
- name: Generate Baseline Profile
run: ./gradlew generateBaselineProfile
+1
View File
@@ -5,4 +5,5 @@
/.kotlin
*.iml
.idea
.vscode
/captures
+73
View File
@@ -1,5 +1,78 @@
# 变更日志
## [1.14.1] - 2025-07-31
### 改进
* 更新 GitHub 访问令牌
## [1.14.0] - 2025-07-23
### 重大变更
* 默认隐藏分数为 -100 的评论
* 迁移部分组件到 Material 3 Expressive
### 新功能
* [EH] 支持显示标签投票状态
* 支持添加画廊标签时自动补全
* [下载] 支持指定连接超时和最低响应速度 [#1915](https://github.com/FooIbar/EhViewer/issues/1915)
* [阅读器] 支持反转物理按键控制 [#1962](https://github.com/FooIbar/EhViewer/issues/1962)
* [高级] 支持禁用 QUIC 支持
* [高级] 重新添加桌面版网站选项以绕过 Cloudflare [#2230](https://github.com/FooIbar/EhViewer/issues/2230)
* 支持 Android 6.0 上的动画 WebP
### 改进
* 优化压缩包加载性能
* 优化预加载策略
* 更新日本语翻译
* 为已选中的项目使用不同的颜色 [#2121](https://github.com/FooIbar/EhViewer/issues/2121)
* 缩略图模式下显示已收藏图标 [#2128](https://github.com/FooIbar/EhViewer/issues/2128)
* 迁移大部分解析器到 Rust 实现
* 优化动画 WebP 解码性能 [#2415](https://github.com/FooIbar/EhViewer/issues/2415)
* 优化标签自动补全命中率 [#2473](https://github.com/FooIbar/EhViewer/issues/2473)
* 在状态栏后绘制半透明背景以提高对比度
* 杂项优化
### Bug 修复
* 退出阅读器时崩溃
* Android 8.0 以下的设备无法读取某些压缩包
* 列表模式下点击画廊封面无法进入详情页
* 点击某些评论时崩溃
* 开启裁剪边缘时某些图片被不正确裁剪
* 下载数较多时无网络时启动 app 时崩溃 [#2041](https://github.com/FooIbar/EhViewer/issues/2041)
* 标签翻译数据无法更新 [#2075](https://github.com/FooIbar/EhViewer/issues/2075)
* 画廊详情页面显示的收藏夹名称错误 [#2081](https://github.com/FooIbar/EhViewer/issues/2081)
* 不使用 CI 频道时无法更新
* 添加后移动快捷搜索闪退 [#2124](https://github.com/FooIbar/EhViewer/issues/2124)
* 某些数据无法导入
* App 位于后台时清除下载通知时崩溃
* 杂项修复
## [1.13.1] - 2024-11-11
### 重大变更
* 由于网站限制,移除搜索封面功能
* 移除桌面版网站选项,默认使用 1280x 分辨率
### 改进
* 更新日本语翻译
* 优化压缩包读取
* 缓解某些小米系统上应用闪退 [#1826](https://github.com/FooIbar/EhViewer/issues/1826)
* 杂项优化
### Bug 修复
* Android 9 上的下载失败误报 [#1914](https://github.com/FooIbar/EhViewer/issues/1914)
## [1.13.0] - 2024-11-01
### 重大变更
+11 -6
View File
@@ -8,6 +8,10 @@
<a href="/docs/README/zh-tw.md">
正體中文
</a>
<span> | </span>
<a href="/docs/README/ja.md">
日本語
</a>
</p>
<h1 align="center">
@@ -59,17 +63,19 @@
# Description
EhViewer fork dedicated to lightweight and high-performance
A modern EhViewer fork dedicated to high-performance
with [Material Design 3](https://m3.material.io/)
and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview) Support
# Download
| Flavor | Feature |
|-------------|----------------------------------|
| Default | Android 8.0+, full support |
| Marshmallow | Android 6.0-7.1, limited support |
| Flavor | Minimum Android Version | Notes |
|-------------|-------------------------|--------------------------------|
| Default | 8.0 | Full support |
| Marshmallow | 6.0* | Limited support, no guarantees |
*Devices running Android 6 need to have the [ISRG Root X1](https://letsencrypt.org/certs/isrgrootx1.pem) certificate installed
<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"/>
@@ -92,7 +98,6 @@ Here is the libraries
- [Coil](https://coil-kt.github.io/coil/)
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
- [libarchive](https://www.libarchive.org/)
- [jsoup](https://jsoup.org/)
# License
+70 -68
View File
@@ -1,6 +1,7 @@
import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
import java.util.regex.Pattern
import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode
val isRelease: Boolean
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
@@ -9,7 +10,6 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.spotless)
@@ -21,24 +21,6 @@ plugins {
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
android {
androidResources {
generateLocaleConfig = true
localeFilters += listOf(
"zh",
"zh-rCN",
"zh-rHK",
"zh-rTW",
"es",
"ja",
"ko",
"fr",
"de",
"th",
"tr",
"nb-rNO",
)
}
splits {
abi {
isEnable = true
@@ -78,8 +60,8 @@ android {
defaultConfig {
applicationId = "moe.tarsin.ehviewer"
versionCode = 180061
versionName = "1.13.0"
versionCode = 180063
versionName = "1.14.1"
// versionNameSuffix = "-SNAPSHOT"
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
@@ -92,11 +74,6 @@ android {
}
debugSymbolLevel = "FULL"
}
externalNativeBuild {
cmake {
arguments += "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
}
}
}
flavorDimensions += "api"
@@ -139,12 +116,32 @@ android {
"/kotlin/**",
"**.txt",
"**.bin",
"**.{html,mmd}", // Compose Destination
"/okhttp3/**", // Okhttp public suffix
)
}
}
androidResources {
ignoreAssetsPatterns += listOf(
"!PublicSuffixDatabase.list", // OkHttp
"!composepreference.preference.generated.resources",
)
generateLocaleConfig = true
localeFilters += listOf(
"zh",
"zh-rCN",
"zh-rHK",
"zh-rTW",
"es",
"ja",
"ko",
"fr",
"de",
"th",
"tr",
"nb-rNO",
)
}
dependenciesInfo.includeInApk = false
buildTypes {
@@ -174,10 +171,6 @@ android {
namespace = "com.hippo.ehviewer"
}
composeCompiler {
featureFlags = setOf(ComposeFeatureFlag.OptimizeNonSkippingGroups)
}
baselineProfile {
mergeIntoMain = true
}
@@ -196,12 +189,12 @@ dependencies {
implementation(libs.compose.destinations.core)
ksp(libs.compose.destinations.compiler)
implementation(libs.compose.preference)
implementation(libs.androidx.core)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.datastore)
implementation(libs.androidx.graphics.path)
// https://developer.android.com/jetpack/androidx/releases/lifecycle
implementation(libs.androidx.lifecycle.process)
@@ -222,9 +215,8 @@ dependencies {
// https://square.github.io/okhttp/changelogs/changelog/
implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp.android)
implementation(libs.okio.jvm)
implementation(platform(libs.okio.bom))
implementation(libs.logcat)
@@ -237,8 +229,7 @@ dependencies {
implementation(libs.reorderable)
implementation(platform(libs.arrow.stack))
implementation(libs.arrow.fx.coroutines)
implementation(libs.arrow.resilience)
implementation(libs.bundles.arrow)
// https://coil-kt.github.io/coil/changelog/
implementation(platform(libs.coil.bom))
@@ -252,7 +243,6 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.jsoup)
coreLibraryDesugaring(libs.desugar)
@@ -265,31 +255,37 @@ dependencies {
kotlin {
jvmToolchain(21)
compilerOptions {
freeCompilerArgs = listOf(
// https://kotlinlang.org/docs/compiler-reference.html#progressive
"-progressive",
"-Xjvm-default=all",
"-Xcontext-receivers",
"-Xwhen-guards",
"-Xsuppress-warning=CONTEXT_RECEIVERS_DEPRECATED",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-opt-in=androidx.compose.animation.ExperimentalSharedTransitionApi",
"-opt-in=androidx.paging.ExperimentalPagingApi",
"-opt-in=kotlin.contracts.ExperimentalContracts",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
// https://kotlinlang.org/docs/gradle-compiler-options.html#all-compiler-options
compilerOptions {
jvmDefault = JvmDefaultMode.NO_COMPATIBILITY
progressiveMode = true
optIn.addAll(
"coil3.annotation.ExperimentalCoilApi",
"androidx.compose.foundation.layout.ExperimentalLayoutApi",
"androidx.compose.material3.ExperimentalMaterial3Api",
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
"androidx.compose.ui.ExperimentalComposeUiApi",
"androidx.compose.foundation.ExperimentalFoundationApi",
"androidx.compose.animation.ExperimentalAnimationApi",
"androidx.compose.animation.ExperimentalSharedTransitionApi",
"androidx.compose.runtime.ExperimentalComposeRuntimeApi",
"androidx.paging.ExperimentalPagingApi",
"kotlin.ExperimentalStdlibApi",
"kotlin.concurrent.atomics.ExperimentalAtomicApi",
"kotlin.contracts.ExperimentalContracts",
"kotlin.time.ExperimentalTime",
"kotlinx.coroutines.ExperimentalCoroutinesApi",
"kotlinx.coroutines.FlowPreview",
"kotlinx.serialization.ExperimentalSerializationApi",
"splitties.experimental.ExperimentalSplittiesApi",
"splitties.preferences.DataStorePreferencesPreview",
)
freeCompilerArgs.addAll(
"-Xcontext-parameters",
"-Xwhen-expressions=indy",
"-Xannotation-default-target=param-property",
)
}
}
@@ -300,8 +296,14 @@ ksp {
}
aboutLibraries {
duplicationMode = MERGE
duplicationRule = GROUP
collect {
includePlatform = false
}
library {
exclusionPatterns.add(Pattern.compile("org\\.jetbrains\\.(?:compose|androidx)\\..*"))
duplicationMode = DuplicateMode.MERGE
duplicationRule = DuplicateRule.GROUP
}
}
val ktlintVersion = libs.ktlint.get().version
+4
View File
@@ -11,6 +11,10 @@
public static final ** CREATOR;
}
-keepclassmembers,allowobfuscation class com.hippo.ehviewer.coil.AnimatedWebPDrawable {
java.nio.ByteBuffer source;
}
# Ktor logger
-dontwarn org.slf4j.impl.StaticLoggerBinder
@@ -1,52 +0,0 @@
From d1db03ebd9989d10a5a905b792b712c6487a0057 Mon Sep 17 00:00:00 2001
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
Date: Sat, 24 Feb 2024 12:03:15 +0000
Subject: [PATCH] Don't build other binaries
---
CMakeLists.txt | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index a346dd02..52b5d0d8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1567,7 +1567,7 @@ endif()
# xzdec and lzmadec
#############################################################################
-if(HAVE_DECODERS AND (NOT MSVC OR MSVC_VERSION GREATER_EQUAL 1900))
+if(0)
foreach(XZDEC xzdec lzmadec)
add_executable("${XZDEC}"
src/common/sysdefs.h
@@ -1624,7 +1624,7 @@ endif()
# lzmainfo
#############################################################################
-if(HAVE_DECODERS AND (NOT MSVC OR MSVC_VERSION GREATER_EQUAL 1900))
+if(0)
add_executable(lzmainfo
src/common/sysdefs.h
src/common/tuklib_common.h
@@ -1680,7 +1680,7 @@ endif()
# xz
#############################################################################
-if(NOT MSVC OR MSVC_VERSION GREATER_EQUAL 1900)
+if(0)
add_executable(xz
src/common/mythread.h
src/common/sysdefs.h
@@ -1914,7 +1914,7 @@ endif()
# Scripts
#############################################################################
-if(UNIX)
+if(0)
# NOTE: This isn't as sophisticated as in the Autotools build which
# uses posix-shell.m4 but hopefully this doesn't need to be either.
# CMake likely won't be used on as many (old) obscure systems as the
--
2.34.1
@@ -0,0 +1,25 @@
From e971b9f23727833cc39b9e325db30f383e5bfc30 Mon Sep 17 00:00:00 2001
From: Dude so hot <djohn@fbi.gov>
Date: Thu, 14 Nov 2024 00:28:13 +0800
Subject: [PATCH] Fix zip_time performance
Signed-off-by: Dude so hot <djohn@fbi.gov>
---
libarchive/archive_time.c | 1 +
1 file changed, 1 insertion(+)
diff --git a/libarchive/archive_time.c b/libarchive/archive_time.c
index 3352c809..9289bf1e 100644
--- a/libarchive/archive_time.c
+++ b/libarchive/archive_time.c
@@ -53,6 +53,7 @@ FILETIME_to_ntfs(const FILETIME* filetime)
int64_t
dos_to_unix(uint32_t dos_time)
{
+ return 0;
uint16_t msTime, msDate;
struct tm ts;
time_t t;
--
2.47.0
@@ -1,25 +0,0 @@
From aee8f7147ddb262fd9e5feee8d5b17094cf3470f Mon Sep 17 00:00:00 2001
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
Date: Tue, 26 Sep 2023 00:09:25 +0800
Subject: [PATCH] Insert link libs
---
CMakeLists.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index ec97e4c7..420f204c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -438,7 +438,7 @@ IF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
# # endif
ENDIF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
-SET(ADDITIONAL_LIBS "")
+SET(ADDITIONAL_LIBS ${LIBARCHIVE_CUSTOM_LIBS})
#
# Find ZLIB
#
--
2.34.1
@@ -0,0 +1,27 @@
From 86d199429b3fabc83f7dcc9afd72e7b4c7750b1a Mon Sep 17 00:00:00 2001
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
Date: Thu, 14 Nov 2024 20:22:52 +0800
Subject: [PATCH] Use UTF-8 as default charset on bionic
---
libarchive/archive_string.c | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/libarchive/archive_string.c b/libarchive/archive_string.c
index abf7ad66..63a73c58 100644
--- a/libarchive/archive_string.c
+++ b/libarchive/archive_string.c
@@ -423,7 +423,9 @@ static const char *
default_iconv_charset(const char *charset) {
if (charset != NULL && charset[0] != '\0')
return charset;
-#if HAVE_LOCALE_CHARSET && !defined(__APPLE__)
+#ifdef __BIONIC__
+ return "UTF-8";
+#elif HAVE_LOCALE_CHARSET && !defined(__APPLE__)
/* locale_charset() is broken on Mac OS */
return locale_charset();
#elif HAVE_NL_LANGINFO
--
2.43.0
+56 -21
View File
@@ -18,21 +18,26 @@ if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
endif (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
option(BUILD_TESTING OFF)
set(XZ_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch)
option(XZ_DOC OFF)
option(XZ_LZIP_DECODER OFF)
option(XZ_MICROLZMA_DECODER OFF)
option(XZ_MICROLZMA_ENCODER OFF)
option(XZ_TOOL_LZMADEC OFF)
option(XZ_TOOL_LZMAINFO OFF)
option(XZ_TOOL_XZ OFF)
option(XZ_TOOL_XZDEC OFF)
FetchContent_Declare(
liblzma
GIT_REPOSITORY https://github.com/tukaani-project/xz.git
GIT_TAG v5.6.3
GIT_TAG v5.8.1
GIT_SHALLOW 1
PATCH_COMMAND git apply --check -R ${XZ_PATCH} || git apply ${XZ_PATCH}
)
FetchContent_MakeAvailable(liblzma)
include_directories(${liblzma_SOURCE_DIR}/src/liblzma/api)
# Build GNUTLS libnettle
if (CMAKE_HOST_SYSTEM_NAME STREQUAL Windows)
if (CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows")
execute_process(
COMMAND wsl wslpath ${CMAKE_CURRENT_LIST_DIR}
OUTPUT_VARIABLE CURRENT_DIR
@@ -44,16 +49,15 @@ endif ()
set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}")
ExternalProject_Add(
nettle
PREFIX nettle
GIT_REPOSITORY https://gitlab.com/gnutls/nettle.git
GIT_TAG nettle_3.10_release_20240616
GIT_TAG nettle_3.10.2_release_20250626
GIT_SHALLOW 1
GIT_CONFIG core.autocrlf=input
CONFIGURE_COMMAND bash ${CURRENT_DIR}/configure_nettle.sh ${NDK_VERSION} ${CMAKE_C_COMPILER_TARGET}
BUILD_COMMAND bash -c "make libnettle.a CFLAGS='${CMAKE_C_FLAGS} -Qunused-arguments'"
BUILD_COMMAND bash -c "make -j4 libnettle.a CFLAGS='${CMAKE_C_FLAGS} -Qunused-arguments'"
INSTALL_COMMAND ""
BUILD_IN_SOURCE 1
BUILD_BYPRODUCTS nettle/src/nettle/libnettle.a
BUILD_BYPRODUCTS <SOURCE_DIR>/libnettle.a
)
ExternalProject_Get_Property(nettle SOURCE_DIR)
@@ -85,35 +89,66 @@ option(ENABLE_CAT OFF)
option(ENABLE_UNZIP OFF)
option(ENABLE_TEST OFF)
# Configure libarchive link's static lib
SET(LIBARCHIVE_CUSTOM_LIBS "libnettle" "liblzma")
set(LIBARCHIVE_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Insert-link-libs.patch)
set(LIBARCHIVE_PATCH
${CMAKE_CURRENT_LIST_DIR}/0001-Fix-zip_time-performance.patch
${CMAKE_CURRENT_LIST_DIR}/0002-Use-UTF-8-as-default-charset-on-bionic.patch
)
FetchContent_Declare(
libarchive
GIT_REPOSITORY https://github.com/libarchive/libarchive.git
GIT_TAG v3.7.7
GIT_TAG v3.8.1
GIT_SHALLOW 1
PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH}
)
FetchContent_MakeAvailable(libarchive)
target_link_libraries(archive_static liblzma libnettle)
include_directories(${libarchive_SOURCE_DIR}/libarchive)
FetchContent_Declare(
libwebp
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
GIT_TAG v1.6.0
GIT_SHALLOW 1
)
option(WEBP_BUILD_ANIM_UTILS OFF)
option(WEBP_BUILD_CWEBP OFF)
option(WEBP_BUILD_DWEBP OFF)
option(WEBP_BUILD_VWEBP OFF)
option(WEBP_BUILD_WEBPINFO OFF)
option(WEBP_BUILD_LIBWEBPMUX OFF)
option(WEBP_BUILD_EXTRAS OFF)
option(WEBP_ENABLE_SWAP_16BIT_CSP ON)
# Disable runtime detection of NEON support as NEON is required since API level 23
# https://source.android.com/docs/compatibility/6.0/android-6.0-cdd#3_3_1_application_binary_interfaces
unset(ANDROID)
FetchContent_MakeAvailable(libwebp)
if (CMAKE_SYSTEM_NAME STREQUAL "Android")
set(ANDROID 1)
endif ()
FetchContent_Declare(
Corrosion
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
GIT_TAG v0.5
GIT_TAG v0.5.2
GIT_SHALLOW 1
)
FetchContent_MakeAvailable(Corrosion)
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml LOCKED)
corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384")
if (ANDROID_NATIVE_API_LEVEL GREATER_EQUAL 26)
corrosion_set_features(ehviewer_rust FEATURES api-level-26)
endif ()
corrosion_import_crate(
MANIFEST_PATH ../rust/Cargo.toml
LOCKED
FEATURES $<IF:$<VERSION_GREATER_EQUAL:${ANDROID_NATIVE_API_LEVEL},26>,android-26,android>
FLAGS $<$<NOT:$<CONFIG:Debug>>:-Z build-std=std,panic_abort -Z build-std-features=panic_immediate_abort>
)
corrosion_link_libraries(ehviewer_rust android jnigraphics log webp webpdemux)
# Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c gifutils.c hash.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics -Wl,--exclude-libs,ALL)
target_link_libraries(
${PROJECT_NAME} archive_static ehviewer_rust
-Wl,-u,JNI_OnLoad -Wl,--undefined-glob,Java_*
-Wl,--version-script,${CMAKE_SOURCE_DIR}/libehviewer.map.txt
)
+135 -174
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2022 Tarsin Norbin
* Copyright 2022-2024 Tarsin Norbin
*
* This file is part of EhViewer
*
@@ -45,33 +45,29 @@ typedef struct {
typedef struct {
const char *filename;
int index;
size_t size;
ssize_t size;
void *addr;
} entry;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static archive_ctx **ctx_pool = NULL;
#define CTX_POOL_SIZE 20
#define MAX_PARALLEL_DECOMP 4
#define max(a, b) ((a) > (b) ? (a) : (b))
static void *mempool = MAP_FAILED;
static size_t *mempoolofs = NULL;
static int page_size = 0;
#define PAGE_ALIGN(x) ((x + page_size - 1) & ~(page_size - 1))
#define MEMPOOL_ADDR_BY_SORTED_IDX(x) (mempool + (index ? mempoolofs[index - 1] : 0))
#define MEMPOOL_SIZE (mempoolofs[entryCount - 1])
#define PROT_RW (PROT_WRITE | PROT_READ)
#define MAP_ANON_POOL (MAP_ANONYMOUS | MAP_NORESERVE | MAP_PRIVATE)
static pthread_mutex_t ctx_pool_mutex = PTHREAD_MUTEX_INITIALIZER;
static archive_ctx **ctx_pool = NULL;
static pthread_mutex_t buffer_mutex = PTHREAD_MUTEX_INITIALIZER;
static void *decode_buffer[MAX_PARALLEL_DECOMP];
static bool need_encrypt = false;
static char *passwd = NULL;
static void *archiveAddr = MAP_FAILED;
static size_t archiveSize = 0;
static entry *entries = NULL;
static size_t entryCount = 0;
static ssize_t max_file_size = 0;
const char supportExt[10][6] = {
#define SUPPORT_EXT_COUNT 11
const char supportExt[SUPPORT_EXT_COUNT][5] = {
"jpeg",
"jpg",
"png",
@@ -80,6 +76,7 @@ const char supportExt[10][6] = {
"bmp",
"ico",
"wbmp",
"heic",
"heif",
"avif"
};
@@ -91,24 +88,19 @@ static inline int filename_is_playable_file(const char *name) {
if (!dotptr++)
return false;
int i;
for (i = 0; i < 10; i++)
for (i = 0; i < SUPPORT_EXT_COUNT; i++)
if (strcmp(dotptr, supportExt[i]) == 0)
return true;
return false;
}
static inline const char *archive_entry_pathname2(struct archive_entry *entry) {
const char *name = archive_entry_pathname_utf8(entry);
return name ? name : archive_entry_pathname(entry);
}
static inline bool archive_entry_is_file(struct archive_entry *entry) {
return archive_entry_filetype(entry) == AE_IFREG;
}
static inline bool archive_entry_is_playable(struct archive_entry *entry) {
return archive_entry_is_file(entry) &&
filename_is_playable_file(archive_entry_pathname2(entry));
filename_is_playable_file(archive_entry_pathname(entry));
}
static inline int compare_entries(const void *a, const void *b) {
@@ -117,52 +109,64 @@ static inline int compare_entries(const void *a, const void *b) {
return strnatcmp(fa, fb);
}
static int archive_map_entries_index(archive_ctx *ctx, bool sort) {
#define ADDR_IN_FILE_MAPPING(addr) (addr >= archiveAddr && addr < archiveAddr + archiveSize)
static bool fill_entry_zero_copy(struct archive *arc, entry *entry) {
void *buffer = NULL;
size_t buffer_size = 0;
la_int64_t output_ofs = 0;
archive_read_data_block(arc, (const void **) &buffer, &buffer_size, &output_ofs);
bool zero_copy = ADDR_IN_FILE_MAPPING(buffer) && !output_ofs && buffer_size == entry->size;
entry->addr = zero_copy ? buffer : NULL;
return zero_copy;
}
static void archive_map_entries_index(archive_ctx *ctx, bool sort) {
int count = 0;
bool zero_copy = true;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
const char *name = archive_entry_pathname2(ctx->entry);
const char *name = archive_entry_pathname(ctx->entry);
if (archive_entry_is_file(ctx->entry) && filename_is_playable_file(name)) {
entries[count].filename = strdup(name);
entries[count].index = count;
entries[count].size = archive_entry_size(ctx->entry);
ssize_t size = archive_entry_size(ctx->entry);
max_file_size = max(size, max_file_size);
entries[count].size = size;
// We don't expect zero copy if first content can't do zero copy
if (zero_copy) zero_copy = fill_entry_zero_copy(ctx->arc, &entries[count]);
count++;
}
}
if (sort)
qsort(entries, entryCount, sizeof(entry), compare_entries);
return count;
if (sort) qsort(entries, entryCount, sizeof(entry), compare_entries);
}
static bool archive_prealloc_mempool() {
mempoolofs = calloc(entryCount, sizeof(size_t));
for (int i = 0; i < entryCount; ++i) {
if (!i)
mempoolofs[i] = PAGE_ALIGN(entries[i].size);
else
mempoolofs[i] = PAGE_ALIGN(entries[i].size) + mempoolofs[i - 1];
static void *acquire_decode_buffer() {
void *addr = NULL;
pthread_mutex_lock(&buffer_mutex);
for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) {
addr = decode_buffer[i];
if (addr) {
decode_buffer[i] = NULL;
break;
}
}
mempool = mmap(0, MEMPOOL_SIZE, PROT_RW, MAP_ANON_POOL, -1, 0);
if (mempool == MAP_FAILED) {
LOGE("%s%s", "mmap failed with error ", strerror(errno));
return false;
}
return true;
pthread_mutex_unlock(&buffer_mutex);
if (!addr) addr = malloc(max_file_size);
return addr;
}
#define ADDR_IN_FILE_MAPPING(addr) (addr >= archiveAddr && addr < archiveAddr + archiveSize)
static void mempool_release_pages(void *addr, size_t size) {
size = PAGE_ALIGN(size);
madvise_log_if_error(addr, size, MADV_DONTNEED);
}
static bool kernel_can_prefault = true;
static void mempool_prefault_pages(void *addr, size_t size) {
size = PAGE_ALIGN(size);
if (kernel_can_prefault) {
if (madvise(addr, size, MADV_POPULATE_WRITE)) kernel_can_prefault = false;
static void release_decode_buffer(void *buffer) {
pthread_mutex_lock(&buffer_mutex);
for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) {
void *addr = decode_buffer[i];
if (!addr) {
decode_buffer[i] = buffer;
pthread_mutex_unlock(&buffer_mutex);
return;
}
}
pthread_mutex_unlock(&buffer_mutex);
free(buffer);
}
static int archive_list_all_entries(archive_ctx *ctx) {
@@ -181,18 +185,10 @@ static void archive_release_ctx(archive_ctx *ctx) {
}
}
static int archive_alloc_ctx(archive_ctx **ctxptr) {
archive_ctx *ctx;
int err;
ctx = calloc(1, sizeof(archive_ctx));
if (!ctx)
return -ENOMEM;
static archive_ctx *archive_alloc_ctx() {
archive_ctx *ctx = calloc(1, sizeof(archive_ctx));
ctx->arc = archive_read_new();
ctx->using = 1;
if (!ctx->arc) {
free(ctx);
return -ENOMEM;
}
archive_read_support_format_tar(ctx->arc);
archive_read_support_format_7zip(ctx->arc);
archive_read_support_format_rar5(ctx->arc);
@@ -202,14 +198,14 @@ static int archive_alloc_ctx(archive_ctx **ctxptr) {
archive_read_set_option(ctx->arc, "zip", "ignorecrc32", "1");
if (passwd)
archive_read_add_passphrase(ctx->arc, passwd);
err = archive_read_open_memory(ctx->arc, archiveAddr, archiveSize);
if (err) {
int err = archive_read_open_memory(ctx->arc, archiveAddr, archiveSize);
if (err < ARCHIVE_OK) {
LOGE("%s%s", "Open archive failed: ", archive_error_string(ctx->arc));
archive_read_free(ctx->arc);
free(ctx);
return err;
return NULL;
}
*ctxptr = ctx;
return 0;
return ctx;
}
static int archive_skip_to_index(archive_ctx *ctx, int index) {
@@ -226,7 +222,7 @@ static int archive_skip_to_index(archive_ctx *ctx, int index) {
static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
int ret;
archive_ctx *ctx = NULL;
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&ctx_pool_mutex);
for (int i = 0; i < CTX_POOL_SIZE; i++) {
if (!ctx_pool[i])
continue;
@@ -241,16 +237,14 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
}
if (ctx)
ctx->using = 1;
pthread_mutex_unlock(&mutex);
pthread_mutex_unlock(&ctx_pool_mutex);
if (!ctx) {
archive_ctx *victimCtx = NULL;
int victimIdx = 0;
int replace = 1;
ret = archive_alloc_ctx(&ctx);
if (ret)
return ret;
pthread_mutex_lock(&mutex);
ctx = archive_alloc_ctx();
pthread_mutex_lock(&ctx_pool_mutex);
for (int i = 0; i < CTX_POOL_SIZE; i++) {
if (!ctx_pool[i]) {
ctx_pool[i] = ctx;
@@ -265,13 +259,13 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
}
}
if (replace) ctx_pool[victimIdx] = ctx;
pthread_mutex_unlock(&mutex);
pthread_mutex_unlock(&ctx_pool_mutex);
if (replace) archive_release_ctx(victimCtx);
}
ret = archive_skip_to_index(ctx, idx);
if (ret != idx) {
ret = archive_errno(ctx->arc);
LOGE("Skip to index failed:%s", archive_error_string(ctx->arc));
LOGE("Skip to index failed: %s", archive_error_string(ctx->arc));
archive_release_ctx(ctx);
return ret;
}
@@ -283,7 +277,6 @@ JNIEXPORT jint JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd, jlong size, jboolean sort_entries) {
EH_UNUSED(env);
EH_UNUSED(thiz);
page_size = getpagesize();
archive_ctx *ctx = NULL;
archiveAddr = mmap(0, size, PROT_READ, MAP_PRIVATE, fd, 0);
if (archiveAddr == MAP_FAILED) {
@@ -292,106 +285,74 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
}
archiveSize = size;
ctx_pool = calloc(CTX_POOL_SIZE, sizeof(archive_ctx **));
if (!ctx_pool) {
LOGE("Allocate archive ctx pool failed:ENOMEM");
ctx = archive_alloc_ctx();
if (!ctx) return 0;
entryCount = archive_list_all_entries(ctx);
LOGI("%s%zu%s", "Found ", entryCount, " images in archive");
if (!entryCount) {
LOGE("%s%s", "Archive read failed: ", archive_error_string(ctx->arc));
archive_release_ctx(ctx);
return 0;
}
int r = archive_alloc_ctx(&ctx);
if (r) {
if (r == -ENOMEM)
LOGE("%s", "Mem alloc failed");
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
return 0;
} else {
entryCount = archive_list_all_entries(ctx);
LOGI("%s%zu%s", "Found ", entryCount, " image entries in archive");
if (!entryCount) {
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
archive_release_ctx(ctx);
return 0;
}
// We must read through the file|vm then we can know whether it is encrypted
int encryptRet = archive_read_has_encrypted_entries(ctx->arc);
switch (encryptRet) {
case 1: // At lease 1 encrypted entry
need_encrypt = true;
break;
case 0: // format supports but no encrypted entry found
default:
need_encrypt = false;
}
int format = archive_format(ctx->arc);
switch (format) {
case ARCHIVE_FORMAT_ZIP:
case ARCHIVE_FORMAT_RAR_V5:
madvise_log_if_error(archiveAddr, archiveSize, MADV_SEQUENTIAL);
break;
case ARCHIVE_FORMAT_7ZIP: // Seek is bad
madvise_log_if_error(archiveAddr, archiveSize, MADV_RANDOM);
break;
default:;
}
// We must read through the file|vm then we can know whether it is encrypted
int encryptRet = archive_read_has_encrypted_entries(ctx->arc);
switch (encryptRet) {
case 1: // At lease 1 encrypted entry
need_encrypt = true;
break;
case 0: // format supports but no encrypted entry found
default:
need_encrypt = false;
}
archive_release_ctx(ctx);
r = archive_alloc_ctx(&ctx);
if (r) {
if (r == -ENOMEM)
LOGE("%s", "Mem alloc failed");
r = 0;
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
} else {
entries = calloc(entryCount, sizeof(entry));
r = archive_map_entries_index(ctx, sort_entries);
if (!archive_prealloc_mempool()) {
r = 0;
}
int format = archive_format(ctx->arc);
switch (format) {
case ARCHIVE_FORMAT_ZIP:
case ARCHIVE_FORMAT_RAR_V5:
madvise_log_if_error(archiveAddr, archiveSize, MADV_SEQUENTIAL);
break;
case ARCHIVE_FORMAT_7ZIP: // Seek is bad
madvise_log_if_error(archiveAddr, archiveSize, MADV_RANDOM);
break;
default:;
}
archive_release_ctx(ctx);
return r;
ctx = archive_alloc_ctx();
if (!ctx) return 0;
entries = calloc(entryCount, sizeof(entry));
archive_map_entries_index(ctx, sort_entries);
archive_release_ctx(ctx);
return (int) entryCount;
}
JNIEXPORT jobject JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToByteBuffer(JNIEnv *env, jclass thiz, jint index) {
EH_UNUSED(env);
EH_UNUSED(thiz);
void *addr = MEMPOOL_ADDR_BY_SORTED_IDX(index);
size_t size = entries[index].size;
index = entries[index].index;
archive_ctx *ctx = NULL;
int ret = archive_get_ctx(&ctx, index);
if (ret) return 0;
void *buffer = NULL;
size_t buffer_size = 0;
la_int64_t output_ofs = 0;
archive_read_data_block(ctx->arc, (const void **) &buffer, &buffer_size, &output_ofs);
bool zero_copy = ADDR_IN_FILE_MAPPING(buffer) && !output_ofs && buffer_size == size;
if (zero_copy) {
ctx->using = 0;
return (*env)->NewDirectByteBuffer(env, buffer, buffer_size);
entry *entry = &entries[index];
ssize_t size = entry->size;
if (entry->addr) {
return (*env)->NewDirectByteBuffer(env, entry->addr, size);
} else {
size_t bytes = 0;
mempool_prefault_pages(addr, size);
do {
memcpy(addr + output_ofs, buffer, buffer_size);
bytes += buffer_size;
ret = archive_read_data_block(ctx->arc, (const void **) &buffer, &buffer_size, &output_ofs);
} while (bytes != size && !ret);
ctx->using = 0;
if (bytes == size) {
return (*env)->NewDirectByteBuffer(env, addr, size);
} else {
if (ret < 0) {
LOGE("%s%s", "Archive read failed:", archive_error_string(ctx->arc));
archive_ctx *ctx = NULL;
if (!archive_get_ctx(&ctx, entry->index)) {
void *addr = acquire_decode_buffer();
ssize_t bytes = archive_read_data(ctx->arc, addr, size);
ctx->using = 0;
if (bytes == size) {
return (*env)->NewDirectByteBuffer(env, addr, size);
} else {
LOGE("%s", "No enough data read, WTF?");
if (bytes < 0) {
LOGE("%s%s", "Archive read failed: ", archive_error_string(ctx->arc));
} else {
LOGE("%s", "No enough data read, WTF?");
}
}
release_decode_buffer(addr);
}
mempool_release_pages(addr, size);
}
return 0;
}
@@ -413,12 +374,11 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
munmap(archiveAddr, archiveSize);
archiveAddr = MAP_FAILED;
}
if (mempool != MAP_FAILED) {
munmap(mempool, MEMPOOL_SIZE);
mempool = MAP_FAILED;
for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) {
free(decode_buffer[i]);
decode_buffer[i] = NULL;
}
free(mempoolofs);
mempoolofs = NULL;
max_file_size = 0;
if (entries) {
for (int i = 0; i < entryCount; ++i) {
free((void *) entries[i].filename);
@@ -445,15 +405,15 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
passwd = realloc(passwd, len + 1);
(*env)->GetStringUTFRegion(env, str, 0, len, passwd);
passwd[len] = 0;
archive_alloc_ctx(&ctx);
void *tmpBuf = alloca(4096);
ctx = archive_alloc_ctx();
char tmpBuf[4096];
while (archive_read_next_header(ctx->arc, &entry) == ARCHIVE_OK) {
if (!archive_entry_is_playable(entry))
continue;
if (!archive_entry_is_encrypted(entry))
continue;
if (archive_read_data(ctx->arc, tmpBuf, 4096) < ARCHIVE_OK) {
LOGE("%s%s", "Archive read failed:", archive_error_string(ctx->arc));
LOGE("%s%s", "Archive read failed: ", archive_error_string(ctx->arc));
ret = false;
}
break;
@@ -489,8 +449,9 @@ JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz, jobject buffer) {
EH_UNUSED(thiz);
void *addr = (*env)->GetDirectBufferAddress(env, buffer);
size_t size = (*env)->GetDirectBufferCapacity(env, buffer);
if (!ADDR_IN_FILE_MAPPING(addr)) mempool_release_pages(addr, size);
if (!ADDR_IN_FILE_MAPPING(addr)) {
release_decode_buffer(addr);
}
}
JNIEXPORT void JNICALL
@@ -515,7 +476,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz,
archive_entry_copy_stat(entry, &st);
archive_entry_set_perm(entry, 0644);
archive_write_header(arc, entry);
int len;
size_t len;
do {
len = read(fd, buff, sizeof(buff));
archive_write_data(arc, buff, len);
+1 -1
View File
@@ -37,7 +37,7 @@ static void doRewrite(byte *addr, size_t size) {
byte *end = addr + i + 4;
if (end[4] != 0) continue;
int frameDelay = end[2] << 8 | end[1];
if (frameDelay > MINIMUM_FRAME_DELAY)
if (frameDelay >= MINIMUM_FRAME_DELAY)
break; // Quit if the first block looks normal, for performance
end[1] = DEFAULT_FRAME_DELAY;
end[2] = 0;
+7
View File
@@ -0,0 +1,7 @@
{
global:
JNI_OnLoad;
Java_*;
local:
*;
};
@@ -16,42 +16,18 @@
package androidx.compose.foundation.gestures
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.FloatDecayAnimationSpec
import androidx.compose.animation.core.generateDecayAnimationSpec
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.snapFlingBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.observeReads
import androidx.compose.ui.node.requireLayoutDirection
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlin.math.sign
import kotlinx.coroutines.launch
@ExperimentalFoundationApi
@@ -61,7 +37,7 @@ fun <T> Modifier.anchoredHorizontalDraggable(
enableDragFromEndToStart: Boolean = true,
enabled: Boolean = true,
startDragImmediately: Boolean = state.isAnimationRunning,
flingBehavior: FlingBehavior? = null,
flingBehavior: FlingBehavior,
): Modifier = this then AnchoredHorizontalDraggableElement(
state = state,
canDrag = { (x, y) ->
@@ -78,7 +54,7 @@ private class AnchoredHorizontalDraggableElement<T>(
private val canDrag: (Offset) -> Boolean,
private val enabled: Boolean,
private val startDragImmediately: Boolean,
private val flingBehavior: FlingBehavior?,
private val flingBehavior: FlingBehavior,
) : ModifierNodeElement<AnchoredHorizontalDraggableNode<T>>() {
override fun create() = AnchoredHorizontalDraggableNode(
state,
@@ -136,45 +112,12 @@ private class AnchoredHorizontalDraggableNode<T>(
private var canDrag: (Offset) -> Boolean,
enabled: Boolean,
private var startDragImmediately: Boolean,
private var flingBehavior: FlingBehavior?,
) : HorizontalDragGestureNode(enabled = enabled),
ObserverModifierNode {
lateinit var resolvedFlingBehavior: FlingBehavior
lateinit var density: Density
private var flingBehavior: FlingBehavior,
) : HorizontalDragGestureNode(enabled = enabled) {
private val isReverseDirection: Boolean
get() = requireLayoutDirection() == LayoutDirection.Rtl
override fun onAttach() {
updateFlingBehavior(flingBehavior)
}
override fun onObservedReadsChanged() {
val newDensity = currentValueOf(LocalDensity)
if (density != newDensity) {
density = newDensity
updateFlingBehavior(flingBehavior)
}
}
private fun updateFlingBehavior(newFlingBehavior: FlingBehavior?) {
// Fall back to default fling behavior if the new fling behavior is null
this.resolvedFlingBehavior =
if (newFlingBehavior == null) {
// Only register for LocalDensity snapshot updates if we are creating a decay
observeReads { density = currentValueOf(LocalDensity) }
anchoredDraggableFlingBehavior(
snapAnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec,
positionalThreshold = AnchoredDraggableDefaults.PositionalThreshold,
density = density,
state = state,
)
} else {
newFlingBehavior
}
}
override fun canDrag(change: PointerInputChange): Boolean = canDrag(change.positionChange().reverseIfNeeded())
override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) {
@@ -185,7 +128,7 @@ private class AnchoredHorizontalDraggableNode<T>(
}
}
override fun onDragStarted(startedPosition: Offset) { }
override fun onDragStarted(startedPosition: Offset) = Unit
override fun onDragStopped(velocity: Velocity) {
if (!isAttached) return
@@ -206,7 +149,7 @@ private class AnchoredHorizontalDraggableNode<T>(
return consumed
}
}
with(resolvedFlingBehavior) {
with(flingBehavior) {
leftoverVelocity = scrollScope.performFling(velocity)
}
}
@@ -220,7 +163,7 @@ private class AnchoredHorizontalDraggableNode<T>(
canDrag: (Offset) -> Boolean,
enabled: Boolean,
startDragImmediately: Boolean,
flingBehavior: FlingBehavior?,
flingBehavior: FlingBehavior,
) {
this.flingBehavior = flingBehavior
@@ -228,7 +171,6 @@ private class AnchoredHorizontalDraggableNode<T>(
if (this.state != state) {
this.state = state
updateFlingBehavior(flingBehavior)
resetPointerInputHandling = true
}
@@ -247,230 +189,3 @@ private class AnchoredHorizontalDraggableNode<T>(
internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) = ((if (offset.isNaN()) 0f else offset) + delta)
.coerceIn(anchors.minPosition(), anchors.maxPosition())
/**
* Compute the target anchor based on the [currentOffset], [velocity] and [positionalThreshold] and
* [velocityThreshold].
*
* @return The suggested target anchor
*/
@ExperimentalFoundationApi
private fun <T> DraggableAnchors<T>.computeTarget(
currentOffset: Float,
velocity: Float,
positionalThreshold: (totalDistance: Float) -> Float,
velocityThreshold: () -> Float,
): T {
val currentAnchors = this
require(!currentOffset.isNaN()) { "The offset provided to computeTarget must not be NaN." }
val velocitySign = sign(velocity)
val isMovingForward = velocitySign == 1.0f
val isMoving = isMovingForward || velocitySign == -1.0f
// When we're not moving, just pick the closest anchor and don't consider directionality
return if (!isMoving) {
currentAnchors.closestAnchor(currentOffset)!!
} else if (abs(velocity) >= abs(velocityThreshold())) {
currentAnchors.closestAnchor(currentOffset, searchUpwards = isMovingForward)!!
} else {
val left = currentAnchors.closestAnchor(currentOffset, false)!!
val leftAnchorPosition = currentAnchors.positionOf(left)
val right = currentAnchors.closestAnchor(currentOffset, true)!!
val rightAnchorPosition = currentAnchors.positionOf(right)
val distance = abs(leftAnchorPosition - rightAnchorPosition)
val relativeThreshold = abs(positionalThreshold(distance))
val closestAnchorFromStart =
if (isMovingForward) leftAnchorPosition else rightAnchorPosition
val relativePosition = abs(closestAnchorFromStart - currentOffset)
if (relativePosition <= relativeThreshold) left else right
}
}
// https://issuetracker.google.com/374241709
@Composable
fun <T> flingBehavior(
state: AnchoredDraggableState<T>,
positionalThreshold: (totalDistance: Float) -> Float = AnchoredDraggableDefaults.PositionalThreshold,
animationSpec: AnimationSpec<Float> = AnchoredDraggableDefaults.SnapAnimationSpec,
): TargetedFlingBehavior {
val density = LocalDensity.current
return remember(density, state, positionalThreshold, animationSpec) {
anchoredDraggableFlingBehavior(
state = state,
density = density,
positionalThreshold = positionalThreshold,
snapAnimationSpec = animationSpec,
)
}
}
/**
* Construct a [FlingBehavior] for use with [Modifier.anchoredDraggable].
*
* @param state The [AnchoredDraggableState] that will be used for the fling animation
* @param positionalThreshold A positional threshold that needs to be crossed in order to reach the
* next anchor when flinging, in pixels. This can be a derived from the distance that the lambda
* is invoked with.
* @param snapAnimationSpec The animation spec that will be used to snap to a new state.
*/
@ExperimentalFoundationApi
internal fun <T> anchoredDraggableFlingBehavior(
state: AnchoredDraggableState<T>,
density: Density,
positionalThreshold: (totalDistance: Float) -> Float,
snapAnimationSpec: AnimationSpec<Float>,
): TargetedFlingBehavior = snapFlingBehavior(
decayAnimationSpec = NoOpDecayAnimationSpec,
snapAnimationSpec = snapAnimationSpec,
snapLayoutInfoProvider =
AnchoredDraggableLayoutInfoProvider(
state = state,
positionalThreshold = positionalThreshold,
velocityThreshold = { with(density) { 125.dp.toPx() } },
),
)
@Suppress("FunctionName")
@OptIn(ExperimentalFoundationApi::class)
private fun <T> AnchoredDraggableLayoutInfoProvider(
state: AnchoredDraggableState<T>,
positionalThreshold: (totalDistance: Float) -> Float,
velocityThreshold: () -> Float,
): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider {
// We never decay in AnchoredDraggable's fling
override fun calculateApproachOffset(velocity: Float, decayOffset: Float) = 0f
override fun calculateSnapOffset(velocity: Float): Float {
val currentOffset = state.requireOffset()
val target =
state.anchors.computeTarget(
currentOffset = currentOffset,
velocity = velocity,
positionalThreshold = positionalThreshold,
velocityThreshold = velocityThreshold,
)
return state.anchors.positionOf(target) - currentOffset
}
}
private val NoOpDecayAnimationSpec: DecayAnimationSpec<Float> =
object : FloatDecayAnimationSpec {
override val absVelocityThreshold = 0f
override fun getValueFromNanos(
playTimeNanos: Long,
initialValue: Float,
initialVelocity: Float,
) = 0f
override fun getDurationNanos(initialValue: Float, initialVelocity: Float) = 0L
override fun getVelocityFromNanos(
playTimeNanos: Long,
initialValue: Float,
initialVelocity: Float,
) = 0f
override fun getTargetValue(initialValue: Float, initialVelocity: Float) = 0f
}.generateDecayAnimationSpec()
/**
* This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout
* node's size and offsetting it.
* It considers lookahead and reports the appropriate size and measurement for the appropriate
* phase.
*
* @param state The state the anchors should be attached to
* @param orientation The orientation the component should be offset in
* @param anchors Lambda to calculate the anchors based on this layout's size and the incoming
* constraints. These can be useful to avoid subcomposition.
*/
internal fun <T> Modifier.draggableAnchors(
state: AnchoredDraggableState<T>,
orientation: Orientation,
anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
) = this then DraggableAnchorsElement(state, anchors, orientation)
private class DraggableAnchorsElement<T>(
private val state: AnchoredDraggableState<T>,
private val anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
private val orientation: Orientation,
) : ModifierNodeElement<DraggableAnchorsNode<T>>() {
override fun create() = DraggableAnchorsNode(state, anchors, orientation)
override fun update(node: DraggableAnchorsNode<T>) {
node.state = state
node.anchors = anchors
node.orientation = orientation
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is DraggableAnchorsElement<*>) return false
if (state != other.state) return false
if (anchors !== other.anchors) return false
if (orientation != other.orientation) return false
return true
}
override fun hashCode(): Int {
var result = state.hashCode()
result = 31 * result + anchors.hashCode()
result = 31 * result + orientation.hashCode()
return result
}
override fun InspectorInfo.inspectableProperties() {
debugInspectorInfo {
properties["state"] = state
properties["anchors"] = anchors
properties["orientation"] = orientation
}
}
}
private class DraggableAnchorsNode<T>(
var state: AnchoredDraggableState<T>,
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
var orientation: Orientation,
) : Modifier.Node(),
LayoutModifierNode {
private var didLookahead: Boolean = false
override fun onDetach() {
didLookahead = false
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
val placeable = measurable.measure(constraints)
// If we are in a lookahead pass, we only want to update the anchors here and not in
// post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead),
// update the anchors in the main pass.
if (!isLookingAhead || !didLookahead) {
val size = IntSize(placeable.width, placeable.height)
val newAnchorResult = anchors(size, constraints)
state.updateAnchors(newAnchorResult.first, newAnchorResult.second)
}
didLookahead = isLookingAhead || didLookahead
return layout(placeable.width, placeable.height) {
// In a lookahead pass, we use the position of the current target as this is where any
// ongoing animations would move. If the component is in a settled state, lookahead
// and post-lookahead will converge.
val offset = if (isLookingAhead) {
state.anchors.positionOf(state.targetValue)
} else {
state.requireOffset()
}
val xOffset = if (orientation == Orientation.Horizontal) offset else 0f
val yOffset = if (orientation == Orientation.Vertical) offset else 0f
placeable.placeRelative(xOffset.roundToInt(), yOffset.roundToInt())
}
}
}
@@ -28,6 +28,7 @@ import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -78,6 +79,7 @@ val LocalScrollbarStyle = staticCompositionLocalOf { defaultScrollbarStyle() }
data class ScrollbarStyle(
val minimalHeight: Dp,
val thickness: Dp,
val padding: Dp,
val shape: Shape,
val hoverDurationMillis: Int,
val unhoverColor: Color,
@@ -90,6 +92,7 @@ data class ScrollbarStyle(
fun defaultScrollbarStyle() = ScrollbarStyle(
minimalHeight = 16.dp,
thickness = 8.dp,
padding = 0.dp,
shape = RoundedCornerShape(4.dp),
hoverDurationMillis = 300,
unhoverColor = Color.Black.copy(alpha = 0.12f),
@@ -239,7 +242,7 @@ private fun Scrollbar(
)
}
val scrollThickness = style.thickness.roundToPx()
val scrollThickness = (style.thickness + style.padding * 2).roundToPx()
val measurePolicy = if (isVertical) {
remember(sliderAdapter, scrollThickness) {
verticalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness)
@@ -261,7 +264,11 @@ private fun Scrollbar(
Layout(
{
Box(
Modifier
if (isVertical) {
Modifier.padding(horizontal = style.padding)
} else {
Modifier.padding(vertical = style.padding)
}
.background(if (isVisible) color else Color.Transparent, style.shape)
.scrollbarDrag(
interactionSource = interactionSource,
@@ -1,191 +0,0 @@
package androidx.compose.material3
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
import androidx.compose.material3.SearchBarDefaults.inputFieldColors
import androidx.compose.material3.SearchBarDefaults.inputFieldShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.ColorProducer
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
/**
* A text field to input a query in a search bar.
*
* @param state [TextFieldState] that holds the internal editing state of the input field.
* @param onSearch the callback to be invoked when the input service triggers the
* [ImeAction.Search] action. The current query in the [state] comes as a parameter of the
* callback.
* @param expanded whether the search bar is expanded and showing search results.
* @param onExpandedChange the callback to be invoked when the search bar's expanded state is
* changed.
* @param modifier the [Modifier] to be applied to this input field.
* @param enabled the enabled state of this input field. When `false`, this component will not
* respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param readOnly controls the editable state of the input field. When `true`, the field cannot
* be modified. However, a user can focus it and copy text from it.
* @param textStyle the style to be applied to the input text. Defaults to [LocalTextStyle].
* @param placeholder the placeholder to be displayed when the input text is empty.
* @param leadingIcon the leading icon to be displayed at the start of the input field.
* @param trailingIcon the trailing icon to be displayed at the end of the input field.
* @param prefix the optional prefix to be displayed before the input text.
* @param suffix the optional suffix to be displayed after the input text.
* @param inputTransformation optional [InputTransformation] that will be used to transform
* changes to the [TextFieldState] made by the user. The transformation will be applied to
* changes made by hardware and software keyboard events, pasting or dropping text,
* accessibility services, and tests. The transformation will _not_ be applied when changing
* the [state] programmatically, or when the transformation is changed. If the transformation
* is changed on an existing text field, it will be applied to the next user edit. The
* transformation will not immediately affect the current [state].
* @param outputTransformation optional [OutputTransformation] that transforms how the contents
* of the text field are presented.
* @param scrollState scroll state that manages the horizontal scroll of the input field.
* @param shape the shape of the input field.
* @param colors [TextFieldColors] that will be used to resolve the colors used for this input
* field in different states. See [SearchBarDefaults.inputFieldColors].
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this input field. You can use this to change the search bar's
* appearance or preview the search bar in different states. Note that if `null` is provided,
* interactions will still happen internally.
*/
@ExperimentalMaterial3Api
@Composable
fun SearchBarInputField(
state: TextFieldState,
onSearch: (String) -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
prefix: @Composable (() -> Unit)? = null,
suffix: @Composable (() -> Unit)? = null,
inputTransformation: InputTransformation? = null,
outputTransformation: OutputTransformation? = null,
scrollState: ScrollState = rememberScrollState(),
shape: Shape = inputFieldShape,
colors: TextFieldColors = inputFieldColors(),
interactionSource: MutableInteractionSource? = null,
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val textColor = textStyle.color.takeOrElse {
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
BasicTextField(
state = state,
modifier = modifier
.sizeIn(
minWidth = SearchBarMinWidth,
maxWidth = SearchBarMaxWidth,
minHeight = InputFieldHeight,
)
.focusRequester(focusRequester)
.onFocusChanged { if (it.isFocused) onExpandedChange(true) }
.fillMaxWidth(),
enabled = enabled,
readOnly = readOnly,
lineLimits = TextFieldLineLimits.SingleLine,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
onKeyboardAction = { onSearch(state.text.toString()) },
interactionSource = interactionSource,
inputTransformation = inputTransformation,
outputTransformation = outputTransformation,
scrollState = scrollState,
decorator = TextFieldDefaults.decorator(
state = state,
enabled = enabled,
lineLimits = TextFieldLineLimits.SingleLine,
outputTransformation = outputTransformation,
interactionSource = interactionSource,
placeholder = placeholder,
leadingIcon = leadingIcon?.let { leading ->
{ Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() } }
},
trailingIcon = trailingIcon?.let { trailing ->
{ Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } }
},
prefix = prefix,
suffix = suffix,
colors = colors,
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
container = {
val containerColor =
animateColorAsState(
targetValue = if (focused) colors.focusedContainerColor else colors.unfocusedContainerColor,
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
label = "ContainerColor",
)
Box(Modifier.textFieldBackground(containerColor::value, shape))
},
),
)
val shouldClearFocus = !expanded && focused
LaunchedEffect(expanded) {
if (shouldClearFocus) {
// Not strictly needed according to the motion spec, but since the animation
// already has a delay, this works around b/261632544.
delay(AnimationDelayMillis.toLong())
focusManager.clearFocus()
}
}
}
private fun Modifier.textFieldBackground(
color: ColorProducer,
shape: Shape,
) = drawWithCache {
val outline = shape.createOutline(size, layoutDirection, this)
onDrawBehind { drawOutline(outline, color = color()) }
}
private val SearchBarMinWidth = 360.dp
private val SearchBarMaxWidth = 720.dp
private val SearchBarIconOffsetX = 4.dp
@Suppress("ktlint:standard:property-naming")
private const val AnimationDelayMillis = 100
@@ -7,11 +7,11 @@ import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animate
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.flingBehavior
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -56,15 +56,9 @@ private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
@Suppress("NotCloseable")
@Stable
class DrawerState2(
initialValue: DrawerValue,
val confirmStateChange: (DrawerValue) -> Boolean = { true },
) {
class DrawerState2(initialValue: DrawerValue) {
val anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
confirmValueChange = confirmStateChange,
)
val anchoredDraggableState = AnchoredDraggableState(initialValue)
/**
* Whether the drawer is open.
@@ -87,7 +81,7 @@ class DrawerState2(
*/
val currentValue: DrawerValue
get() {
return anchoredDraggableState.currentValue
return anchoredDraggableState.settledValue
}
/**
@@ -206,9 +200,9 @@ class DrawerState2(
/**
* The default [Saver] implementation for [DrawerState2].
*/
fun Saver(confirmStateChange: (DrawerValue) -> Boolean) = Saver<DrawerState2, DrawerValue>(
fun Saver() = Saver<DrawerState2, DrawerValue>(
save = { it.currentValue },
restore = { DrawerState2(it, confirmStateChange) },
restore = { DrawerState2(it) },
)
}
}
@@ -217,14 +211,12 @@ class DrawerState2(
* Create and [remember] a [DrawerState2].
*
* @param initialValue The initial value of the state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
fun rememberDrawerState2(
initialValue: DrawerValue,
confirmStateChange: (DrawerValue) -> Boolean = { true },
): DrawerState2 = rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) {
DrawerState2(initialValue, confirmStateChange)
): DrawerState2 = rememberSaveable(saver = DrawerState2.Saver()) {
DrawerState2(initialValue)
}
@Composable
@@ -257,7 +249,7 @@ fun ModalSideDrawer(
enableDragFromStartToEnd = drawerState.isOpen,
enableDragFromEndToStart = drawerState.isClosed,
enabled = gesturesEnabled,
flingBehavior = flingBehavior(
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
state = drawerState.anchoredDraggableState,
animationSpec = AnimationSpec,
),
@@ -282,7 +274,7 @@ fun ModalSideDrawer(
Scrim(
open = drawerState.isOpen,
onClose = {
if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) {
if (gesturesEnabled) {
scope.launch { drawerState.close() }
}
},
@@ -0,0 +1,151 @@
/*
* Copyright 2025 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
*
* 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 androidx.compose.material3.fork
/**
* Material-specific anchor layout logic which considers lookahead. This internal code is expected
* to remain in the library after androidx.compose.material3.internal.AnchoredDraggable.kt is
* removed.
*/
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.requireLayoutDirection
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.roundToInt
/**
* This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout
* node's size and offsetting it. It considers lookahead and reports the appropriate size and
* measurement for the appropriate phase.
*
* @param state The state the anchors should be attached to
* @param orientation The orientation the component should be offset in
* @param anchors Lambda to calculate the anchors based on this layout's size and the incoming
* constraints. These can be useful to avoid subcomposition.
*/
@Stable
internal fun <T> Modifier.draggableAnchors(
state: AnchoredDraggableState<T>,
orientation: Orientation,
anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
) = this then DraggableAnchorsElement(state, anchors, orientation)
private class DraggableAnchorsElement<T>(
private val state: AnchoredDraggableState<T>,
private val anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
private val orientation: Orientation,
) : ModifierNodeElement<DraggableAnchorsNode<T>>() {
override fun create() = DraggableAnchorsNode(state, anchors, orientation)
override fun update(node: DraggableAnchorsNode<T>) {
node.state = state
node.anchors = anchors
node.orientation = orientation
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is DraggableAnchorsElement<*>) return false
if (state != other.state) return false
if (anchors !== other.anchors) return false
if (orientation != other.orientation) return false
return true
}
override fun hashCode(): Int {
var result = state.hashCode()
result = 31 * result + anchors.hashCode()
result = 31 * result + orientation.hashCode()
return result
}
override fun InspectorInfo.inspectableProperties() {
debugInspectorInfo {
properties["state"] = state
properties["anchors"] = anchors
properties["orientation"] = orientation
}
}
}
private class DraggableAnchorsNode<T>(
var state: AnchoredDraggableState<T>,
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
var orientation: Orientation,
) : Modifier.Node(), LayoutModifierNode {
private var didLookahead: Boolean = false
override fun onDetach() {
didLookahead = false
}
private val isReverseDirection: Boolean
get() = requireLayoutDirection() == LayoutDirection.Rtl && orientation == Orientation.Horizontal
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
val placeable = measurable.measure(constraints)
// If we are in a lookahead pass, we only want to update the anchors here and not in
// post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead),
// update the anchors in the main pass.
if (!isLookingAhead || !didLookahead) {
val size = IntSize(placeable.width, placeable.height)
val newAnchorResult = anchors(size, constraints)
state.updateAnchors(newAnchorResult.first, newAnchorResult.second)
}
didLookahead = isLookingAhead || didLookahead
return layout(placeable.width, placeable.height) {
// In a lookahead pass, we use the position of the current target as this is where any
// ongoing animations would move. If the component is in a settled state, lookahead
// and post-lookahead will converge.
val offset = if (isLookingAhead) {
state.anchors.positionOf(state.targetValue)
} else {
state.requireOffset()
}
val rtlModifier = if (isReverseDirection) -1f else 1f
val xOffset = if (orientation == Orientation.Horizontal) offset * rtlModifier else 0f
val yOffset = if (orientation == Orientation.Vertical) offset else 0f
// Tagging as motion frame of reference placement, meaning the placement
// contains scrolling. This allows the consumer of this placement offset to
// differentiate this offset vs. offsets from structural changes. Generally
// speaking, this signals a preference to directly apply changes rather than
// animating, to avoid a chasing effect to scrolling.
withMotionFrameOfReferencePlacement {
placeable.place(xOffset.roundToInt(), yOffset.roundToInt())
}
}
}
}
@@ -17,14 +17,12 @@
package androidx.compose.material3.fork
import androidx.annotation.FloatRange
import androidx.compose.animation.core.SpringSpec
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.draggableAnchors
import androidx.compose.foundation.gestures.flingBehavior
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
@@ -32,38 +30,31 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.SwipeToDismissBoxDefaults
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import kotlinx.coroutines.CancellationException
private val SnapAnimationSpec = SpringSpec<Float>()
/**
* State of the [SwipeToDismissBox] composable.
*
* @param initialValue The initial value of the state.
* @param density The density that this state can use to convert values to and from dp.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
* the start of a transition. It will be, depending on the direction of the interaction, added or
* subtracted from/to the origin offset. It should always be a positive value.
* @param positionalThreshold The positional threshold to be used when calculating the target
* state while a swipe is in progress and when settling after the swipe ends. This is the
* distance from the start of a transition. It will be, depending on the direction of the
* interaction, added or subtracted from/to the origin offset. It should always be a positive
* value.
*/
class SwipeToDismissBoxState(
initialValue: SwipeToDismissBoxValue,
internal val density: Density,
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
internal val positionalThreshold: (totalDistance: Float) -> Float,
) {
internal val anchoredDraggableState =
AnchoredDraggableState(
initialValue = initialValue,
confirmValueChange = confirmValueChange,
)
internal val anchoredDraggableState = AnchoredDraggableState(initialValue)
internal val offset: Float get() = anchoredDraggableState.offset
internal val offset: Float
get() = anchoredDraggableState.offset
/**
* Require the current offset.
@@ -72,29 +63,38 @@ class SwipeToDismissBoxState(
*/
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
/**
* The current state value of the [SwipeToDismissBoxState].
*/
val currentValue: SwipeToDismissBoxValue get() = anchoredDraggableState.currentValue
/** The current state value of the [SwipeToDismissBoxState]. */
val currentValue: SwipeToDismissBoxValue
get() = anchoredDraggableState.currentValue
/**
* The target state. This is the closest state to the current offset (taking into account
* positional thresholds). If no interactions like animations or drags are in progress, this
* will be the current state.
*/
val targetValue: SwipeToDismissBoxValue get() = anchoredDraggableState.targetValue
val targetValue: SwipeToDismissBoxValue
get() = anchoredDraggableState.targetValue
/**
* The value the [SwipeToDismissBoxState] is currently settled at. When progressing through
* multiple anchors, e.g. A -> B -> C, settledValue will stay the same until settled at an
* anchor, while currentValue will update to the closest anchor.
*/
val settledValue: SwipeToDismissBoxValue
get() = anchoredDraggableState.settledValue
/**
* The fraction of the progress going from currentValue to targetValue, within [0f..1f] bounds.
*/
@get:FloatRange(from = 0.0, to = 1.0)
val progress: Float get() = anchoredDraggableState.progress(anchoredDraggableState.settledValue, anchoredDraggableState.targetValue)
val progress: Float
get() = anchoredDraggableState.progress(anchoredDraggableState.settledValue, anchoredDraggableState.targetValue)
/**
* The direction (if any) in which the composable has been or is being dismissed.
*
* Use this to change the background of the [SwipeToDismissBox] if you want different actions
* on each side.
* Use this to change the background of the [SwipeToDismissBox] if you want different actions on
* each side.
*/
val dismissDirection: SwipeToDismissBoxValue
get() = when {
@@ -114,14 +114,12 @@ class SwipeToDismissBoxState(
/**
* Reset the component to the default position with animation and suspend until it if fully
* reset or animation has been cancelled. This method will throw [CancellationException] if
* the animation is interrupted
* reset or animation has been cancelled. This method will throw [CancellationException] if the
* animation is interrupted
*
* @return the reason the reset animation ended
*/
suspend fun reset() = anchoredDraggableState.animateTo(
targetValue = SwipeToDismissBoxValue.Settled,
)
suspend fun reset() = anchoredDraggableState.animateTo(targetValue = SwipeToDismissBoxValue.Settled)
/**
* Dismiss the component in the given [direction], with an animation and suspend. This method
@@ -135,21 +133,10 @@ class SwipeToDismissBoxState(
companion object {
/**
* The default [Saver] implementation for [SwipeToDismissBoxState].
*/
fun Saver(
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean,
density: Density,
) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
/** The default [Saver] implementation for [SwipeToDismissBoxState]. */
fun Saver(positionalThreshold: (totalDistance: Float) -> Float) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
save = { it.currentValue },
restore = {
SwipeToDismissBoxState(
it,
density,
confirmValueChange,
)
},
restore = { SwipeToDismissBoxState(it, positionalThreshold) },
)
}
}
@@ -158,25 +145,19 @@ class SwipeToDismissBoxState(
* Create and [remember] a [SwipeToDismissBoxState].
*
* @param initialValue The initial value of the state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
* the start of a transition. It will be, depending on the direction of the interaction, added or
* subtracted from/to the origin offset. It should always be a positive value.
* @param positionalThreshold The positional threshold to be used when calculating the target state
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
* the start of a transition. It will be, depending on the direction of the interaction, added or
* subtracted from/to the origin offset. It should always be a positive value.
*/
@Composable
fun rememberSwipeToDismissBoxState(
initialValue: SwipeToDismissBoxValue = SwipeToDismissBoxValue.Settled,
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
): SwipeToDismissBoxState {
val density = LocalDensity.current
return rememberSaveable(
saver = SwipeToDismissBoxState.Saver(
confirmValueChange = confirmValueChange,
density = density,
),
) {
SwipeToDismissBoxState(initialValue, density, confirmValueChange)
}
positionalThreshold: (totalDistance: Float) -> Float = SwipeToDismissBoxDefaults.positionalThreshold,
): SwipeToDismissBoxState = rememberSaveable(
saver = SwipeToDismissBoxState.Saver(positionalThreshold = positionalThreshold),
) {
SwipeToDismissBoxState(initialValue, positionalThreshold)
}
/**
@@ -184,11 +165,14 @@ fun rememberSwipeToDismissBoxState(
*
* @param state The state of this component.
* @param backgroundContent A composable that is stacked behind the [content] and is exposed when
* the content is swiped. You can/should use the [state] to have different backgrounds on each side.
* the content is swiped. You can/should use the [state] to have different backgrounds on each
* side.
* @param modifier Optional [Modifier] for this component.
* @param enableDismissFromStartToEnd Whether SwipeToDismissBox can be dismissed from start to end.
* @param enableDismissFromEndToStart Whether SwipeToDismissBox can be dismissed from end to start.
* @param gesturesEnabled Whether swipe-to-dismiss can be interacted by gestures.
* @param onDismiss Optional callback to be called when [content] is dismissed. onDismissed provides
* the current dismissed direction.
* @param content The content that can be dismissed.
*/
@Composable
@@ -199,35 +183,28 @@ fun SwipeToDismissBox(
enableDismissFromStartToEnd: Boolean = true,
enableDismissFromEndToStart: Boolean = true,
gesturesEnabled: Boolean = true,
onDismiss: suspend (SwipeToDismissBoxValue) -> Unit = {},
content: @Composable RowScope.() -> Unit,
) {
Box(
modifier
.anchoredHorizontalDraggable(
modifier = modifier.anchoredHorizontalDraggable(
state = state.anchoredDraggableState,
enableDragFromStartToEnd = enableDismissFromStartToEnd,
enableDragFromEndToStart = enableDismissFromEndToStart,
enabled = gesturesEnabled && state.settledValue == SwipeToDismissBoxValue.Settled,
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
state = state.anchoredDraggableState,
enableDragFromStartToEnd = enableDismissFromStartToEnd,
enableDragFromEndToStart = enableDismissFromEndToStart,
enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled,
flingBehavior = flingBehavior(
state = state.anchoredDraggableState,
positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold,
animationSpec = SnapAnimationSpec,
),
positionalThreshold = state.positionalThreshold,
),
),
propagateMinConstraints = true,
) {
Row(
content = backgroundContent,
modifier = Modifier.matchParentSize(),
)
Row(content = backgroundContent, modifier = Modifier.matchParentSize())
Row(
content = content,
modifier = Modifier.draggableAnchors(
state.anchoredDraggableState,
Orientation.Horizontal,
) { size, _ ->
val width = size.width.toFloat()
return@draggableAnchors DraggableAnchors {
modifier = Modifier.draggableAnchors(state.anchoredDraggableState, Orientation.Horizontal) { size, _ ->
DraggableAnchors {
val width = size.width.toFloat()
SwipeToDismissBoxValue.Settled at 0f
if (enableDismissFromStartToEnd) {
SwipeToDismissBoxValue.StartToEnd at width
@@ -239,4 +216,10 @@ fun SwipeToDismissBox(
},
)
}
LaunchedEffect(state.settledValue, onDismiss) {
// Only call when state is settled in a dismissed direction.
if (state.settledValue != SwipeToDismissBoxValue.Settled) {
onDismiss(state.dismissDirection)
}
}
}
@@ -21,6 +21,7 @@ import android.content.Context
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.runtime.Composer
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil3.EventListener
@@ -29,13 +30,15 @@ import coil3.asImage
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.ConnectivityChecker
import coil3.network.NetworkFetcher
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.serviceLoaderEnabled
import coil3.util.DebugLogger
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.coil.AnimatedWebPDecoder
import com.hippo.ehviewer.coil.CropBorderInterceptor
import com.hippo.ehviewer.coil.DetectBorderInterceptor
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
@@ -43,7 +46,6 @@ import com.hippo.ehviewer.coil.HardwareBitmapInterceptor
import com.hippo.ehviewer.coil.MapExtraInfoInterceptor
import com.hippo.ehviewer.coil.MergeInterceptor
import com.hippo.ehviewer.coil.QrCodeInterceptor
import com.hippo.ehviewer.coil.limitConcurrency
import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase
import com.hippo.ehviewer.download.DownloadManager
@@ -67,6 +69,7 @@ import com.hippo.ehviewer.util.isAtLeastO
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastS
import com.hippo.ehviewer.util.isAtLeastSExtension7
import com.hippo.files.deleteContent
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withUIContext
@@ -93,14 +96,19 @@ class EhApplication :
initSETConnection()
// Initialize Settings on first access
lifecycleScope.launchIO {
val mode = Settings.theme
val mode = Settings.theme.value
if (!isAtLeastS) {
withUIContext {
AppCompatDelegate.setDefaultNightMode(mode)
}
}
if (!LogcatLogger.isInstalled && Settings.saveCrashLog) {
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
LogcatLogger.loggers += AndroidLogcatLogger(LogPriority.VERBOSE)
Settings.saveCrashLog.valueFlow().collect {
if (it) {
LogcatLogger.install()
} else {
LogcatLogger.uninstall()
}
}
}
lifecycle.addObserver(lockObserver)
@@ -108,8 +116,16 @@ class EhApplication :
super.onCreate()
System.loadLibrary("ehviewer")
lifecycleScope.launchIO {
launchUI { FavouriteStatusRouter.collect { (gid, slot) -> detailCache[gid]?.favoriteSlot = slot } }
launch { EhTagDatabase }
launchUI {
FavouriteStatusRouter.collect { info ->
detailCache[info.gid]?.apply {
favoriteSlot = info.favoriteSlot
favoriteName = info.favoriteName
favoriteNote = info.favoriteNote
}
}
}
EhTagDatabase.launchUpdate()
launch { EhDB }
launchIO { dataStateFlow.value }
launchIO { OSUtils.totalMemory }
@@ -124,12 +140,13 @@ class EhApplication :
FileUtils.cleanupDirectory(AppConfig.externalParseErrorDir)
}
launch { cleanupDownload() }
if (Settings.requestNews) {
if (Settings.requestNews.value) {
launch { checkDawn() }
}
}
if (BuildConfig.DEBUG) {
StrictMode.enableDefaults()
Composer.setDiagnosticStackTraceEnabled(true)
}
}
@@ -147,30 +164,32 @@ class EhApplication :
}
private fun clearTempDir() {
var dir = AppConfig.tempDir
if (null != dir) {
FileUtils.deleteContent(dir)
}
dir = AppConfig.externalTempDir
if (null != dir) {
FileUtils.deleteContent(dir)
}
AppConfig.tempDir.deleteContent()
AppConfig.externalTempDir?.deleteContent()
}
override fun newImageLoader(context: Context) = context.imageLoader {
interceptorCoroutineContext(Dispatchers.Default)
components {
serviceLoaderEnabled(false)
add(NetworkFetcher.Factory({ ktorClient.limitConcurrency() }) { ConnectivityChecker.ONLINE })
add(
KtorNetworkFetcherFactory(
httpClient = { ktorClient },
connectivityChecker = { ConnectivityChecker.ONLINE },
),
)
add(MergeInterceptor)
add(DownloadThumbInterceptor)
if (isAtLeastO) {
add(HardwareBitmapInterceptor)
} else {
allowRgb565(true)
}
add(CropBorderInterceptor)
add(DetectBorderInterceptor)
add(QrCodeInterceptor)
add(MapExtraInfoInterceptor)
add(AnimatedWebPDecoder.Factory)
if (isAtLeastP) {
add(AnimatedImageDecoder.Factory(false))
} else {
@@ -196,9 +215,9 @@ class EhApplication :
companion object {
val ktorClient by lazy {
if (isAtLeastSExtension7 && Settings.enableCronet) {
if (isAtLeastSExtension7 && Settings.enableCronet.value) {
HttpClient(Cronet) {
engine { configureClient() }
engine { configureClient(Settings.enableQuic.value) }
configureCommon()
}
} else {
@@ -218,7 +237,7 @@ class EhApplication :
val imageCache by lazy {
diskCache {
directory(appCtx.cacheDir.toOkioPath() / "image_cache")
maxSizeBytes(Settings.readCacheSize.coerceIn(320, 5120).toLong() * 1024 * 1024)
maxSizeBytes(Settings.readCacheSize.value.coerceIn(320, 5120).toLong() * 1024 * 1024)
}
}
+33 -33
View File
@@ -17,8 +17,8 @@ package com.hippo.ehviewer
import android.content.Context
import android.net.Uri
import arrow.fx.coroutines.release
import arrow.fx.coroutines.resource
import arrow.fx.coroutines.resourceScope
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.dao.DownloadArtist
import com.hippo.ehviewer.dao.DownloadDirname
@@ -44,7 +44,7 @@ object EhDB {
addMigrations(Schema17to18())
}
private suspend fun putGalleryInfo(galleryInfo: BaseGalleryInfo) {
suspend fun putGalleryInfo(galleryInfo: BaseGalleryInfo) {
db.galleryDao().upsert(galleryInfo)
}
@@ -279,52 +279,52 @@ object EhDB {
dbFile.toOkioPath() sendTo file
}
suspend fun importDB(context: Context, uri: Uri) {
suspend fun importDB(context: Context, uri: Uri) = resourceScope {
val tempDBName = "tmp.db"
resource {
val oldDB = resource {
context.deleteDatabase(tempDBName)
roomDb<EhDatabase>(tempDBName) {
createFromInputStream { context.contentResolver.openInputStream(uri) }
addMigrations(Schema17to18())
}
} release {
it.close()
} release { db ->
db.close()
context.deleteDatabase(tempDBName)
} use { oldDB ->
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
}
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
val downloadLabelList = oldDB.downloadLabelDao().list()
DownloadManager.addDownloadLabel(downloadLabelList)
val downloadLabelList = oldDB.downloadLabelDao().list()
DownloadManager.addDownloadLabel(downloadLabelList)
oldDB.downloadDirnameDao().list().let {
importDownloadDirname(it)
}
oldDB.downloadDirnameDao().list().let {
importDownloadDirname(it)
}
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
DownloadManager.addDownload(downloadInfoList)
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
DownloadManager.addDownload(downloadInfoList)
val historyInfoList = oldDB.historyDao().list()
importHistoryInfo(historyInfoList)
val historyInfoList = oldDB.historyDao().list()
importHistoryInfo(historyInfoList)
val quickSearchList = oldDB.quickSearchDao().list()
val currentQuickSearchList = db.quickSearchDao().list()
val offset = currentQuickSearchList.size
val importList = quickSearchList.filter { newQS ->
currentQuickSearchList.none { it.name == newQS.name }
}.onEachIndexed { index, q -> q.position = index + offset }
importQuickSearch(importList)
val quickSearchList = oldDB.quickSearchDao().list()
val currentQuickSearchList = db.quickSearchDao().list()
val offset = currentQuickSearchList.size
val importList = quickSearchList.filter { newQS ->
currentQuickSearchList.none { it.name == newQS.name }
}.onEachIndexed { index, q ->
q.id = null
q.position = index + offset
}
importQuickSearch(importList)
oldDB.localFavoritesDao().list().let {
importLocalFavorites(it)
}
oldDB.localFavoritesDao().list().let {
importLocalFavorites(it)
}
val filterList = oldDB.filterDao().list()
val currentFilterList = db.filterDao().list()
filterList.forEach {
if (it !in currentFilterList) addFilter(it)
}
oldDB.filterDao().list().forEach {
addFilter(it)
}
}
}
@@ -90,8 +90,8 @@ fun <T> PrefDelegate<T>.asMutableState(): MutableState<T> {
set(value) {
setValue(value)
}
override fun component1() = TODO()
override fun component2() = TODO()
override fun component1() = readOnly.value
override fun component2() = ::setValue
}
}
}
@@ -106,26 +106,67 @@ object Settings : DataStorePreferences(null) {
var favCount by intArrayPref("fav_count", 10).emitTo(_favFlow)
var favCloudCount by intPref("fav_cloud", 0).emitTo(_favFlow)
// Eh
val gallerySite = intPref("gallery_site_2", 0).observed { updateWhenGallerySiteChanges() }
val defaultFavSlot = intPref("default_favorite_slot", -2)
val theme = intPref("theme_2", -1).observed { updateWhenThemeChanges() }
val blackDarkTheme = boolPref("black_dark_theme", false)
val harmonizeCategoryColor = boolPref("harmonize_category_color", true)
val launchPage = intPref("launch_page_2", 0)
val listMode = intPref("list_mode_2", 0)
val listThumbSize = intPref("list_tile_size", 40)
val detailSize = intPref("detail_size_2", 0)
val thumbColumns = intPref("thumb_columns", 3)
val listThumbSize = intPref("list_tile_size", 40)
val showGalleryPages = boolPref("show_gallery_pages", true)
val showVoteStatus = boolPref("show_vote_status", false)
val showComments = boolPref("show_gallery_comments", true)
val commentThreshold = intPref("comment_threshold", -100)
val showTagTranslations = boolPref(KEY_SHOW_TAG_TRANSLATIONS, false).observed { updateWhenTagTranslationChanges() }
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
val showJpnTitle = boolPref("show_jpn_title", false)
val requestNews = boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
val hideHvEvents = boolPref("hide_hv_events", false)
// Download
val mediaScan = boolPref("media_scan", false).observed { updateWhenKeepMediaStatusChanges() }
val multiThreadDownload = intPref("download_thread_2", 3)
val downloadDelay = intPref("download_delay_3", 1000)
val connTimeout = intPref("conn_timeout", 10)
val timeoutSpeed = intPref("timeout_speed_level", 6)
val preloadImage = intPref("preload_image_2", 5)
val downloadOriginImage = boolPref("download_origin_image", false)
val saveAsCbz = boolPref("save_as_cbz", false)
val archiveMetadata = boolPref("archive_metadata", true)
// Privacy
val security = boolPref("require_unlock", false)
val securityDelay = intPref("require_unlock_delay", 0)
val enabledSecurity = boolPref("enable_secure", false)
// Advanced
val saveParseErrorBody = boolPref("save_parse_error_body", true)
val saveCrashLog = boolPref("save_crash_log", true)
val readCacheSize = intPref("read_cache_size_2", 640)
val enableCronet = boolPref("enable_cronet", true)
val enableQuic = boolPref("enable_quic", true)
val hardwareBitmapThreshold = intPref("hardware_bitmap_threshold", 16384)
val preloadThumbAggressively = boolPref("preload_thumb_aggressively", false)
val animateItems = boolPref("animate_items", true)
val desktopSite = boolPref("desktop_site", true)
// About
val backupBeforeUpdate = boolPref("backup_before_update", true)
val useCIUpdateChannel = boolPref("ci_update_channel", AppConfig.isSnapshot)
val updateIntervalDays = intPref("update_interval_days", 7)
// Misc
val languageFilter = intPref("language_filter", -1)
val downloadSortMode = intPref("download_sort_mode", SortMode.Default.flag)
val downloadFilterMode = intPref("download_filter_mode", DownloadsFilterMode.Default.flag)
// Enabled by default as some ExHentai thumbnail servers do not support HTTP/2
val forceEhThumb = boolPref("force_eh_thumb", true)
val hasSignedIn = boolPref("has_signed_in", EhCookieStore.hasSignedIn())
val needSignIn = boolPref("need_sign_in", true)
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
val blackDarkTheme = boolPref("black_dark_theme", false)
val gridView = boolPref("grid_view", false)
val showGalleryPages = boolPref("show_gallery_pages", true)
val qSSaveProgress = boolPref("qs_save_progress", true)
val security = boolPref("require_unlock", false)
val animateItems = boolPref("animate_items", true)
val displayName = stringOrNullPref("display_name", null)
val avatar = stringOrNullPref("avatar", null)
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
@@ -136,42 +177,12 @@ object Settings : DataStorePreferences(null) {
var downloadQuery by stringOrNullPref("image_query", null)
var downloadFragment by stringOrNullPref("image_fragment", null)
var archivePasswds by stringSetPref("archive_passwds")
var downloadDelay by intPref("download_delay_3", 1000)
var multiThreadDownload by intPref("download_thread_2", 3)
var preloadImage by intPref("preload_image_2", 5)
var downloadTimeout by intPref("download_timeout", 60)
var theme by intPref("theme_2", -1).observed { updateWhenThemeChanges() }
var readCacheSize by intPref("read_cache_size_2", 640)
var launchPage by intPref("launch_page_2", 0)
var commentThreshold by intPref("comment_threshold", -101)
var hardwareBitmapThreshold by intPref("hardware_bitmap_threshold", 16384)
var showComments by boolPref("show_gallery_comments", true)
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
var hideHvEvents by boolPref("hide_hv_events", false)
var showJpnTitle by boolPref("show_jpn_title", false)
var showTagTranslations by boolPref(KEY_SHOW_TAG_TRANSLATIONS, false).observed { updateWhenTagTranslationChanges() }
var appLinkVerifyTip by boolPref("app_link_verify_tip", false)
var enabledSecurity by boolPref("enable_secure", false)
var backupBeforeUpdate by boolPref("backup_before_update", true)
var useCIUpdateChannel by boolPref("ci_update_channel", AppConfig.isSnapshot)
var mediaScan by boolPref("media_scan", false).observed { updateWhenKeepMediaStatusChanges() }
var hasDefaultDownloadLabel by boolPref("has_default_download_label", false)
var saveParseErrorBody by boolPref("save_parse_error_body", true)
var saveCrashLog by boolPref("save_crash_log", true)
var removeImageFiles by boolPref("include_pic", true)
var harmonizeCategoryColor by boolPref("harmonize_category_color", true)
var preloadThumbAggressively by boolPref("preload_thumb_aggressively", false)
var downloadOriginImage by boolPref("download_origin_image", false)
var saveAsCbz by boolPref("save_as_cbz", false)
var archiveMetadata by boolPref("archive_metadata", true)
var enableCronet by boolPref("enable_cronet", true)
var desktopSite by boolPref("desktop_site", false)
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
var defaultFavSlot by intPref("default_favorite_slot", -2)
var securityDelay by intPref("require_unlock_delay", 0)
var clipboardTextHashCode by intPref("clipboard_text_hash_code", 0)
var requestNewsTime by intPref("request_news_time", 0).observed { updateWhenRequestNewsChanges() }
var updateIntervalDays by intPref("update_interval_days", 7)
var lastDawnDays by intPref("last_dawn_days", 0)
var recentToplist by stringPref("recent_toplist", "11")
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
@@ -195,6 +206,7 @@ object Settings : DataStorePreferences(null) {
val keepScreenOn = boolPref("pref_keep_screen_on_key", true)
val readerLongTapAction = boolPref("reader_long_tap", true)
val pageTransitions = boolPref("pref_enable_transitions_key", true)
val readerReverseControls = boolPref("reader_volume_keys_inverted", false)
val grayScale = boolPref("pref_grayscale", false)
val invertedColors = boolPref("pref_inverted_colors", false)
val readerWebtoonNav = intPref("reader_navigation_mode_webtoon", 0)
@@ -210,13 +222,10 @@ object Settings : DataStorePreferences(null) {
val showNavigationOverlayOnStart = boolPref("reader_navigation_overlay_on_start", false)
val stripExtraneousAds = boolPref("strip_extraneous_ads", false)
val userAgent
get() = if (desktopSite) CHROME_USER_AGENT else CHROME_MOBILE_USER_AGENT
init {
if ("CN" == Locale.getDefault().country) {
edit {
if (KEY_SHOW_TAG_TRANSLATIONS !in prefs) showTagTranslations = true
if (KEY_SHOW_TAG_TRANSLATIONS !in prefs) showTagTranslations.value = true
}
}
if (downloadFilterMode.value == DownloadsFilterMode.ARTIST.flag && recentDownloadLabel.value == null) {
@@ -254,6 +263,3 @@ object Settings : DataStorePreferences(null) {
}
}
}
private const val CHROME_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Safari/537.36"
private const val CHROME_MOBILE_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
@@ -37,14 +37,14 @@ fun updateWhenThemeChanges() {
collectScope.launch {
delay(100) // Avoid recompose being cancelled
if (isAtLeastS) {
val mode = when (Settings.theme) {
val mode = when (Settings.theme.value) {
AppCompatDelegate.MODE_NIGHT_NO -> UiModeManager.MODE_NIGHT_NO
AppCompatDelegate.MODE_NIGHT_YES -> UiModeManager.MODE_NIGHT_YES
else -> UiModeManager.MODE_NIGHT_AUTO
}
uiModeManager.setApplicationNightMode(mode)
}
AppCompatDelegate.setDefaultNightMode(Settings.theme)
AppCompatDelegate.setDefaultNightMode(Settings.theme.value)
}
}
@@ -65,11 +65,5 @@ fun updateWhenGallerySiteChanges() {
}
fun updateWhenTagTranslationChanges() {
collectScope.launchIO {
runCatching {
EhTagDatabase.update()
}.onFailure {
logcat(it)
}
}
EhTagDatabase.launchUpdate()
}
@@ -15,7 +15,6 @@
*/
package com.hippo.ehviewer.client
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.GalleryInfo
// Normal Preview (v2): https://*.hath.network/c(m|1|2)/[timed token]/[gid]-[index].(jpg|webp)
@@ -26,7 +25,6 @@ import com.hippo.ehviewer.client.data.GalleryInfo
const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/"
const val URL_SIGNATURE_THUMB_NORMAL = ".hath.network/c"
private const val URL_PREFIX_V1_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
private val V2PreviewKeyRegex = Regex("/c([m12]/)[^/]+/(\\d+-\\d+)")
@@ -40,20 +38,15 @@ fun getV2PreviewKey(url: String) = "$".plus(
} ?: url,
)
val String.isV2PreviewKey
get() = startsWith('$')
val GalleryInfo.thumbUrl
get() = thumbKey!!.let {
if (it.endsWith("webp")) {
if (useExThumb) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
} else {
v1ThumbPrefix
} + it
}
get() = keyToUrl(thumbKey!!)
val v1ThumbPrefix
get() = if (useExThumb) URL_PREFIX_V1_THUMB_EX else URL_PREFIX_THUMB_E
private val useExThumb
get() = EhUtils.isExHentai && !Settings.forceEhThumb.value
fun keyToUrl(key: String) = if (key.startsWith("https:")) {
key
} else {
if (key.endsWith("webp")) {
if (EhUtils.isExHentai) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
} else {
if (EhUtils.isExHentai) URL_PREFIX_V1_THUMB_EX else URL_PREFIX_THUMB_E
} + key
}
@@ -13,11 +13,12 @@ object EhCookieStore : CookiesStorage {
fun hasSignedIn(): Boolean = getCookies(EhUrl.HOST_E)?.run {
containsKey(KEY_IPB_MEMBER_ID) && containsKey(KEY_IPB_PASS_HASH)
} ?: false
} == true
const val KEY_IPB_MEMBER_ID = "ipb_member_id"
const val KEY_IPB_PASS_HASH = "ipb_pass_hash"
const val KEY_IGNEOUS = "igneous"
private const val KEY_HATH_PERKS = "hath_perks"
private const val KEY_CONTENT_WARNING = "nw"
private const val CONTENT_WARNING_NOT_SHOW = "1"
private const val KEY_UTMP_NAME = "__utmp"
@@ -35,6 +36,8 @@ object EhCookieStore : CookiesStorage {
fun getUserId() = getCookies(EhUrl.HOST_E)?.get(KEY_IPB_MEMBER_ID)
fun getHathPerks() = getCookies(EhUrl.HOST_E)?.get(KEY_HATH_PERKS)?.substringBefore('-')
fun getIdentityCookies(): List<Pair<String, String?>> {
val eCookies = getCookies(EhUrl.HOST_E)
val exCookies = getCookies(EhUrl.HOST_EX)
@@ -48,6 +51,8 @@ object EhCookieStore : CookiesStorage {
)
}
fun isCloudflareBypassed() = getCookies(EhUrl.HOST_E)?.containsKey("cf_clearance") == true
fun flush() = manager.flush()
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
@@ -27,8 +27,10 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.exception.CloudflareBypassException
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.NoHathClientException
import com.hippo.ehviewer.client.exception.NoHitsFoundException
import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.client.exception.ParseException
@@ -36,11 +38,9 @@ import com.hippo.ehviewer.client.parser.ArchiveParser
import com.hippo.ehviewer.client.parser.EventPaneParser
import com.hippo.ehviewer.client.parser.FavParserResult
import com.hippo.ehviewer.client.parser.FavoritesParser
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.GalleryNotAvailableParser
import com.hippo.ehviewer.client.parser.GalleryPageParser
import com.hippo.ehviewer.client.parser.GalleryTokenApiParser
import com.hippo.ehviewer.client.parser.HomeParser
@@ -59,9 +59,9 @@ import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.bodyAsUtf8Text
import com.hippo.ehviewer.util.ensureSuccess
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.HttpStatement
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpStatusCode
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import io.ktor.utils.io.readAvailable
@@ -79,7 +79,6 @@ import moe.tarsin.coroutines.removeAllSuspend
import moe.tarsin.coroutines.runSuspendCatching
import okio.buffer
import okio.sink
import org.jsoup.Jsoup
import splitties.init.appCtx
// https://ehwiki.org/wiki/API#Basics
@@ -99,10 +98,12 @@ fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
}
}
fun rethrowExactly(status: HttpStatusCode, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
fun rethrowExactly(response: HttpResponse, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
// Don't translate coroutine cancellation
if (e is CancellationException) throw e
if (response.headers["cf-mitigated"] == "challenge") throw CloudflareBypassException()
// Check sad panda (without panda)
val empty = body.fold(
{ it.isEmpty() },
@@ -118,26 +119,28 @@ fun rethrowExactly(status: HttpStatusCode, body: Either<String, ByteBuffer>, e:
// Check Gallery Not Available
body.onLeft {
if ("Gallery Not Available - " in it) {
val error = GalleryNotAvailableParser.parse(it)
if (!error.isNullOrBlank()) {
throw EhException(error)
}
if ("Gallery Not Available - " in it) throw e
}
// Check parse error thrown by rust
val cause = e.cause
if (e is ParseException && cause is RuntimeException) {
when (val message = cause.message) {
"0" -> throw NoHitsFoundException()
"1" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
"2" -> throw NotLoggedInException()
"3" -> throw NoHathClientException()
"4" -> throw InsufficientFundsException()
is String if message.startsWith('5') -> throw EhException(message.drop(1))
}
}
// Check bad response code
status.ensureSuccess()
response.status.ensureSuccess()
if (e is ParseException || e is SerializationException) {
body.onLeft { if ("<" !in it) throw EhException(it) }
when (val message = e.cause?.message) {
"0" -> throw NoHitsFoundException()
"1" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
"2" -> throw NotLoggedInException()
is String if message.startsWith('3') -> throw EhException(message.drop(1))
}
if (Settings.saveParseErrorBody) body.saveParseError(e)
if (Settings.saveParseErrorBody.value) body.saveParseError(e)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
}
@@ -152,7 +155,7 @@ suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend
runSuspendCatching {
block(body)
}.onFailure {
rethrowExactly(response.status, body.left(), it)
rethrowExactly(response, body.left(), it)
}.getOrThrow()
}
@@ -164,7 +167,7 @@ suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: s
block(buffer)
}.onFailure {
buffer.rewind()
rethrowExactly(response.status, buffer.right(), it)
rethrowExactly(response, buffer.right(), it)
}.getOrThrow()
}
}
@@ -175,7 +178,8 @@ object EhEngine {
location.takeIf { "bounce_login" !in it } ?: throw NotLoggedInException()
}
suspend fun getTorrentList(url: String, gid: Long, token: String): TorrentResult {
suspend fun getTorrentList(gid: Long, token: String): TorrentResult {
val url = EhUrl.getTorrentUrl(gid, token)
val referer = EhUrl.getGalleryDetailUrl(gid, token)
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
}
@@ -185,10 +189,11 @@ object EhEngine {
"${id}x$key"
}
suspend fun getArchiveList(url: String, gid: Long, token: String): ArchiveParser.Result {
suspend fun getArchiveList(gid: Long, token: String): ArchiveParser.Result {
val url = EhUrl.getArchiveUrl(gid, token)
val funds = if (EhUtils.isExHentai) getFunds() else null
return ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
.fetchUsingAsText(ArchiveParser::parse.partially2(funds))
.fetchUsingAsByteBuffer(ArchiveParser::parse.partially2(funds))
}
suspend fun getImageLimits() = parZip(
@@ -200,11 +205,11 @@ object EhEngine {
private suspend fun getFunds() = ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds)
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
.fetchUsingAsText { if (parse) EventPaneParser.parse(this) else null }
.fetchUsingAsByteBuffer { if (parse) EventPaneParser.parse(this) else null }
suspend fun getProfile(): ProfileParser.Result {
val url = ehRequest(EhUrl.URL_FORUMS).fetchUsingAsText(ForumsParser::parse)
return ehRequest(url, EhUrl.URL_FORUMS).fetchUsingAsText(ProfileParser::parse)
val url = ehRequest(EhUrl.URL_FORUMS).fetchUsingAsByteBuffer(ProfileParser::parseProfileUrl)
return ehRequest(url, EhUrl.URL_FORUMS).fetchUsingAsByteBuffer(ProfileParser::parse)
}
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
@@ -230,18 +235,19 @@ object EhEngine {
.apply { galleryInfoList.fillInfo(url, true) }
.takeUnless { it.galleryInfoList.isEmpty() } ?: GalleryListParser.emptyResult
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
val eventPane = EventPaneParser.parse(this)
if (eventPane != null) {
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(GalleryDetailParser::parse).run {
event?.let {
Settings.lastDawnDays = today
showEventNotification(eventPane)
showEventNotification(it)
}
detail.apply {
// Fill info for database
fillInfo()
filterComments()
}
GalleryDetailParser.parse(this)
}
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
GalleryDetailParser.parsePreviewList(this)
}
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(GalleryDetailParser::parsePreviews)
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(FavoritesParser::parse)
.apply { galleryInfoList.fillInfo(url) }
@@ -275,14 +281,7 @@ object EhEngine {
// Ktor does not handle POST redirect, we need to do it manually
// https://youtrack.jetbrains.com/issue/KTOR-478
val location = response.headers["Location"] ?: url
ehRequest(location, url).fetchUsingAsText {
val document = Jsoup.parse(this)
val elements = document.select("#chd + p")
if (elements.isNotEmpty()) {
throw EhException(elements[0].text())
}
GalleryDetailParser.parseComments(document)
}
ehRequest(location, url).fetchUsingAsByteBuffer(GalleryDetailParser::parseComments)
}
suspend fun modifyFavorites(gid: Long, token: String, dstCat: Int = -1, note: String = "") {
@@ -306,12 +305,12 @@ object EhEngine {
suspend fun getFavoriteNote(gid: Long, token: String) = ehRequest(EhUrl.getAddFavorites(gid, token), EhUrl.getGalleryDetailUrl(gid, token))
.fetchUsingAsText(FavoritesParser::parseNote)
suspend fun downloadArchive(gid: Long, token: String, res: String, isHAtH: Boolean): String? {
val url = EhUrl.getDownloadArchive(gid, token)
suspend fun downloadArchive(gid: Long, token: String, res: String, isHath: Boolean): String? {
val url = EhUrl.getArchiveUrl(gid, token)
val referer = EhUrl.getGalleryDetailUrl(gid, token)
val request = ehRequest(url, referer, EhUrl.origin) {
formBody {
if (isHAtH) {
if (isHath) {
append("hathdl_xres", res)
} else {
append("dltype", res)
@@ -323,12 +322,12 @@ object EhEngine {
}
}
}
var result = request.fetchUsingAsText(ArchiveParser::parseArchiveUrl)
if (!isHAtH) {
var result = request.fetchUsingAsByteBuffer(ArchiveParser::parseArchiveUrl)
if (!isHath) {
if (result == null) {
// Wait for the server to prepare archives
delay(1000)
result = request.fetchUsingAsText(ArchiveParser::parseArchiveUrl)
result = request.fetchUsingAsByteBuffer(ArchiveParser::parseArchiveUrl)
if (result == null) throw EhException("Archive unavailable")
}
return result
@@ -392,10 +391,10 @@ object EhEngine {
jsonBody {
put("method", "gdata")
array("gidlist") {
it.forEach {
it.forEach { info ->
addJsonArray {
add(it.gid)
add(it.token)
add(info.gid)
add(info.token)
}
}
}
@@ -427,7 +426,7 @@ object EhEngine {
put("tags", tags)
put("vote", vote)
}
}.fetchUsingAsText(VoteTagParser::parse)
}.fetchUsingAsByteBuffer(VoteTagParser::parse)
suspend fun getGalleryToken(gid: Long, gtoken: String, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
@@ -55,8 +55,8 @@ object EhFilter : CoroutineScope {
suspend fun needTags() = filters.await().any { it.enable && (it.mode == FilterMode.TAG || it.mode == FilterMode.TAG_NAMESPACE) }
suspend fun filterTitle(info: GalleryInfo) = anyActive(FilterMode.TITLE) { info.title.orEmpty().contains(it.text, true) }
suspend fun filterUploader(info: GalleryInfo) = anyActive(FilterMode.UPLOADER) { it.text == info.uploader }
suspend fun filterTag(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG) { matchTag(tag, it.text.lowercase()) } } ?: false
suspend fun filterTagNamespace(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG_NAMESPACE) { matchTagNamespace(tag, it.text.lowercase()) } } ?: false
suspend fun filterTag(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG) { matchTag(tag, it.text.lowercase()) } } == true
suspend fun filterTagNamespace(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG_NAMESPACE) { matchTagNamespace(tag, it.text.lowercase()) } } == true
suspend fun filterCommenter(commenter: String) = anyActive(FilterMode.COMMENTER) { it.text == commenter }
suspend fun filterComment(comment: String) = anyActive(FilterMode.COMMENT) { regex(it).containsMatchIn(comment) }
}
@@ -32,13 +32,13 @@ import io.ktor.http.HttpMethod
import io.ktor.http.ParametersBuilder
import io.ktor.http.content.TextContent
import kotlinx.coroutines.cancel
import kotlinx.io.Source
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 okio.BufferedSource
import kotlinx.serialization.json.io.decodeFromSource
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
@@ -47,10 +47,13 @@ suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely
it.bodyAsUtf8Text().parseAs<T>()
}
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
inline fun <reified T> Source.parseAs(): T = json.decodeFromSource(this)
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
val json = Json { ignoreUnknownKeys = true }
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
fun HttpRequestBuilder.applyEhConfig(referer: String?, origin: String?) {
header("Referer", referer)
@@ -19,35 +19,39 @@ import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import arrow.core.Either
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.copyTo
import com.hippo.ehviewer.util.bodyAsUtf8Text
import com.hippo.ehviewer.util.ensureSuccess
import com.hippo.ehviewer.util.sha1
import com.hippo.ehviewer.util.utf8
import com.hippo.files.metadataOrNull
import com.hippo.files.read
import com.hippo.files.write
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import java.io.File
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.io.Source
import kotlinx.io.writeString
import kotlinx.serialization.json.io.encodeToSink
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import moe.tarsin.coroutines.runSuspendCatching
import okio.BufferedSource
import okio.Path.Companion.toOkioPath
import okio.buffer
import okio.source
import splitties.init.appCtx
object EhTagDatabase : CoroutineScope {
override val coroutineContext = Dispatchers.IO + Job()
private const val NAMESPACE_PREFIX = "n"
private val PREFIXES = arrayOf(
"-",
@@ -57,180 +61,139 @@ object EhTagDatabase : CoroutineScope {
)
private lateinit var tagGroups: Map<String, Map<String, String>>
private val updateLock = Mutex()
override val coroutineContext = Dispatchers.IO + Job()
var initialized by mutableStateOf(false)
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? = tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
private fun internalSuggestFlow(
tags: Map<String, String>,
keyword: String,
translate: Boolean,
exactly: Boolean,
): Flow<Pair<String?, String>> = flow {
if (exactly) {
tags[keyword]?.let {
emit(Pair(it.takeIf { translate }, keyword))
}
// Copied from https://github.com/EhTagTranslation/EhSyringe/blob/c0cf88da04081072492829673aca34380b14dae6/src/plugin/suggest.ts#L37
// TODO: Optimize
private val nsScore = mapOf(
TagNamespace.Other.prefix to 10f,
TagNamespace.Female.prefix to 9f,
TagNamespace.Male.prefix to 8.5f,
TagNamespace.Mixed.prefix to 8f,
TagNamespace.Parody.prefix to 3.3f,
TagNamespace.Character.prefix to 2.8f,
TagNamespace.Artist.prefix to 2.5f,
TagNamespace.Cosplayer.prefix to 2.4f,
TagNamespace.Group.prefix to 2.2f,
TagNamespace.Language.prefix to 2f,
)
data class Tag(val tag: String, val hint: String?, val score: Float)
context(_: Context)
fun suggestion(rawKeyword: String, expectTranslate: Boolean): List<Tag> {
val keyword = PREFIXES.fold(rawKeyword) { kwd, pfx -> kwd.removePrefix(pfx) }
if (!initialized || keyword.isEmpty()) return emptyList()
val translate = expectTranslate && translatable
val prefix = rawKeyword.dropLast(keyword.length)
val ns = keyword.substringBefore(':')
val tag = keyword.drop(ns.length + 1)
val nsPrefix = TagNamespace.from(ns)?.prefix ?: ns
val tags = tagGroups[nsPrefix.takeIf { tag.isNotEmpty() && it != NAMESPACE_PREFIX }]
return if (tags != null) {
lookup(tags, tag, translate).map { it.copy(tag = "$prefix$nsPrefix:${it.tag}") }
} else {
if (translate) {
tags.forEach { (tag, hint) ->
if (tag != keyword &&
(tag.containsIgnoreSpace(keyword) || hint.containsIgnoreSpace(keyword))
) {
emit(Pair(hint, tag))
}
tagGroups.asSequence().flatMap { (nsPrefix, tags) ->
if (nsPrefix != NAMESPACE_PREFIX) {
lookup(tags, keyword, translate).map { it.copy(tag = "$prefix$nsPrefix:${it.tag}", score = it.score / (nsScore[nsPrefix] ?: 1f)) }
} else {
lookup(tags, keyword, translate).map { it.copy(tag = "$prefix${it.tag}:", score = 0f) }
}
}
}.toMutableList().apply { sortBy { it.score } }
}
private fun lookup(tags: Map<String, String>, keyword: String, translate: Boolean) = if (translate) {
tags.asSequence().mapNotNull { (tag, hint) ->
val score = if (tag.startsWith(keyword, ignoreCase = true)) {
tag.length
} else {
tags.keys.forEach { tag ->
if (tag != keyword && tag.containsIgnoreSpace(keyword)) {
emit(Pair(null, tag))
if (tag.indexOf(" $keyword", ignoreCase = true) != -1) {
tag.length * 2
} else {
when (hint.indexOf(keyword, ignoreCase = true)) {
-1 -> 0
0 -> hint.length
else -> hint.length * 2
}
}
}
if (score != 0) Tag(tag, hint, score.toFloat()) else null
}
}
// Construct a cold flow for tag database suggestions
fun suggestFlow(
keyword: String,
translate: Boolean,
exactly: Boolean = false,
): Flow<Pair<String?, String>> = flow {
var mKeyword = keyword
PREFIXES.forEach { mKeyword = mKeyword.removePrefix(it) }
val prefix = keyword.dropLast(mKeyword.length)
val namespace = mKeyword.substringBefore(':')
val tagKeyword = mKeyword.drop(namespace.length + 1)
val namespacePrefix = TagNamespace.from(namespace)?.prefix ?: namespace
val tags = tagGroups[namespacePrefix.takeIf { tagKeyword.isNotEmpty() && it != NAMESPACE_PREFIX }]
tags?.let {
internalSuggestFlow(it, tagKeyword, translate, exactly).collect { (hint, tag) ->
emit(Pair(hint, "$prefix$namespacePrefix:$tag"))
}
} ?: tagGroups.forEach { (namespacePrefix, tags) ->
if (namespacePrefix != NAMESPACE_PREFIX) {
internalSuggestFlow(tags, mKeyword, translate, exactly).collect { (hint, tag) ->
emit(Pair(hint, "$prefix$namespacePrefix:$tag"))
}
} else {
tags.keys.asSequence().mapNotNull { tag ->
val score = if (tag.startsWith(keyword, ignoreCase = true)) {
tag.length
} else {
internalSuggestFlow(tags, mKeyword, translate, exactly).collect { (hint, namespacePrefix) ->
emit(Pair(hint, "$prefix$namespacePrefix:"))
if (tag.indexOf(" $keyword", ignoreCase = true) != -1) {
tag.length * 2
} else {
0
}
}
if (score != 0) Tag(tag, null, score.toFloat()) else null
}
}
private fun String.removeSpace(): String = replace(" ", "")
private fun metadata(context: Context): Array<String> = context.resources.getStringArray(R.array.tag_translation_metadata)
private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean = removeSpace().contains(other.removeSpace(), ignoreCase)
context(ctx: Context)
val translatable
get() = ctx.resources.getBoolean(R.bool.tag_translatable)
private fun getMetadata(context: Context): Array<String>? = context.resources.getStringArray(R.array.tag_translation_metadata)
.takeIf { it.size == 4 }
fun isTranslatable(context: Context): Boolean = context.resources.getBoolean(R.bool.tag_translatable)
private fun getFileContent(file: File): String? = runCatching {
file.source().buffer().use { it.readUtf8() }
}.getOrNull()
private fun checkData(sha1: String?, data: File): Boolean = sha1 != null && sha1 == data.toOkioPath().sha1()
private suspend fun save(client: HttpClient, url: String, file: File) {
runCatching {
client.prepareGet(url).executeSafely {
it.status.ensureSuccess()
it.bodyAsChannel().copyTo(file)
}
}.onFailure {
file.delete()
}.getOrThrow()
private suspend fun fetch(url: String) = ktorClient.prepareGet(url).executeSafely {
it.status.ensureSuccess()
it.bodyAsUtf8Text()
}
suspend fun update() {
fun launchUpdate() = launch {
updateLock.withLock {
updateInternal()
}
}
private fun issueUpdateInMemoryData(file: File? = null) {
val dataFile = file ?: getMetadata(appCtx)?.let { metadata ->
val dataName = metadata[2]
val dir = AppConfig.getFilesDir("tag-translations")
File(dir, dataName).takeIf { it.exists() }
} ?: return
tagGroups = dataFile.source().buffer().use(BufferedSource::parseAs)
initialized = true
}
init {
launch {
updateLock.withLock {
runSuspendCatching {
issueUpdateInMemoryData()
}.onFailure {
logcat(it)
}
updateInternal()
}
}
}
private suspend fun updateInternal() {
getMetadata(appCtx)?.let { metadata ->
val sha1Name = metadata[0]
val sha1Url = metadata[1]
val dataName = metadata[2]
val dataUrl = metadata[3]
val dir = AppConfig.getFilesDir("tag-translations")
checkNotNull(dir)
val sha1File = File(dir, sha1Name)
val dataFile = File(dir, dataName)
runSuspendCatching {
// Check current sha1 and current data
val sha1 = getFileContent(sha1File)
if (!checkData(sha1, dataFile)) {
FileUtils.delete(sha1File)
FileUtils.delete(dataFile)
}
// Save new sha1
val tempSha1File = File(dir, "$sha1Name.tmp")
save(ktorClient, sha1Url, tempSha1File)
val tempSha1 = getFileContent(tempSha1File)
// Check new sha1 and current sha1
if (tempSha1 == sha1) {
// The data is the same
FileUtils.delete(tempSha1File)
return
}
// Save new data
val tempDataFile = File(dir, "$dataName.tmp")
save(ktorClient, dataUrl, tempDataFile)
// Check new sha1 and new data
if (!checkData(tempSha1, tempDataFile)) {
FileUtils.delete(tempSha1File)
FileUtils.delete(tempDataFile)
return
}
// Replace current sha1 and current data with new sha1 and new data
FileUtils.delete(sha1File)
FileUtils.delete(dataFile)
tempSha1File.renameTo(sha1File)
tempDataFile.renameTo(dataFile)
// Read new EhTagDatabase
issueUpdateInMemoryData(dataFile)
atomicallyUpdate()
}.onFailure {
logcat(it)
}
}
}
private suspend fun atomicallyUpdate() {
val (sha1Name, sha1Url, dataName, dataUrl) = metadata(appCtx)
val workdir = AppConfig.tagTranslationsDir
val sha1File = workdir / sha1Name
val dataFile = workdir / dataName
if (!initialized) {
Either.catch {
tagGroups = dataFile.read(Source::parseAs)
initialized = true
// Perform only one automatic update (on app startup) per day
val lastModified = Instant.fromEpochMilliseconds(dataFile.metadataOrNull()!!.lastModifiedAtMillis!!)
if (Clock.System.now() - lastModified < 1.days) return
}.onLeft {
logcat(it)
}
}
val currentSha1 = if (initialized) {
Either.catch { sha1File.utf8() }.getOrNull()
} else {
null
}
val newSha1 = fetch(sha1Url)
if (newSha1 == currentSha1) return
val rawData = fetch(dataUrl)
tagGroups = json.parseToJsonElement(rawData).jsonObject["data"]!!.jsonArray.associate { e ->
val data = e.jsonObject
val namespace = data["namespace"]!!.jsonPrimitive.content
val prefix = if (namespace == "rows") NAMESPACE_PREFIX else TagNamespace.from(namespace)!!.prefix!!
prefix to data["data"]!!.jsonObject.mapValues { it.value.jsonObject["name"]!!.jsonPrimitive.content }
}
initialized = true
sha1File.write { writeString(newSha1) }
dataFile.write { json.encodeToSink(tagGroups, this) }
}
}
@@ -125,11 +125,16 @@ object EhUrl {
addQueryParameter("act", "addfav")
}.buildString()
fun getDownloadArchive(gid: Long, token: String) = ehUrl("archiver.php") {
fun getArchiveUrl(gid: Long, token: String) = ehUrl("archiver.php") {
addQueryParameter("gid", "$gid")
addQueryParameter("token", token)
}.buildString()
fun getTorrentUrl(gid: Long, token: String) = ehUrl("gallerytorrents.php") {
addQueryParameter("gid", "$gid")
addQueryParameter("t", token)
}.buildString()
fun getTagDefinitionUrl(tag: String) = "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
fun getTrackerUrl(gid: Long, key: String?) = ehUrl(
@@ -15,18 +15,31 @@
*/
package com.hippo.ehviewer.client
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Environment
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.core.net.toUri
import arrow.core.memoize
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.parser.Archive
import com.hippo.ehviewer.spider.SpiderDen
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.addTextToClipboard
import com.materialkolor.hct.Hct
import com.materialkolor.ktx.from
import com.materialkolor.ktx.toColor
import kotlin.math.abs
import eu.kanade.tachiyomi.util.lang.withUIContext
import splitties.systemservices.downloadManager
object EhUtils {
const val NONE = -1 // Use it for homepage
@@ -87,6 +100,9 @@ object EhUtils {
val isExHentai: Boolean
get() = Settings.gallerySite.value == EhUrl.SITE_EX
val isMpvAvailable
get() = EhCookieStore.getHathPerks()?.contains('q') == true
fun getCategory(type: String?): Int {
for (entry in CATEGORY_STRINGS) {
for (str in entry.first) {
@@ -102,25 +118,10 @@ object EhUtils {
fun invCategory(category: Int): Int = category.inv() and ALL_CATEGORY
private fun differenceDegrees(a: Double, b: Double): Double = 180.0 - abs(abs(a - b) - 180.0)
private fun sanitizeDegreesDouble(degrees: Double): Double {
val deg = degrees % 360.0
return if (deg < 0) deg + 360.0 else deg
}
private fun rotationDirection(from: Double, to: Double): Double {
val increasingDifference = sanitizeDegreesDouble(to - from)
return if (increasingDifference <= 180.0) 1.0 else -1.0
}
val harmonizeWithRole = { primaryContainer: Color, src: Color ->
val mergeColor = { primaryContainer: Color, src: Color ->
val fromHct = Hct.from(src)
val toHct = Hct.from(primaryContainer)
val differenceDegrees = differenceDegrees(toHct.hue, fromHct.hue)
val rotationDegrees = minOf(differenceDegrees * 0.5, 15.0)
val outputHue = sanitizeDegreesDouble(fromHct.hue + rotationDegrees * rotationDirection(fromHct.hue, toHct.hue))
Hct.from(outputHue, toHct.chroma, toHct.tone).toColor()
Hct.from(fromHct.hue, toHct.chroma, toHct.tone).toColor()
}.memoize()
@Stable
@@ -142,9 +143,9 @@ object EhUtils {
else -> BG_COLOR_UNKNOWN
}.toInt(),
)
return if (Settings.harmonizeCategoryColor) {
return if (Settings.harmonizeCategoryColor.value) {
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
harmonizeWithRole(primaryContainer, primary)
mergeColor(primaryContainer, primary)
} else {
primary
}
@@ -152,6 +153,8 @@ object EhUtils {
val categoryTextColor = Color(0xffe6e0e9)
val favoriteIconColor = Color(0xffff3040)
fun signOut() {
EhCookieStore.removeAllCookies()
Settings.displayName.value = null
@@ -160,7 +163,7 @@ object EhUtils {
Settings.needSignIn.value = true
}
fun getSuitableTitle(gi: GalleryInfo): String = if (Settings.showJpnTitle) {
fun getSuitableTitle(gi: GalleryInfo): String = if (Settings.showJpnTitle.value) {
if (gi.titleJpn.isNullOrEmpty()) gi.title else gi.titleJpn
} else {
if (gi.title.isNullOrEmpty()) gi.titleJpn else gi.title
@@ -175,4 +178,35 @@ object EhUtils {
// TODO But not sure every '|' means that
return title.substringBeforeLast('|').trim().ifEmpty { null }
}
context(ctx: Context)
suspend fun downloadArchive(galleryDetail: GalleryDetail, archive: Archive) {
val gid = galleryDetail.gid
EhEngine.downloadArchive(gid, galleryDetail.token, archive.res, archive.isHath)?.let {
val uri = it.toUri()
val intent = Intent().apply {
action = Intent.ACTION_VIEW
setDataAndType(uri, "application/zip")
}
val name = "$gid-${getSuitableTitle(galleryDetail)}.zip"
try {
ctx.startActivity(intent)
withUIContext { addTextToClipboard(name, true) }
} catch (_: ActivityNotFoundException) {
val r = DownloadManager.Request(uri)
r.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
)
r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
downloadManager.enqueue(r)
}
if (Settings.archiveMetadata.value) {
SpiderDen(galleryDetail).apply {
initDownloadDir()
writeComicInfo()
}
}
}
}
}
@@ -15,7 +15,6 @@
*/
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
@@ -23,10 +22,8 @@ import androidx.room.PrimaryKey
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Parcelize
@Serializable
@Entity(tableName = "GALLERIES")
@TypeConverters(SimpleTagsConverter::class)
@@ -88,7 +85,7 @@ data class BaseGalleryInfo(
@Ignore
override var favoriteNote: String? = null,
) : GalleryInfo, Parcelable
) : GalleryInfo
class SimpleTagsConverter {
@TypeConverter
@@ -15,21 +15,23 @@
*/
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
import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class FavListUrlBuilder(
val favCat: Int = FAV_CAT_ALL,
val keyword: String? = null,
var jumpTo: String? = null,
var prev: String? = null,
var next: String? = null,
) : Parcelable {
) {
val isLocal
get() = favCat == FAV_CAT_LOCAL
fun setIndex(index: String?, isNext: Boolean) {
next = index.takeIf { isNext }
prev = index.takeUnless { isNext }
@@ -15,10 +15,9 @@
*/
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class GalleryComment(
val id: Long,
val score: Int,
@@ -33,4 +32,4 @@ data class GalleryComment(
val user: String?,
val comment: String,
val lastEdited: Long,
) : Parcelable
)
@@ -15,11 +15,10 @@
*/
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class GalleryCommentList(
val comments: List<GalleryComment>,
val hasMore: Boolean,
) : Parcelable
)
@@ -15,10 +15,11 @@
*/
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import androidx.compose.ui.util.fastFlatMap
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANGS
import kotlinx.parcelize.Parcelize
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhFilter
import kotlinx.serialization.Serializable
private val LANGUAGES = arrayOf(
"English",
@@ -37,14 +38,12 @@ private val LANGUAGES = arrayOf(
"Dutch",
)
@Parcelize
@Serializable
data class GalleryDetail(
val galleryInfo: BaseGalleryInfo = BaseGalleryInfo(),
var apiUid: Long = -1L,
var apiKey: String? = null,
var torrentCount: Int = 0,
var torrentUrl: String? = null,
var archiveUrl: String? = null,
var parent: String? = null,
var newerVersions: List<BaseGalleryInfo> = emptyList(),
var visible: String? = null,
@@ -54,12 +53,30 @@ data class GalleryDetail(
var ratingCount: Int = 0,
val tagGroups: List<GalleryTagGroup>,
var comments: GalleryCommentList,
val previewPages: Int,
val previewList: List<GalleryPreview>,
) : GalleryInfo by galleryInfo, Parcelable {
fun fillInfo() {
) : GalleryInfo by galleryInfo {
suspend fun fillInfo() {
val index = LANGUAGES.indexOf(language)
if (index != -1) simpleLanguage = S_LANGS[index]
simpleTags = tagGroups.fastFlatMap(GalleryTagGroup::tags)
if (index != -1) simpleLanguage = GalleryInfo.S_LANGS[index]
simpleTags = tagGroups.fastFlatMap(GalleryTagGroup::tags).map { (text, power, _) ->
if (power == PowerStatus.Weak) "_$text" else text
}
if (favoriteSlot == GalleryInfo.NOT_FAVORITED && EhDB.containLocalFavorites(gid)) {
favoriteSlot = GalleryInfo.LOCAL_FAVORITED
}
}
suspend fun filterComments() {
comments = with(comments) {
val scoreThreshold = Settings.commentThreshold.value
copy(
comments = comments.filter {
it.uploader ||
it.score > scoreThreshold &&
!EhFilter.filterCommenter(it.user.orEmpty()) &&
!EhFilter.filterComment(it.comment)
},
)
}
}
}
@@ -15,30 +15,46 @@
*/
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import com.hippo.ehviewer.client.v1ThumbPrefix
import kotlinx.parcelize.Parcelize
import com.hippo.ehviewer.client.getThumbKey
import com.hippo.ehviewer.client.getV2PreviewKey
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.CborArray
sealed interface GalleryPreview : Parcelable {
@Serializable
sealed interface GalleryPreview {
val url: String
val imageKey: String
val position: Int
val pToken: String
}
@Parcelize
@Serializable
@SerialName("V1")
data class V1GalleryPreview(
override val imageKey: String,
override val url: String,
override val position: Int,
override val pToken: String,
) : GalleryPreview {
override val url get() = v1ThumbPrefix + imageKey
override val imageKey get() = getThumbKey(url)
}
@Parcelize
@Serializable
@SerialName("V2")
data class V2GalleryPreview(
override val url: String,
override val imageKey: String,
override val position: Int,
override val pToken: String,
val offsetX: Int,
val clipWidth: Int,
val clipHeight: Int,
) : GalleryPreview
) : GalleryPreview {
override val imageKey get() = getV2PreviewKey(url)
}
@Serializable
@CborArray
class GalleryPreviewList(
val previews: List<GalleryPreview>,
val total: Int,
)
@@ -0,0 +1,22 @@
package com.hippo.ehviewer.client.data
import kotlinx.serialization.Serializable
enum class VoteStatus(val display: String) {
None(""),
Up("+1"),
Down("-1"),
}
enum class PowerStatus {
Solid,
Active,
Weak,
}
@Serializable
data class GalleryTag(
val text: String,
val power: PowerStatus,
val vote: VoteStatus,
)
@@ -15,11 +15,10 @@
*/
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class GalleryTagGroup(
val nameSpace: TagNamespace,
val tags: List<String>,
) : Parcelable
val namespace: TagNamespace,
val tags: List<GalleryTag>,
)
@@ -15,7 +15,6 @@
*/
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
@@ -26,9 +25,9 @@ import com.hippo.ehviewer.dao.QuickSearch
import com.hippo.ehviewer.ui.main.AdvanceTable
import io.ktor.http.Parameters
import kotlin.text.toIntOrNull
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class ListUrlBuilder(
var mode: Int = MODE_NORMAL,
private var prev: String? = null,
@@ -44,7 +43,7 @@ data class ListUrlBuilder(
var minRating: Int = -1,
var pageFrom: Int = -1,
var pageTo: Int = -1,
) : Parcelable {
) {
fun setIndex(index: String, isNext: Boolean = true) {
next = index.takeIf { isNext }
@@ -1,10 +1,15 @@
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
@Parcelize
sealed class TagNamespace(val value: String, val prefix: String?) : Parcelable {
@Serializable(TagNamespaceSerializer::class)
sealed class TagNamespace(val value: String, val prefix: String?) {
object Artist : TagNamespace("artist", "a")
object Cosplayer : TagNamespace("cosplayer", "cos")
object Character : TagNamespace("character", "c")
@@ -36,3 +41,12 @@ sealed class TagNamespace(val value: String, val prefix: String?) : Parcelable {
}
}
}
object TagNamespaceSerializer : KSerializer<TagNamespace> {
override val descriptor = PrimitiveSerialDescriptor("TagNamespace", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): TagNamespace {
val string = decoder.decodeString()
return TagNamespace.from(string) ?: throw SerializationException("Unknown tag namespace $string")
}
override fun serialize(encoder: Encoder, value: TagNamespace) = encoder.encodeString(value.value)
}
@@ -0,0 +1,5 @@
package com.hippo.ehviewer.client.exception
import com.hippo.ehviewer.R
class CloudflareBypassException : EhException(R.string.cloudflare_bypass_failed)
@@ -15,4 +15,6 @@
*/
package com.hippo.ehviewer.client.exception
class NoHAtHClientException(detailMessage: String?) : EhException(detailMessage)
import com.hippo.ehviewer.R
class NoHathClientException() : EhException(R.string.download_archive_failure_no_hath)
@@ -15,72 +15,43 @@
*/
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.NoHAtHClientException
import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup
import arrow.core.Either.Companion.catch
import arrow.core.getOrElse
import arrow.core.partially3
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
object ArchiveParser {
private val PATTERN_ARCHIVE_URL =
Regex("<strong>(.*)</strong>.*<a href=\"([^\"]*)\">Click Here To Start Downloading</a>")
private val PATTERN_CURRENT_FUNDS =
Regex("<p>([\\d,]+) GP \\[[^]]*] &nbsp; ([\\d,]+) Credits \\[[^]]*]</p>")
private val PATTERN_HATH_ARCHIVE =
Regex("<p><a href=\"[^\"]*\" onclick=\"return do_hathdl\\('([0-9]+|org)'\\)\">([^<]+)</a></p>\\s*<p>([\\w. ]+)</p>\\s*<p>([\\w. ]+)</p>")
private const val ERROR_NEED_HATH_CLIENT =
"You must have a H@H client assigned to your account to use this feature."
private const val ERROR_INSUFFICIENT_FUNDS =
"You do not have enough funds to download this archive."
fun parse(body: String, maybeFunds: Funds?): Result {
val archiveList = ArrayList<Archive>()
Jsoup.parse(body).select("#db>div>div").forEach { element ->
if (element.childrenSize() > 0 && !element.attr("style").contains("color:#CCCCCC")) {
runCatching {
val res = element.selectFirst("form>input")!!.attr("value")
val name = element.selectFirst("form>div>input")!!.attr("value")
val size = element.selectFirst("p>strong")!!.text()
val cost = element.selectFirst("div>strong")!!.text().replace(",", "")
Archive(res, name, size, cost, false)
}.onSuccess {
archiveList.add(it)
}.onFailure {
logcat(it)
}
}
fun parse(body: ByteBuffer, maybeFunds: Funds?) = catch {
if (maybeFunds != null) {
val archives = unmarshalParsingAs<List<Archive>>(body, ::parseArchives)
Result(archives, maybeFunds)
} else {
unmarshalParsingAs<Result>(body, ::parseArchives.partially3(true))
}
PATTERN_HATH_ARCHIVE.findAll(body).forEach { matchResult ->
val (res, name, size, cost) = matchResult.groupValues.slice(1..4)
.map { ParserUtils.trim(it) }
val item = Archive(res, name, size, cost, true)
archiveList.add(item)
}
val funds = maybeFunds ?: PATTERN_CURRENT_FUNDS.find(body)!!.groupValues.run {
val fundsGP = ParserUtils.parseInt(get(1), 0) / 1000
val fundsC = ParserUtils.parseInt(get(2), 0)
Funds(fundsGP, fundsC)
}
return Result(archiveList, funds)
}.getOrElse {
throw ParseException("Can't parse archive list", it)
}
fun parseArchiveUrl(body: String): String? {
if (body.contains(ERROR_NEED_HATH_CLIENT)) {
throw NoHAtHClientException("No H@H client")
} else if (body.contains(ERROR_INSUFFICIENT_FUNDS)) {
throw InsufficientFundsException()
}
return Jsoup.parse(body).selectFirst("#continue>a[href]")
?.let { it.attr("href") + "?start=1" }
// TODO: Check more errors
fun parseArchiveUrl(body: ByteBuffer) = catch {
unmarshalParsingAs<String?>(body, ::parseArchiveUrl)
}.getOrElse {
throw ParseException("Can't parse archive url", it)
}
@Serializable
data class Result(val archiveList: List<Archive>, val funds: Funds)
}
@Serializable
class Archive(
val res: String,
val name: String,
val size: String,
val cost: String,
val isHAtH: Boolean,
val isHath: Boolean,
)
private external fun parseArchives(body: ByteBuffer, size: Int = body.limit(), parseFunds: Boolean = false): Int
private external fun parseArchiveUrl(body: ByteBuffer, size: Int = body.limit()): Int
@@ -17,8 +17,10 @@
*/
package com.hippo.ehviewer.client.parser
import org.jsoup.Jsoup
import java.nio.ByteBuffer
object EventPaneParser {
fun parse(body: String): String? = Jsoup.parse(body).getElementById("eventpane")?.html()
private external fun parseEventPane(body: ByteBuffer, size: Int = body.limit()): Int
fun parse(body: ByteBuffer): String? = unmarshalParsingAs(body, ::parseEventPane)
}
@@ -1,33 +0,0 @@
/*
* 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.parser
import arrow.core.Either
import arrow.core.getOrElse
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.exception.NotLoggedInException
import org.jsoup.Jsoup
object ForumsParser {
fun parse(body: String): String = Either.catch {
val d = Jsoup.parse(body, EhUrl.URL_FORUMS)
val userlinks = d.getElementById("userlinks")
val child = userlinks!!.child(0).child(0).child(0)
child.attr("href")
}.getOrElse {
throw NotLoggedInException()
}
}
@@ -17,429 +17,38 @@ package com.hippo.ehviewer.client.parser
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.unzip
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhFilter
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.GalleryCommentList
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import com.hippo.ehviewer.client.data.GalleryTagGroup
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.client.data.V1GalleryPreview
import com.hippo.ehviewer.client.data.V2GalleryPreview
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.OffensiveException
import com.hippo.ehviewer.client.data.GalleryPreviewList
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.exception.PiningException
import com.hippo.ehviewer.client.getThumbKey
import com.hippo.ehviewer.client.getV2PreviewKey
import com.hippo.ehviewer.util.toEpochMillis
import com.hippo.ehviewer.util.toFloatOrDefault
import com.hippo.ehviewer.util.toIntOrDefault
import com.hippo.ehviewer.util.trimAnd
import com.hippo.ehviewer.util.unescapeXml
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format.MonthNames
import kotlinx.datetime.format.char
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.select.NodeTraversor
import org.jsoup.select.NodeVisitor
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.CborArray
object GalleryDetailParser {
private val PATTERN_ERROR = Regex("<div class=\"d\">\n<p>([^<]+)</p>")
private val PATTERN_DETAIL = Regex(
"var gid = (\\d+);.+?var token = \"([a-f0-9]+)\";.+?var apiuid = ([\\-\\d]+);.+?var apikey = \"([a-f0-9]+)\";",
RegexOption.DOT_MATCHES_ALL,
)
private val PATTERN_TORRENT =
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download \\((\\d+)\\)</a>")
private val PATTERN_ARCHIVE =
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Archive Download</a>")
private val PATTERN_COVER = Regex("width:(\\d+)px; height:(\\d+)px.+?url\\((.+?)\\)")
private val PATTERN_PAGES =
Regex("<tr><td[^<>]*>Length:</td><td[^<>]*>([\\d,]+) pages</td></tr>")
private val PATTERN_PREVIEW_PAGES =
Regex("<td[^>]+><a[^>]+>([\\d,]+)</a></td><td[^>]+>(?:<a[^>]+>)?&gt;(?:</a>)?</td>")
private val PATTERN_NEWER_DATE = Regex(", added (.+?)<br />")
private val PATTERN_FAVORITE_SLOT =
Regex("/fav.png\\); background-position:0px -(\\d+)px")
private val EMPTY_GALLERY_COMMENT_LIST = GalleryCommentList(emptyList(), false)
private val PreviewRegex =
Regex("<a href=\"([^\"]+)\">(?:<div>)?<div title=\"Page (\\d+)(?:[^\"]+\"){2}\\D+(\\d+)\\D+(\\d+)[^(]+\\(([^)]+)\\)(?: -(\\d+))?")
// dd MMMM yyyy, HH:mm
private val WEB_COMMENT_DATE_FORMAT = LocalDateTime.Format {
dayOfMonth()
char(' ')
monthName(MonthNames.ENGLISH_FULL)
char(' ')
year()
chars(", ")
hour()
char(':')
minute()
}
private const val OFFENSIVE_STRING =
"<p>(And if you choose to ignore this warning, you lose all rights to complain about it in the future.)</p>"
private const val PINING_STRING = "<p>This gallery is pining for the fjords.</p>"
suspend fun parse(body: String): GalleryDetail {
if (body.contains(OFFENSIVE_STRING)) {
throw OffensiveException()
}
if (body.contains(PINING_STRING)) {
throw PiningException()
}
// Error info
PATTERN_ERROR.find(body)?.run { throw EhException(groupValues[1]) }
val document = Jsoup.parse(body)
val galleryDetail = GalleryDetail(
tagGroups = parseTagGroups(document),
comments = parseComments(document),
previewPages = parsePreviewPages(body),
previewList = parsePreviewList(body).first,
)
parseDetail(galleryDetail, document, body)
// Fill info for database
galleryDetail.fillInfo()
return galleryDetail
}
private suspend fun parseDetail(gd: GalleryDetail, d: Document, body: String) {
PATTERN_DETAIL.find(body)?.apply {
gd.gid = groupValues[1].toLongOrNull() ?: -1L
gd.token = groupValues[2]
gd.apiUid = groupValues[3].toLongOrNull() ?: -1L
gd.apiKey = groupValues[4]
} ?: throw ParseException("Can't parse gallery detail")
if (gd.gid == -1L) {
throw ParseException("Can't parse gallery detail")
}
PATTERN_TORRENT.find(body)?.run {
gd.torrentUrl = groupValues[1].trim().unescapeXml()
gd.torrentCount = groupValues[2].toIntOrNull() ?: 0
}
PATTERN_ARCHIVE.find(body)?.run {
gd.archiveUrl = groupValues[1].trim().unescapeXml()
}
Either.catch {
val gm = d.getElementsByClass("gm")[0]
// Thumb url
gm.getElementById("gd1")?.child(0)?.attr("style")?.trim()?.let {
gd.thumbKey = getThumbKey(PATTERN_COVER.find(it)!!.groupValues[3])
}
gd.title = gm.getElementById("gn")?.text()?.trim()
gd.titleJpn = gm.getElementById("gj")?.text()?.trim()
// Category
gd.category = Either.catch {
val gdc = gm.getElementById("gdc")!!
var ce = gdc.getElementsByClass("cn").first()
if (ce == null) {
ce = gdc.getElementsByClass("cs").first()
}
EhUtils.getCategory(ce!!.text())
}.getOrElse {
EhUtils.UNKNOWN
}
// Uploader
val gdn = gm.getElementById("gdn")
if (null != gdn) {
gd.disowned = gdn.attr("style").contains("opacity:0.5")
gd.uploader = gdn.text().trim()
} else {
gd.uploader = ""
}
val gdd = gm.getElementById("gdd")
gd.posted = ""
gd.parent = ""
gd.visible = ""
gd.size = ""
gd.pages = 0
gd.favoriteCount = 0
val es = gdd!!.child(0).child(0).children()
es.forEach {
parseDetailInfo(gd, it)
}
// Rating count
val ratingCount = gm.getElementById("rating_count")
if (null != ratingCount) {
gd.ratingCount = ratingCount.text().trim().toIntOrDefault(0)
} else {
gd.ratingCount = 0
}
// Rating
val ratingLabel = gm.getElementById("rating_label")
if (null != ratingLabel) {
val ratingStr = ratingLabel.text().trim()
if ("Not Yet Rated" == ratingStr) {
gd.rating = -1.0f
} else {
val index = ratingStr.indexOf(' ')
if (index == -1 || index >= ratingStr.length) {
gd.rating = 0f
} else {
gd.rating = ratingStr.substring(index + 1).toFloatOrDefault(0f)
}
}
} else {
gd.rating = -1.0f
}
// isFavorited
val gdf = gm.getElementById("gdf")
if (gdf != null) {
val favoriteName = gdf.text().trim()
if (favoriteName != "Add to Favorites") {
gd.favoriteName = gdf.text().trim()
PATTERN_FAVORITE_SLOT.find(body)?.run {
gd.favoriteSlot = ((groupValues[1].toIntOrNull() ?: 2) - 2) / 19
}
}
}
if (gd.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(gd.gid)) {
gd.favoriteSlot = LOCAL_FAVORITED
}
}.getOrElse {
throw ParseException("Can't parse gallery detail")
}
// newer version
d.getElementById("gnd")?.run {
val dates = PATTERN_NEWER_DATE.findAll(body).map { it.groupValues[1] }.toList()
gd.newerVersions = select("a").mapIndexedNotNull { index, element ->
val result = GalleryDetailUrlParser.parse(element.attr("href"))
result?.run {
BaseGalleryInfo(gid, token, element.text().trim(), posted = dates[index])
}
}
}
}
private fun parseDetailInfo(gd: GalleryDetail, e: Element) {
val es = e.children()
if (es.size < 2) {
return
}
val key = es[0].text().trim()
val value = es[1].ownText().trim()
if (key.startsWith("Posted")) {
gd.posted = value
} else if (key.startsWith("Parent")) {
val a = es[1].children().first()
if (a != null) {
gd.parent = a.attr("href")
}
} else if (key.startsWith("Visible")) {
gd.visible = value
} else if (key.startsWith("Language")) {
gd.language = value
} else if (key.startsWith("File Size")) {
gd.size = value
} else if (key.startsWith("Length")) {
val index = value.indexOf(' ')
if (index >= 0) {
gd.pages = value.substring(0, index).toIntOrDefault(1)
} else {
gd.pages = 1
}
} else if (key.startsWith("Favorited")) {
when (value) {
"Never" -> gd.favoriteCount = 0
"Once" -> gd.favoriteCount = 1
else -> {
val index = value.indexOf(' ')
if (index == -1) {
gd.favoriteCount = 0
} else {
gd.favoriteCount = value.substring(0, index).toIntOrDefault(0)
}
}
}
}
}
private fun parseSingleTagGroup(element: Element): GalleryTagGroup? = Either.catch {
val nameSpace = with(element.child(0).text()) {
// Remove last ':'
TagNamespace.from(substring(0, length - 1))
}
checkNotNull(nameSpace) { "$nameSpace is not a valid tag namespace!" }
val tags = element.child(1).children().map { e ->
val text = e.text()
// Sometimes parody tag is followed with '|' and english translate, just remove them
val index = text.indexOf('|')
val tag = if (index >= 0) text.substring(0, index).trim() else text
// weak tag
if (e.className() == "gtw") "_$tag" else tag
}
check(tags.isNotEmpty()) { "TagGroup is empty!" }
GalleryTagGroup(nameSpace, tags)
fun parse(body: ByteBuffer) = Either.catch {
unmarshalParsingAs<Result>(body, ::nativeParse)
}.getOrElse {
logcat(it)
null
throw ParseException("Failed to parse gallery detail", it)
}
/**
* Parse tag groups with html parser
*/
private fun parseTagGroups(document: Document): List<GalleryTagGroup> = Either.catch {
val taglist = document.getElementById("taglist")!!
taglist.child(0).child(0).children().mapNotNull(::parseSingleTagGroup)
fun parseComments(body: ByteBuffer) = Either.catch {
unmarshalParsingAs<GalleryCommentList>(body, ::nativeParseComments)
}.getOrElse {
logcat(it)
emptyList()
throw ParseException("Failed to parse comments", it)
}
private suspend fun parseComment(element: Element): GalleryComment? = Either.catch {
// Id
val a = element.previousElementSibling()
val name = a!!.attr("name")
val id = name trimAnd { substring(1).toInt().toLong() }
// Editable, vote up and vote down
val c4 = element.getElementsByClass("c4").first()
var voteUpAble = false
var voteUpEd = false
var voteDownAble = false
var voteDownEd = false
var editable = false
var uploader = false
if (null != c4) {
if ("Uploader Comment" == c4.text()) {
uploader = true
}
for (e in c4.children()) {
when (e.text()) {
"Vote+" -> {
voteUpAble = true
voteUpEd = e.attr("style").trim().isNotEmpty()
}
"Vote-" -> {
voteDownAble = true
voteDownEd = e.attr("style").trim().isNotEmpty()
}
"Edit" -> editable = true
}
}
}
// Vote state
val c7 = element.getElementsByClass("c7").first()
val voteState = c7?.text()?.trim()
// Score
val c5 = element.getElementsByClass("c5").first()
val score = c5?.children()?.first()?.text()?.trim()?.toIntOrDefault(0) ?: 0
// time
val c3 = element.getElementsByClass("c3").first()
val temp = c3!!.ownText()
val (timeStr, user) = if (temp.endsWith(':')) {
temp.substring("Posted on ".length, temp.length - " by:".length) to c3.child(0).text()
} else {
temp.substring("Posted on ".length) to null
}
val time = WEB_COMMENT_DATE_FORMAT.parse(timeStr).toEpochMillis()
// comment
val c6 = element.getElementsByClass("c6").first()
// fix underline support
for (e in c6!!.children()) {
if ("span" == e.tagName() && "text-decoration:underline;" == e.attr("style")) {
e.tagName("u")
}
}
val commentHtml = c6.html()
// filter comment
if (!uploader) {
val sEhFilter = EhFilter
if (score <= Settings.commentThreshold || sEhFilter.filterCommenter(user.orEmpty()) || sEhFilter.filterComment(commentHtml)) {
return null
}
}
// last edited
val c8 = element.getElementsByClass("c8").first()
val lastEdited = c8?.children()?.first()?.run { WEB_COMMENT_DATE_FORMAT.parse(text()).toEpochMillis() } ?: 0
GalleryComment(
id, score, editable, voteUpAble, voteUpEd, voteDownAble,
voteDownEd, uploader, voteState, time, user, commentHtml, lastEdited,
)
fun parsePreviews(body: ByteBuffer) = Either.catch {
unmarshalParsingAs<GalleryPreviewList>(body, ::nativeParsePreviews)
}.getOrElse {
logcat(it)
null
throw ParseException("Failed to parse previews", it)
}
/**
* Parse comments with html parser
*/
suspend fun parseComments(document: Document): GalleryCommentList = Either.catch {
// Disable pretty print to get comments in raw html
document.outputSettings().prettyPrint(false)
val cdiv = document.getElementById("cdiv")!!
val c1s = cdiv.getElementsByClass("c1")
val list = c1s.mapNotNull { parseComment(it) }
val chd = cdiv.getElementById("chd")
var hasMore = false
NodeTraversor.traverse(
object : NodeVisitor {
override fun head(node: Node, depth: Int) {
if (node is Element && node.text() == "click to show all") {
hasMore = true
}
}
@Serializable
@CborArray
class Result(val detail: GalleryDetail, val event: String?)
override fun tail(node: Node, depth: Int) {}
},
chd!!,
)
GalleryCommentList(list, hasMore)
}.getOrElse {
logcat(it)
EMPTY_GALLERY_COMMENT_LIST
}
/**
* Parse preview pages with regular expressions
*/
fun parsePreviewPages(body: String): Int = PATTERN_PREVIEW_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
?: throw ParseException("Parse preview page count error")
/**
* Parse pages with regular expressions
*/
fun parsePages(body: String): Int = PATTERN_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
?: throw ParseException("Parse pages error")
fun parsePreviewList(body: String) = PreviewRegex.findAll(body).unzip {
val pageUrl = it.groupValues[1]
val position = it.groupValues[2].toInt() - 1
val url = it.groupValues[5]
val offset = it.groupValues[6]
if (offset.isEmpty()) {
val imageKey = getThumbKey(url)
V1GalleryPreview(imageKey, position)
} else {
val imageKey = getV2PreviewKey(url)
val width = it.groupValues[3].toInt()
val height = it.groupValues[4].toInt()
V2GalleryPreview(url, imageKey, position, offset.toInt(), width, height)
} to pageUrl
}.run {
first.toList().apply {
if (isEmpty()) throw ParseException("Parse preview list error")
} to second.toList()
}
private external fun nativeParse(body: ByteBuffer, size: Int = body.limit()): Int
private external fun nativeParseComments(body: ByteBuffer, size: Int = body.limit()): Int
private external fun nativeParsePreviews(body: ByteBuffer, size: Int = body.limit()): Int
}
@@ -1,33 +0,0 @@
/*
* Copyright 2022 Tarsin Norbin
*
* This file is part of EhViewer
*
* EhViewer is free software: you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* EhViewer is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with EhViewer.
* If not, see <https://www.gnu.org/licenses/>.
*/
package com.hippo.ehviewer.client.parser
import arrow.core.Either
import arrow.core.getOrElse
import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup
object GalleryNotAvailableParser {
fun parse(body: String): String? = Either.catch {
val e = Jsoup.parse(body).getElementsByClass("d").first()
e!!.child(0).html().replace("<br>", "\n")
}.getOrElse {
logcat(it)
null
}
}
@@ -36,7 +36,7 @@ object GalleryTokenApiParser {
fun parse(body: String): String = Either.catch {
body.parseAs<Result>().tokenList[0].token
}.getOrElse {
throw EhException(body.parseAs<Error>().error)
throw EhException(body.parseAs<VoteTagResult>().error)
}
@Serializable
@@ -1,11 +1,9 @@
package com.hippo.ehviewer.client.parser
import android.os.Parcelable
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlin.random.Random
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
object HomeParser {
@@ -27,23 +25,21 @@ object HomeParser {
?: throw ParseException("Parse torrent key error")
fun parseFunds(body: String): Funds {
FundsRegex.find(body)?.groupValues?.run {
val fundsC = ParserUtils.parseInt(get(1), 0)
val fundsGP = ParserUtils.parseInt(get(2), 0)
FundsRegex.find(body)?.run {
val fundsC = ParserUtils.parseInt(groupValues[1], 0)
val fundsGP = ParserUtils.parseInt(groupValues[2], 0)
return Funds(fundsGP, fundsC)
}
throw ParseException("Parse funds error")
}
@Parcelize
data class Result(val limits: Limits, val funds: Funds, private val id: Int = Random.nextInt()) : Parcelable
data class Result(val limits: Limits, val funds: Funds, private val id: Int = Random.nextInt())
}
@Parcelize
data class Funds(val gp: Int, val credit: Int) : Parcelable
@Parcelize
@Serializable
data class Limits(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
data class Funds(val gp: Int, val credit: Int)
@Serializable
data class Limits(val current: Int, val maximum: Int, val resetCost: Int)
private external fun parseLimit(body: ByteBuffer, limit: Int = body.limit()): Int
@@ -18,8 +18,6 @@ package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.util.toEpochMillis
import com.hippo.ehviewer.util.toIntOrDefault
import com.hippo.ehviewer.util.toLocalDateTime
import com.hippo.ehviewer.util.toLongOrDefault
import com.hippo.ehviewer.util.unescapeXml
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format.char
@@ -30,7 +28,7 @@ object ParserUtils {
char('-')
monthNumber()
char('-')
dayOfMonth()
day()
char(' ')
hour()
char(':')
@@ -41,9 +39,5 @@ object ParserUtils {
fun parseDate(str: String): Long = formatter.parse(str).toEpochMillis()
fun trim(str: String?): String = str?.unescapeXml()?.trim() ?: ""
fun parseInt(str: String?, defValue: Int): Int = trim(str).replace(",", "").toIntOrDefault(defValue)
fun parseLong(str: String?, defValue: Long): Long = trim(str).replace(",", "").toLongOrDefault(defValue)
fun parseInt(str: String, defValue: Int): Int = str.replace(",", "").toIntOrDefault(defValue)
}
@@ -15,36 +15,28 @@
*/
package com.hippo.ehviewer.client.parser
import arrow.core.Either
import arrow.core.Either.Companion.catch
import arrow.core.getOrElse
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.exception.ParseException
import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
object ProfileParser {
fun parse(body: String): Result = Either.catch {
val d = Jsoup.parse(body)
val profilename = d.getElementById("profilename")
val displayName = profilename!!.child(0).text()
val avatar = Either.catch {
val avatar =
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
if (avatar.isEmpty()) {
null
} else if (!avatar.startsWith("http")) {
EhUrl.URL_FORUMS + avatar
} else {
avatar
}
}.getOrElse {
logcat { "No avatar" }
null
}
Result(displayName, avatar)
fun parse(body: ByteBuffer) = catch {
unmarshalParsingAs<Result>(body, ::parseProfile)
}.getOrElse {
throw ParseException("Parse forums error")
throw ParseException("Failed to parse profile", it)
}
class Result(val displayName: String?, val avatar: String?)
fun parseProfileUrl(body: ByteBuffer) = catch {
unmarshalParsingAs<String>(body, ::parseProfileUrl)
}.getOrElse {
throw ParseException("Failed to parse profile url", it)
}
@Serializable
data class Result(val displayName: String, val avatar: String?)
private external fun parseProfile(body: ByteBuffer, size: Int = body.limit()): Int
private external fun parseProfileUrl(body: ByteBuffer, size: Int = body.limit()): Int
}
@@ -17,9 +17,18 @@
*/
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.parseAs
import arrow.core.Either.Companion.catch
import arrow.core.getOrElse
import com.hippo.ehviewer.client.data.GalleryTagGroup
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
object VoteTagParser {
// {"error":"The tag \"neko\" is not allowed. Use character:neko or artist:neko"}
fun parse(body: String): String? = runCatching { body.parseAs<Error>().error }.getOrNull()
fun parse(body: ByteBuffer) = catch {
unmarshalParsingAs<List<GalleryTagGroup>>(body, ::nativeParse)
}.getOrElse {
throw ParseException("Failed to parse vote tag result", it)
}
private external fun nativeParse(body: ByteBuffer, size: Int = body.limit()): Int
}
@@ -3,4 +3,7 @@ package com.hippo.ehviewer.client.parser
import kotlinx.serialization.Serializable
@Serializable
data class Error(val error: String)
data class VoteTagResult(
val error: String?,
val tagpane: String?,
)
@@ -0,0 +1,49 @@
package com.hippo.ehviewer.coil
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.ByteBufferMetadata
import coil3.decode.ContentMetadata
import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.gif.isAnimatedWebP
import coil3.request.Options
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
import okio.FileSystem
class AnimatedWebPDecoder(private val source: ByteBuffer) : Decoder {
override suspend fun decode() = DecodeResult(AnimatedWebPDrawable(source).asImage(), false)
object Factory : Decoder.Factory {
override fun create(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader,
) = if (DecodeUtils.isAnimatedWebP(result.source.source())) {
result.source.toByteBufferOrNull()?.let { AnimatedWebPDecoder(it) }
} else {
null
}
}
}
private fun ImageSource.toByteBufferOrNull(): ByteBuffer? {
if (fileSystem === FileSystem.SYSTEM) {
val file = fileOrNull()
if (file != null) {
return file.toFile().inputStream().mapReadOnly()
}
}
return when (val metadata = metadata) {
is ContentMetadata -> metadata.assetFileDescriptor.createInputStream().mapReadOnly()
is ByteBufferMetadata -> metadata.byteBuffer
else -> null
}
}
private fun FileInputStream.mapReadOnly(): ByteBuffer = channel.use { it.map(FileChannel.MapMode.READ_ONLY, 0, it.size()) }
@@ -0,0 +1,151 @@
package com.hippo.ehviewer.coil
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.os.SystemClock
import androidx.core.graphics.createBitmap
import eu.kanade.tachiyomi.util.system.logcat
import java.nio.ByteBuffer
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
// Hold a reference to the buffer as it's used by the decoder
@Suppress("CanBeParameter")
class AnimatedWebPDrawable(private val source: ByteBuffer) : Drawable(), Animatable {
private val decodeScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))
private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.DITHER_FLAG or Paint.FILTER_BITMAP_FLAG)
private val decoder = nativeCreateDecoder(source)
private val width: Int
private val height: Int
private val loopCount: Int
private var decodeJob: Job? = null
private var loopsCompleted = 0
private var timeToShowNextFrame = 0L
private var currentFrame: Frame
private var nextFrame: Frame
init {
check(decoder != 0L) { "Failed to create decoder" }
val packed = nativeGetImageInfo(decoder)
width = (packed shr 40).toInt()
height = (packed shr 16 and 0xFFFFFF).toInt()
loopCount = (packed and 0xFFFF).toInt()
val bitmap = createBitmap(width, height)
val timestamp = nativeDecodeNextFrame(decoder, false, bitmap)
check(timestamp != 0) {
nativeDestroyDecoder(decoder)
"Failed to decode first frame"
}
currentFrame = Frame(bitmap, timestamp)
nextFrame = Frame(createBitmap(width, height), 0)
}
override fun getIntrinsicWidth() = width
override fun getIntrinsicHeight() = height
private val runnable = Runnable {
timeToShowNextFrame = SystemClock.uptimeMillis() + nextFrame.timestamp - currentFrame.timestamp
invalidateSelf()
}
private fun decodeNextFrame(reset: Boolean) = decodeScope.launch {
val timestamp = nativeDecodeNextFrame(decoder, reset, nextFrame.bitmap)
ensureActive()
check(timestamp != 0) {
decodeJob = null
"Failed to decode next frame"
}
if (timestamp <= currentFrame.timestamp) {
if (reset) loopsCompleted = 0 else loopsCompleted++
currentFrame.timestamp = 0
}
nextFrame.timestamp = timestamp
nextFrame.bitmap.prepareToDraw()
}.apply {
invokeOnCompletion { cause ->
when (cause) {
null -> if (reset) {
runnable.run()
} else {
scheduleSelf(runnable, timeToShowNextFrame)
}
!is CancellationException -> logcat(cause)
}
}
}
override fun draw(canvas: Canvas) {
if (decodeJob?.isCompleted == true && isVisible) {
decodeJob = if (loopCount == 0 || loopsCompleted < loopCount) {
currentFrame = nextFrame.also { nextFrame = currentFrame }
decodeNextFrame(false)
} else {
null
}
}
canvas.drawBitmap(currentFrame.bitmap, null, bounds, paint)
}
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
override fun setAlpha(alpha: Int) {
if (alpha != paint.alpha) {
paint.alpha = alpha
invalidateSelf()
}
}
override fun getAlpha(): Int = paint.alpha
override fun setColorFilter(colorFilter: ColorFilter?) {
if (colorFilter != paint.colorFilter) {
paint.colorFilter = colorFilter
invalidateSelf()
}
}
override fun getColorFilter(): ColorFilter? = paint.colorFilter
override fun isRunning() = decodeJob != null
override fun start() {
if (decodeJob == null) {
decodeJob = decodeNextFrame(true)
}
}
override fun stop() {
decodeJob?.cancel()
decodeJob = null
unscheduleSelf(runnable)
}
fun dispose() {
runBlocking {
decodeScope.coroutineContext.job.cancelAndJoin()
}
nativeDestroyDecoder(decoder)
}
}
private class Frame(val bitmap: Bitmap, var timestamp: Int)
private external fun nativeCreateDecoder(source: ByteBuffer): Long
private external fun nativeGetImageInfo(decoder: Long): Long
private external fun nativeDecodeNextFrame(decoder: Long, reset: Boolean, bitmap: Bitmap): Int
private external fun nativeDestroyDecoder(decoder: Long)
@@ -17,9 +17,6 @@ import com.hippo.ehviewer.util.isAtLeastQ
import eu.kanade.tachiyomi.util.system.logcat
import moe.tarsin.coroutines.runSuspendCatching
@RequiresApi(Build.VERSION_CODES.O)
private const val FORMAT = HardwareBuffer.RGBA_8888
@RequiresApi(Build.VERSION_CODES.O)
private const val USAGE = HardwareBuffer.USAGE_CPU_WRITE_RARELY or HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE
@@ -42,8 +39,14 @@ object CropBorderInterceptor : Interceptor {
val meetHardwareThreshold = maxOf(w, h) <= chain.request.hardwareThreshold
val bitmap = when {
isAtLeastQ && meetHardwareThreshold -> runSuspendCatching {
val format = when (val config = src.config) {
Bitmap.Config.ARGB_8888 -> HardwareBuffer.RGBA_8888
Bitmap.Config.RGB_565 -> HardwareBuffer.RGB_565
Bitmap.Config.RGBA_F16 -> HardwareBuffer.RGBA_FP16
else -> error("Unsupported bitmap config: $config")
}
resourceScope {
val buffer = autoCloseable { HardwareBuffer.create(w, h, FORMAT, 1, USAGE) }
val buffer = autoCloseable { HardwareBuffer.create(w, h, format, 1, USAGE) }
copyBitmapToAHB(src, buffer, x, y)
Bitmap.wrapHardwareBuffer(buffer, src.colorSpace)
}
@@ -7,41 +7,56 @@ import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.EhApplication.Companion.imageCache
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.client.getThumbKey
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.util.sendTo
import com.hippo.files.delete
import com.hippo.files.isDirectory
import com.hippo.files.isFile
import com.hippo.files.toUri
import okio.Path
import okio.Path.Companion.toPath
private val emptyPath = "".toPath()
private val downloadLocationKey = Extras.Key(default = emptyPath)
private val downloadInfoKey = Extras.Key<DownloadInfo?>(default = null)
fun ImageRequest.Builder.downloadLocation(path: Path) = apply {
extras[downloadLocationKey] = path
fun ImageRequest.Builder.downloadInfo(info: DownloadInfo) = apply {
extras[downloadInfoKey] = info
}
val ImageRequest.downloadLocation: Path
get() = getExtra(downloadLocationKey)
val ImageRequest.downloadInfo: DownloadInfo?
get() = getExtra(downloadInfoKey)
object DownloadThumbInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val thumb = chain.request.downloadLocation
if (thumb != emptyPath) {
val info = chain.request.downloadInfo
if (info != null && !info.dirname.isNullOrBlank()) {
val thumbKey = getThumbKey(chain.request.data as String)
if (info.thumbKey != thumbKey) {
info.thumbKey = thumbKey
EhDB.putGalleryInfo(info.galleryInfo)
}
val dir = downloadLocation / info.dirname
val format = thumbKey.substringAfterLast('.', "")
check(format.isNotBlank())
val thumb = dir / "thumb.$format"
val v1Thumb = dir / "thumb.jpg"
if (thumb.isFile) {
val new = chain.request.newBuilder().data(thumb.toUri()).build()
val result = chain.withRequest(new).proceed()
if (result is SuccessResult) return result
if (result is SuccessResult) {
if (thumb != v1Thumb) v1Thumb.delete()
return result
}
}
val new = chain.request.newBuilder().build()
val result = chain.withRequest(new).proceed()
if (result is SuccessResult && thumb.parent?.isDirectory == true) {
val result = chain.proceed()
if (result is SuccessResult && dir.isDirectory) {
// Accessing the recreated file immediately after deleting it throws
// FileNotFoundException, so we just overwrite the existing file.
val key = requireNotNull(chain.request.memoryCacheKey)
imageCache.read(key) {
data sendTo thumb
}
if (thumb != v1Thumb) v1Thumb.delete()
}
return result
}
@@ -1,36 +1,58 @@
package com.hippo.ehviewer.coil
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.paging.compose.LazyPagingItems
import coil3.decode.BlackholeDecoder
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.size.Size
import coil3.size.SizeResolver
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.V2GalleryPreview
import com.hippo.ehviewer.client.thumbUrl
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.ktbuilder.execute
// Load in original size so the memory cache can be reused for preload requests
fun ImageRequest.Builder.ehUrl(info: GalleryInfo) = apply {
val key = info.thumbKey!!
data(info.thumbUrl)
val format = info.thumbUrl.substringAfterLast('.', "")
check(format.isNotBlank())
if (info is DownloadInfo && !info.dirname.isNullOrBlank()) {
downloadLocation(downloadLocation / info.dirname / "thumb.$format")
size(SizeResolver.ORIGINAL)
val downloadInfo = (info as? DownloadInfo) ?: DownloadManager.getDownloadInfo(info.gid)
if (downloadInfo != null) {
downloadInfo(downloadInfo)
}
memoryCacheKey(key)
diskCacheKey(key)
}
fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = with(preview) {
data(url)
if (preview is V2GalleryPreview) size(Size.ORIGINAL)
memoryCacheKey(imageKey)
diskCacheKey(imageKey)
// Load in original size so the memory cache can be reused for preload requests
fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
with(preview) {
val key = imageKey
data(url)
size(SizeResolver.ORIGINAL)
memoryCacheKey(key)
diskCacheKey(key)
}
}
fun ImageRequest.Builder.justDownload() = apply {
memoryCachePolicy(CachePolicy.DISABLED)
decoderFactory(BlackholeDecoder.Factory())
}
@Composable
inline fun <T : Any> PrefetchAround(data: LazyPagingItems<T>, index: Int, distance: Int, crossinline f: (T) -> ImageRequest) {
data.peek((index - distance).coerceAtLeast(0))?.let { fetchBefore ->
LaunchedEffect(fetchBefore) {
f(fetchBefore).execute()
}
}
data.peek((index + distance).coerceAtMost(data.itemCount - 1))?.let { fetchAhead ->
LaunchedEffect(fetchAhead) {
f(fetchAhead).execute()
}
}
}
@@ -1,30 +0,0 @@
package com.hippo.ehviewer.coil
import coil3.network.NetworkClient
import coil3.network.NetworkRequest
import coil3.network.NetworkResponse
import coil3.network.ktor3.asNetworkClient
import com.hippo.ehviewer.client.URL_PREFIX_THUMB_EX
import com.hippo.ehviewer.client.URL_SIGNATURE_THUMB_NORMAL
import io.ktor.client.HttpClient
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import moe.tarsin.coroutines.NamedSemaphore
import moe.tarsin.coroutines.withLock
class LimitConcurrencyNetworkClient(val impl: NetworkClient) : NetworkClient {
val semaphores = NamedSemaphore<String>(permits = 8)
override suspend fun <T> executeRequest(req: NetworkRequest, f: suspend (NetworkResponse) -> T): T {
val url = req.url
return when {
// Ex thumb server may not have h2 multiplexing support
URL_PREFIX_THUMB_EX in url -> semaphores.withLock(URL_PREFIX_THUMB_EX) { withContext(NonCancellable) { impl.executeRequest(req, f) } }
// H@H server may not have h2 multiplexing support
URL_SIGNATURE_THUMB_NORMAL in url -> semaphores.withLock(url.substringBefore(URL_SIGNATURE_THUMB_NORMAL)) { withContext(NonCancellable) { impl.executeRequest(req, f) } }
// H2 multiplexing enabled
else -> impl.executeRequest(req, f)
}
}
}
fun HttpClient.limitConcurrency() = LimitConcurrencyNetworkClient(asNetworkClient())
@@ -1,24 +1,63 @@
package com.hippo.ehviewer.coil
import arrow.core.merge
import arrow.fx.coroutines.raceN
import coil3.decode.DataSource
import coil3.imageLoader
import coil3.intercept.Interceptor
import coil3.memory.MemoryCache
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.isV2PreviewKey
import moe.tarsin.coroutines.NamedMutex
import moe.tarsin.coroutines.withLockNeedSuspend
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import moe.tarsin.coroutines.Counter
import moe.tarsin.coroutines.Tracker
import moe.tarsin.coroutines.counter
import moe.tarsin.coroutines.runSuspendCatching
import moe.tarsin.coroutines.use
typealias F = suspend () -> Unit
suspend inline fun <T> CancellableContinuation<T>.evalAndResume(f: suspend () -> T) = runSuspendCatching { f() }.takeIf { isActive }?.let(::resumeWith)
class TightRope : CoroutineScope, Counter by counter() {
override val coroutineContext = Dispatchers.IO + Job()
val actions = Channel<F>()
val flow = actions.receiveAsFlow().map(F::invoke).buffer(0).shareIn(this, SharingStarted.WhileSubscribed(stopTimeoutMillis = 200))
suspend inline fun <R> sendAndAwait(crossinline block: suspend () -> R) = raceN(
{ flow.collect {} },
{ suspendCancellableCoroutine { cont -> launch { actions.send { cont.evalAndResume(block) } } } },
).merge()
}
object TightRopeTracker : Tracker<TightRope, String>() {
override fun new() = TightRope()
override fun free(e: TightRope) = e.cancel()
}
object MergeInterceptor : Interceptor {
private val mutex = NamedMutex<String>()
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val req = chain.request
val key = req.memoryCacheKey?.takeIf { it.isV2PreviewKey || Settings.preloadThumbAggressively }
val key = req.memoryCacheKey
return if (key != null) {
val (result, suspended) = mutex.withLockNeedSuspend(key) { chain.proceed() }
val cacheKey = MemoryCache.Key(key, req.memoryCacheKeyExtras)
val cached = req.context.imageLoader.memoryCache!![cacheKey] != null
val result = TightRopeTracker.use(key) { sendAndAwait { chain.proceed() } }
when (result) {
is SuccessResult if (suspended) -> result.copy(dataSource = DataSource.MEMORY)
is SuccessResult if !cached && result.dataSource == DataSource.MEMORY_CACHE -> {
result.copy(dataSource = DataSource.MEMORY)
}
else -> result
}
} else {
@@ -3,12 +3,12 @@ package com.hippo.ehviewer.dailycheck
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.text.style.URLSpan
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.net.toUri
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import androidx.work.Constraints
@@ -26,8 +26,8 @@ import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.system.logcat
import java.time.Duration
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
@@ -66,7 +66,7 @@ private fun getDailyCheckWorkRequest(): PeriodicWorkRequest {
private const val WORK_NAME = "DailyCheckWork"
fun updateDailyCheckWork(context: Context) {
if (Settings.requestNews) {
if (Settings.requestNews.value) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
@@ -93,7 +93,7 @@ suspend fun checkDawn() = runCatching {
@SuppressLint("MissingPermission")
fun showEventNotification(html: String) {
if (Settings.hideHvEvents && html.contains("You have encountered a monster!")) {
if (Settings.hideHvEvents.value && html.contains("You have encountered a monster!")) {
return
}
val notificationManager = NotificationManagerCompat.from(appCtx)
@@ -110,7 +110,7 @@ fun showEventNotification(html: String) {
.setStyle(NotificationCompat.BigTextStyle())
val urls = text.getSpans<URLSpan>(0, text.length)
if (urls.isNotEmpty()) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(urls.first().url))
val intent = Intent(Intent.ACTION_VIEW, urls.first().url.toUri())
val pi = PendingIntentCompat.getActivity(appCtx, 0, intent, 0, false)
msg.setContentIntent(pi)
}
@@ -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 kotlinx.datetime.Clock
import kotlin.time.Clock
@Entity(
tableName = "DOWNLOADS",
@@ -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 kotlinx.datetime.Clock
import kotlin.time.Clock
@Entity(tableName = "HISTORY", foreignKeys = [ForeignKey(BaseGalleryInfo::class, ["GID"], ["GID"])])
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 kotlinx.datetime.Clock
import kotlin.time.Clock
@Entity(tableName = "LOCAL_FAVORITES", foreignKeys = [ForeignKey(BaseGalleryInfo::class, ["GID"], ["GID"])])
class LocalFavoriteInfo(gid: Long = 0, time: Long = Clock.System.now().toEpochMilliseconds()) : TimeInfo(gid, time)
@@ -22,9 +22,9 @@ import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.util.lerp
import androidx.core.util.size
import arrow.fx.coroutines.parMapNotNull
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.Settings
@@ -34,6 +34,7 @@ import com.hippo.ehviewer.dao.DownloadArtist
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.DownloadLabel
import com.hippo.ehviewer.spider.COMIC_INFO_FILE
import com.hippo.ehviewer.spider.SpeedTracker
import com.hippo.ehviewer.spider.SpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
@@ -53,7 +54,6 @@ import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.transform
@@ -81,7 +81,10 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
private val mAllInfoMap = allInfoList.associateBy { it.gid } as MutableMap<Long, DownloadInfo>
// All labels without default label
val labelList = runAssertingNotMainThread { EhDB.getAllDownloadLabelList() }.toMutableStateList()
// Create the SnapshotStateList first in case the database query is time-consuming
val labelList = mutableStateListOf<DownloadLabel>().apply {
addAll(runAssertingNotMainThread { EhDB.getAllDownloadLabelList() })
}
// Store download info wait to start
private val mWaitList = ArrayDeque<DownloadInfo>()
@@ -308,16 +311,6 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
}
suspend fun stopCurrentDownload() {
val info = stopCurrentDownloadInternal()
if (info != null) {
// Update listener
mutableNotifyFlow.emit(info)
// Ensure download
ensureDownload()
}
}
suspend fun stopRangeDownload(gidList: LongArray) {
stopRangeDownloadInternal(gidList)
@@ -710,28 +703,26 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
private val mSpeedReminder = object {
private val mContentLengthMap = SparseLongArray()
private val mReceivedSizeMap = SparseLongArray()
private val contentLengthMap = SparseLongArray()
private val receivedSizeMap = SparseLongArray()
private val tracker = SpeedTracker()
private val mutex = Mutex()
private var mBytesRead = 0L
private var oldSpeed = -1L
private var currentJob: Job? = null
fun start() {
if (currentJob == null) {
currentJob = launch {
while (true) {
runInternal()
tracker.speedFlow().collect { speed ->
updateSpeed(speed.toLong())
}
}
}
}
suspend fun stop() {
mBytesRead = 0
oldSpeed = -1
tracker.reset()
mutex.withLock {
mContentLengthMap.clear()
mReceivedSizeMap.clear()
contentLengthMap.clear()
receivedSizeMap.clear()
}
currentJob?.cancel()
currentJob = null
@@ -739,48 +730,43 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
suspend fun onDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) {
mutex.withLock {
mContentLengthMap.put(index, contentLength)
mReceivedSizeMap.put(index, receivedSize)
contentLengthMap.put(index, contentLength)
receivedSizeMap.put(index, receivedSize)
}
mBytesRead += bytesRead.toLong()
tracker.track(bytesRead)
}
suspend fun onDone(index: Int) {
mutex.withLock {
mContentLengthMap.delete(index)
mReceivedSizeMap.delete(index)
contentLengthMap.delete(index)
receivedSizeMap.delete(index)
}
}
suspend fun onFinish() {
mutex.withLock {
mContentLengthMap.clear()
mReceivedSizeMap.clear()
contentLengthMap.clear()
receivedSizeMap.clear()
}
}
private suspend fun runInternal() {
private suspend fun updateSpeed(speed: Long) {
mCurrentTask?.let { info ->
var newSpeed = mBytesRead / 2
if (oldSpeed != -1L) {
newSpeed = lerp(oldSpeed, newSpeed, 0.75f)
}
oldSpeed = newSpeed
info.speed = newSpeed
info.speed = speed
// Calculate remaining
if (info.total <= 0) {
info.remaining = -1
} else if (newSpeed == 0L) {
} else if (speed == 0L) {
info.remaining = 300L * 24L * 60L * 60L * 1000L // 300 days
} else {
var downloadingCount = 0
var downloadingContentLengthSum = 0L
var totalSize = 0L
mutex.withLock {
for (i in 0..<mContentLengthMap.size()) {
val contentLength = mContentLengthMap.valueAt(i)
val receivedSize = mReceivedSizeMap.valueAt(i)
for (i in 0..<contentLengthMap.size) {
val contentLength = contentLengthMap.valueAt(i)
val receivedSize = receivedSizeMap.valueAt(i)
downloadingCount++
downloadingContentLengthSum += contentLength
totalSize += contentLength - receivedSize
@@ -788,14 +774,12 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
if (downloadingCount != 0) {
totalSize += downloadingContentLengthSum * (info.total - info.downloaded - downloadingCount) / downloadingCount
info.remaining = totalSize / newSpeed * 1000
info.remaining = totalSize / speed * 1000
}
}
mDownloadListener?.onDownload(info)
mutableNotifyFlow.emit(info)
}
mBytesRead = 0
delay(2000)
}
}
@@ -830,4 +814,4 @@ var downloadLocation: Path
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation / it }
val DownloadInfo.archiveFile get() = downloadDir?.run { find("$gid.cbz") ?: find("$gid.zip") }
val GalleryInfo.tempDownloadDir get() = AppConfig.getTempDir("$gid")?.toOkioPath()
val GalleryInfo.tempDownloadDir get() = AppConfig.externalTempPersistDir?.let { it / "$gid" }
@@ -16,8 +16,10 @@
package com.hippo.ehviewer.download
import android.Manifest
import android.app.ForegroundServiceStartNotAllowedException
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
@@ -26,7 +28,9 @@ import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.savedstate.serialization.decodeFromSavedState
import androidx.savedstate.serialization.encodeToSavedState
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@@ -34,7 +38,7 @@ import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.isAtLeastS
import com.hippo.ehviewer.util.unsafeLazy
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope
@@ -65,12 +69,18 @@ class DownloadService :
NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.download_service)).build(),
)
downloadingNotification.builder.run {
downloadingNotification.builder.runCatching {
setContentTitle(getString(R.string.download_service))
.setContentText(null)
.setSubText(null)
.setProgress(0, 0, true)
startForeground(ID_DOWNLOADING, build())
}.onFailure {
if (isAtLeastS && it is ForegroundServiceStartNotAllowedException) {
logcat(it)
} else {
throw it
}
}
launch {
deferredMgr.await().setDownloadListener(this@DownloadService)
@@ -95,11 +105,9 @@ class DownloadService :
private suspend fun handleIntent(intent: Intent?) {
when (intent?.action) {
ACTION_START -> {
val gi = intent.getParcelableExtraCompat<BaseGalleryInfo>(KEY_GALLERY_INFO)
val gi = intent.getBundleExtra(KEY_GALLERY_INFO) ?: return
val label = intent.getStringExtra(KEY_LABEL)
if (gi != null) {
deferredMgr.await().startDownload(gi, label)
}
deferredMgr.await().startDownload(decodeFromSavedState(gi), label)
}
ACTION_START_RANGE -> {
@@ -109,47 +117,11 @@ class DownloadService :
}
}
ACTION_START_ALL -> {
deferredMgr.await().startAllDownload()
}
ACTION_STOP -> {
val gid = intent.getLongExtra(KEY_GID, -1)
if (gid != -1L) {
deferredMgr.await().stopDownload(gid)
}
}
ACTION_STOP_CURRENT -> deferredMgr.await().stopCurrentDownload()
ACTION_STOP_RANGE -> {
val gidList = intent.getLongArrayExtra(KEY_GID_LIST)
if (gidList != null) {
deferredMgr.await().stopRangeDownload(gidList)
}
}
ACTION_START_ALL -> deferredMgr.await().startAllDownload()
ACTION_STOP_ALL -> deferredMgr.await().stopAllDownload()
ACTION_DELETE -> {
val gid = intent.getLongExtra(KEY_GID, -1)
if (gid != -1L) {
deferredMgr.await().deleteDownload(gid)
}
}
ACTION_DELETE_RANGE -> {
val gidList = intent.getLongArrayExtra(KEY_GID_LIST)
if (gidList != null) {
deferredMgr.await().deleteRangeDownload(gidList)
}
}
ACTION_CLEAR -> {
clear()
checkStopSelf()
}
ACTION_CLEAR -> clear()
}
checkStopSelf()
}
override fun onBind(intent: Intent) = null
@@ -246,7 +218,7 @@ class DownloadService :
override fun onDownload(info: DownloadInfo) {
val speed = info.speed.coerceAtLeast(0)
val speedText = FileUtils.humanReadableByteCount(speed, false) + "/s"
val speedText = FileUtils.humanReadableByteCount(speed) + "/s"
val remaining = info.remaining
val text = if (remaining >= 0) {
val interval = ReadableTime.getShortTimeInterval(remaining)
@@ -372,7 +344,6 @@ class DownloadService :
private fun checkStopSelf() {
launch {
if (deferredMgr.await().isIdle) {
ServiceCompat.stopForeground(this@DownloadService, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
@@ -431,12 +402,7 @@ class DownloadService :
const val ACTION_START = "start"
const val ACTION_START_RANGE = "start_range"
const val ACTION_START_ALL = "start_all"
const val ACTION_STOP = "stop"
const val ACTION_STOP_RANGE = "stop_range"
const val ACTION_STOP_CURRENT = "stop_current"
const val ACTION_STOP_ALL = "stop_all"
const val ACTION_DELETE = "delete"
const val ACTION_DELETE_RANGE = "delete_range"
const val ACTION_CLEAR = "clear"
const val KEY_GALLERY_INFO = "gallery_info"
const val KEY_LABEL = "label"
@@ -454,6 +420,23 @@ class DownloadService :
private var sFinishedCount = 0
private var sDownloadedCount = 0
context(ctx: Context)
inline fun startService(action: String, intentBuilder: Intent.() -> Unit = {}) {
val intent = Intent(ctx, DownloadService::class.java).setAction(action).apply(intentBuilder)
ContextCompat.startForegroundService(ctx, intent)
}
context(_: Context)
fun startDownload(info: BaseGalleryInfo, label: String? = null) = startService(ACTION_START) {
putExtra(KEY_GALLERY_INFO, encodeToSavedState(info))
label?.let { putExtra(KEY_LABEL, it) }
}
context(_: Context)
fun startRangeDownload(gidList: LongArray) = startService(ACTION_START_RANGE) {
putExtra(KEY_GID_LIST, gidList)
}
fun clear() {
sFailedCount = 0
sFinishedCount = 0
@@ -1,5 +1,5 @@
/*
* Copyright 2023 Tarsin Norbin
* Copyright 2023-2024 Tarsin Norbin
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,9 +15,7 @@
*/
package com.hippo.ehviewer.gallery
import arrow.fx.coroutines.autoCloseable
import arrow.fx.coroutines.closeable
import arrow.fx.coroutines.resourceScope
import arrow.autoCloseScope
import com.hippo.ehviewer.Settings.archivePasswds
import com.hippo.ehviewer.image.ImageSource
import com.hippo.ehviewer.image.byteBufferSource
@@ -29,39 +27,37 @@ import com.hippo.ehviewer.jni.needPassword
import com.hippo.ehviewer.jni.openArchive
import com.hippo.ehviewer.jni.providePassword
import com.hippo.ehviewer.jni.releaseByteBuffer
import com.hippo.ehviewer.ui.screen.implicit
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.displayName
import com.hippo.files.openFileDescriptor
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import moe.tarsin.kt.install
import okio.Path
typealias PasswdInvalidator = (String) -> Boolean
typealias PasswdProvider = suspend (PasswdInvalidator) -> String
val emptyPasswdProvider: PasswdProvider = { error("Managed Archive have password???") }
suspend fun <T> useArchivePageLoader(
suspend inline fun <T> useArchivePageLoader(
file: Path,
gid: Long = 0,
startPage: Int = 0,
hasAds: Boolean = false,
passwdProvider: PasswdProvider = emptyPasswdProvider,
block: suspend (PageLoader) -> T,
) = coroutineScope {
resourceScope {
val pfd = closeable { file.openFileDescriptor("r") }
crossinline passwdProvider: PasswdProvider,
crossinline block: suspend (PageLoader) -> T,
) = autoCloseScope {
coroutineScope {
val pfd = install(file.openFileDescriptor("r"))
val size = install(
{ openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip")) },
{ _, _ -> closeArchive() },
)
check(size >= 0) { "Archive have no content!" }
if (needPassword() && archivePasswds.filterNotNull().none { providePassword(it) }) {
archivePasswds += passwdProvider { providePassword(it) }
check(size > 0) { "Archive have no content!" }
if (needPassword() && archivePasswds.filterNotNull().none(::providePassword)) {
archivePasswds += passwdProvider(::providePassword)
}
val loader = autoCloseable {
object : PageLoader(gid, startPage, size, hasAds, implicit<CoroutineScope>()) {
val loader = install(
object : PageLoader(this, gid, startPage, size, hasAds) {
override val title by lazy { FileUtils.getNameFromFilename(file.displayName)!! }
override fun getImageExtension(index: Int) = getExtension(index)
@@ -82,12 +78,11 @@ suspend fun <T> useArchivePageLoader(
return byteBufferSource(buffer) { releaseByteBuffer(buffer) }
}
override fun prefetchPages(pages: List<Int>, bounds: IntRange) = pages.forEach(::notifySourceReady)
override fun prefetchPages(pages: List<Int>, bounds: IntRange) = Unit
override fun onRequest(index: Int, force: Boolean, orgImg: Boolean) = notifySourceReady(index)
}
}
loader.progressJob.join()
},
)
block(loader)
}
}
@@ -1,32 +1,30 @@
package com.hippo.ehviewer.gallery
import arrow.fx.coroutines.autoCloseable
import arrow.fx.coroutines.resourceScope
import arrow.autoCloseScope
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.spider.SpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.obtainSpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.releaseSpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
import com.hippo.ehviewer.ui.screen.implicit
import com.hippo.ehviewer.util.hasAds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import moe.tarsin.kt.install
import okio.Path
suspend fun <T> useEhPageLoader(
suspend inline fun <T> useEhPageLoader(
info: GalleryInfo,
startPage: Int,
block: suspend (PageLoader) -> T,
) = coroutineScope {
resourceScope {
crossinline block: suspend (PageLoader) -> T,
) = autoCloseScope {
coroutineScope {
val queen = install(
{ obtainSpiderQueen(info, SpiderQueen.MODE_READ) },
{ queen, _ -> releaseSpiderQueen(queen, SpiderQueen.MODE_READ) },
)
queen.awaitReady()
val loader = autoCloseable {
object : PageLoader(info.gid, startPage, queen.size, info.hasAds, implicit<CoroutineScope>()) {
val loader = install(
object : PageLoader(this, info.gid, startPage, queen.size, info.hasAds) {
override val title by lazy { EhUtils.getSuitableTitle(info) }
override fun getImageExtension(index: Int) = queen.getExtension(index)
@@ -38,8 +36,8 @@ suspend fun <T> useEhPageLoader(
override fun prefetchPages(pages: List<Int>, bounds: IntRange) = queen.preloadPages(pages, bounds)
override fun onRequest(index: Int, force: Boolean, orgImg: Boolean) = queen.request(index, force, orgImg)
}
}.apply {
},
).apply {
val listener = object : OnSpiderListener {
override fun onPageDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) {
if (contentLength > 0) {
@@ -47,7 +45,7 @@ suspend fun <T> useEhPageLoader(
}
}
override fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int) = notifySourceReady(index)
override fun onPageReady(index: Int) = notifySourceReady(index)
override fun onPageFailure(index: Int, error: String?, finished: Int, downloaded: Int, total: Int) = notifyPageFailed(index, error)
}
@@ -56,7 +54,6 @@ suspend fun <T> useEhPageLoader(
{ _, _ -> queen.removeOnSpiderListener(listener) },
)
}
loader.progressJob.join()
block(loader)
}
}
@@ -1,7 +1,7 @@
package com.hippo.ehviewer.gallery
import androidx.collection.SieveCache
import arrow.atomic.value
import androidx.collection.mutableIntObjectMapOf
import arrow.fx.coroutines.ExitCase
import arrow.fx.coroutines.bracketCase
import com.hippo.ehviewer.EhDB
@@ -13,12 +13,15 @@ import com.hippo.ehviewer.util.OSUtils
import com.hippo.ehviewer.util.detectAds
import com.hippo.ehviewer.util.displayString
import com.hippo.ehviewer.util.isAtLeastO
import java.util.concurrent.atomic.AtomicInteger
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.read
import kotlin.concurrent.write
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -30,60 +33,49 @@ import okio.Path
private val progressScope = CoroutineScope(Dispatchers.IO)
private const val MAX_CACHE_SIZE = 512 * 1024 * 1024
private const val MIN_CACHE_SIZE = 128 * 1024 * 1024
private const val MIN_CACHE_SIZE = 256 * 1024 * 1024
abstract class PageLoader(val gid: Long, var startPage: Int, val size: Int, val hasAds: Boolean = false, val scope: CoroutineScope) : AutoCloseable {
abstract class PageLoader(val scope: CoroutineScope, val gid: Long, startPage: Int, val size: Int, val hasAds: Boolean = false) : AutoCloseable {
var startPage = startPage.coerceIn(0, size - 1)
private val jobs = mutableIntObjectMapOf<Job>()
private val mutex = NamedMutex<Int>()
private val semaphore = Semaphore(4)
private val cache = SieveCache<Int, Image>(
maxSize = if (isAtLeastO) {
(OSUtils.totalMemory / 16).toInt().coerceIn(MIN_CACHE_SIZE, MAX_CACHE_SIZE)
(OSUtils.totalMemory / 8).toInt().coerceIn(MIN_CACHE_SIZE, MAX_CACHE_SIZE)
} else {
(OSUtils.appMaxMemory / 3 * 2).toInt()
},
sizeOf = { _, v -> v.allocationSize.toInt() },
onEntryRemoved = { k, o, n, _ ->
if (o.isRecyclable) {
n ?: notifyPageWait(k)
o.recycle()
} else {
o.isRecyclable = true
}
},
onEntryRemoved = { k, o, n, _ -> if (o.unpin()) n ?: notifyPageWait(k) },
)
fun decodePreloadRange(index: Int) = index - 3..index + 3
fun needDecode(index: Int) = index in decodePreloadRange(prevIndex) &&
lock.read { index !in cache } &&
pages[index].status !is PageStatus.Ready &&
pages[index].status !is PageStatus.Blocked
suspend fun atomicallyDecodeAndUpdate(index: Int) {
try {
bracketCase(
{ openSource(index) },
{ src -> notifyPageSucceed(index, Image.decode(src, hasAds && detectAds(index, size))) },
{ src, case -> if (case !is ExitCase.Completed) src.close() },
)
} catch (e: Throwable) {
notifyPageFailed(index, e.displayString())
}
private suspend fun atomicallyDecodeAndUpdate(index: Int) {
bracketCase(
{ openSource(index) },
{ src ->
withNonCancellableContext {
notifyPageSucceed(index, Image.decode(src, hasAds && detectAds(index, size)))
}
},
{ src, case -> if (case !is ExitCase.Completed) src.close() },
)
}
private val lock = ReentrantReadWriteLock()
val pages = (0 until size).map { Page(it) }
private val prefetchPageCount = Settings.preloadImage
private val prefetchPageCount = Settings.preloadImage.value
fun restart() {
lock.write { cache.evictAll() }
pages.forEach(Page::reset)
}
private var prevIndex by AtomicInteger(-1)::value
private val prevIndex = AtomicInt(-1)
fun retryPage(index: Int, orgImg: Boolean = false) {
notifyPageWait(index)
@@ -117,12 +109,6 @@ abstract class PageLoader(val gid: Long, var startPage: Int, val size: Int, val
pages[index].statusFlow.update { PageStatus.Error(error) }
}
val progressJob = progressScope.launch {
if (startPage == -1) {
startPage = EhDB.getReadProgress(gid)
}
}
override fun close() {
lock.write { cache.evictAll() }
if (gid != 0L) {
@@ -141,12 +127,12 @@ abstract class PageLoader(val gid: Long, var startPage: Int, val size: Int, val
}
fun request(index: Int) {
val prefetchRange = if (index >= prevIndex) {
val prefetchRange = if (index >= prevIndex.load()) {
index + 1..(index + prefetchPageCount).coerceAtMost(size - 1)
} else {
index - 1 downTo (index - prefetchPageCount).coerceAtLeast(0)
}
prevIndex = index
prevIndex.store(index)
val image = lock.read { cache[index] }
if (image != null) {
notifyPageSucceed(index, image, false)
@@ -167,15 +153,27 @@ abstract class PageLoader(val gid: Long, var startPage: Int, val size: Int, val
prefetchPages(pagesAbsent, start - 5..end + 5)
}
fun cancelRequest(index: Int) {
jobs[index]?.cancel()
}
abstract fun save(index: Int, file: Path): Boolean
fun notifySourceReady(index: Int) {
if (needDecode(index)) {
scope.launch {
mutex.withLock(index) {
semaphore.withPermit {
// Double check
if (needDecode(index)) atomicallyDecodeAndUpdate(index)
fun notifySourceReady(index: Int) = synchronized(jobs) {
if (jobs[index]?.isActive != true) {
jobs[index] = scope.launch {
try {
mutex.withLock(index) {
semaphore.withPermit {
atomicallyDecodeAndUpdate(index)
}
}
} catch (e: Throwable) {
if (e is CancellationException) {
notifyPageFailed(index, null)
throw e
} else {
notifyPageFailed(index, e.displayString())
}
}
}
@@ -28,14 +28,16 @@ import arrow.fx.coroutines.bracketCase
import coil3.BitmapImage
import coil3.DrawableImage
import coil3.Image as CoilImage
import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ErrorResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.size.Dimension
import coil3.size.Precision
import coil3.size.Size
import coil3.size.SizeResolver
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.coil.AnimatedWebPDrawable
import com.hippo.ehviewer.coil.BitmapImageWithExtraInfo
import com.hippo.ehviewer.coil.detectQrCode
import com.hippo.ehviewer.coil.hardwareThreshold
@@ -44,16 +46,26 @@ 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.ktbuilder.execute
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastU
import com.hippo.files.openFileDescriptor
import com.hippo.files.toUri
import java.nio.ByteBuffer
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.decrementAndFetch
import kotlin.concurrent.atomics.updateAndFetch
import okio.Path
import splitties.init.appCtx
class Image private constructor(image: CoilImage, private val src: ImageSource) {
val refcnt = AtomicInt(1)
fun pin() = refcnt.updateAndFetch { if (it != 0) it + 1 else 0 } != 0
fun unpin() = (refcnt.decrementAndFetch() == 0).also { if (it) recycle() }
val intrinsicSize = with(image) { IntSize(width, height) }
val allocationSize = image.size
val hasQrCode = when (image) {
@@ -66,12 +78,12 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
else -> image
}
var isRecyclable = true
@Synchronized
fun recycle() {
when (val image = innerImage ?: return) {
is DrawableImage -> src.close()
private fun recycle() {
when (val image = innerImage!!) {
is DrawableImage -> {
(image.drawable as? AnimatedWebPDrawable)?.dispose()
src.close()
}
is BitmapImage -> image.bitmap.recycle()
}
innerImage = null
@@ -79,20 +91,23 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
companion object {
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
private val sizeResolver = SizeResolver(Size(targetWidth, Dimension.Undefined))
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(checkExtraneousAds: Boolean): CoilImage {
val req = appCtx.imageRequest {
onLeft { data(it.source) }
onRight { data(it.source.toUri()) }
size(Dimension(targetWidth), Dimension.Undefined)
precision(Precision.INEXACT)
allowHardware(false)
hardwareThreshold(Settings.hardwareBitmapThreshold)
maybeCropBorder(Settings.cropBorder.value)
detectQrCode(checkExtraneousAds)
memoryCachePolicy(CachePolicy.DISABLED)
val request = with(appCtx) {
imageRequest {
onLeft { data(it.source) }
onRight { data(it.source.toUri()) }
size(sizeResolver)
precision(Precision.INEXACT)
allowHardware(false)
hardwareThreshold(Settings.hardwareBitmapThreshold.value)
maybeCropBorder(Settings.cropBorder.value)
detectQrCode(checkExtraneousAds)
memoryCachePolicy(CachePolicy.DISABLED)
}
}
return when (val result = appCtx.imageLoader.execute(req)) {
return when (val result = request.execute()) {
is SuccessResult -> result.image
is ErrorResult -> throw result.throwable
}
@@ -12,9 +12,13 @@ import com.hippo.ehviewer.coil.ehUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
inline fun Context.imageRequest(preview: GalleryPreview, builder: ImageRequest.Builder.() -> Unit = {}) = ImageRequest.Builder(this).ehPreview(preview).apply(builder).build()
inline fun Context.imageRequest(info: GalleryInfo, builder: ImageRequest.Builder.() -> Unit = {}) = ImageRequest.Builder(this).ehUrl(info).apply(builder).build()
inline fun Context.imageRequest(builder: ImageRequest.Builder.() -> Unit = {}) = ImageRequest.Builder(this).apply(builder).build()
context(ctx: Context)
inline fun imageRequest(preview: GalleryPreview, builder: ImageRequest.Builder.() -> Unit = {}) = ImageRequest.Builder(ctx).ehPreview(preview).apply(builder).build()
context(ctx: Context)
inline fun imageRequest(info: GalleryInfo, builder: ImageRequest.Builder.() -> Unit = {}) = ImageRequest.Builder(ctx).ehUrl(info).apply(builder).build()
context(ctx: Context)
inline fun imageRequest(builder: ImageRequest.Builder.() -> Unit = {}) = ImageRequest.Builder(ctx).apply(builder).build()
inline fun diskCache(builder: DiskCache.Builder.() -> Unit) = DiskCache.Builder().apply(builder).build()
inline fun Context.imageLoader(builder: ImageLoader.Builder.() -> Unit) = ImageLoader.Builder(this).apply(builder).build()
fun ImageRequest.launchIn(scope: CoroutineScope) = scope.launch { context.imageLoader.execute(this@launchIn) }
suspend fun ImageRequest.execute() = context.imageLoader.execute(this)
fun ImageRequest.executeIn(scope: CoroutineScope) = scope.launch { execute() }
@@ -1,36 +1,54 @@
package com.hippo.ehviewer.ktor
import com.hippo.ehviewer.Settings.userAgent
import com.hippo.ehviewer.BuildConfig
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.SaveBodyPlugin
import io.ktor.client.plugins.UserAgent
import io.ktor.client.plugins.HttpTimeoutConfig
import io.ktor.client.plugins.api.createClientPlugin
import io.ktor.client.plugins.cookies.HttpCookies
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.http.userAgent
import io.ktor.util.appendIfNameAbsent
private 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"
private const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
const val CHROME_MOBILE_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
private const val CHROME_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Safari/537.36"
private 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.7"
private const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.9"
fun <T : HttpClientEngineConfig> HttpClientConfig<T>.configureCommon(redirect: Boolean = true) = apply {
install(HttpCookies) {
storage = EhCookieStore
}
install(HttpTimeout) {
reset()
requestTimeoutMillis = 10_000
}
install(UserAgent) {
agent = userAgent
}
install(UserAgent)
defaultRequest {
header(HttpHeaders.Accept, CHROME_ACCEPT)
headers.appendIfNameAbsent(HttpHeaders.Accept, CHROME_ACCEPT)
header(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
}
followRedirects = redirect
install(SaveBodyPlugin) {
disabled = true
}
private val UserAgent = createClientPlugin("UserAgent") {
onRequest { request, _ ->
val userAgent = if (Settings.desktopSite.value) {
CHROME_USER_AGENT
} else {
CHROME_MOBILE_USER_AGENT
}
request.userAgent(userAgent)
}
}
fun HttpTimeoutConfig.reset() = apply {
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
connectTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
}
@@ -11,6 +11,7 @@ import android.net.http.UrlResponseInfo
import android.os.Build
import androidx.annotation.RequiresExtension
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.util.restartApplication
import io.ktor.client.engine.HttpClientEngineBase
import io.ktor.client.engine.callContext
import io.ktor.client.plugins.HttpTimeoutCapability
@@ -39,6 +40,7 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.job
import kotlinx.coroutines.suspendCancellableCoroutine
import splitties.preferences.edit
class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cronet") {
// Limit thread to 1 since we are async & non-blocking
@@ -103,8 +105,12 @@ class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cr
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: HttpException) {
// Cronet may crash on some devices, fuck xiaomi
// https://github.com/FooIbar/EhViewer/issues/1826
if (error.message?.contains("ERR_FILE_NOT_FOUND") == true) {
Settings.enableCronet = false
val message = error.message.orEmpty()
if ("ERR_FILE_NOT_FOUND" in message || "ERR_PROXY_CONNECTION_FAILED" in message) {
Settings.edit(true) {
enableCronet.value = false
}
with(config.context) { restartApplication() }
}
if (continuation.isActive) {
continuation.resumeWithException(error)
@@ -7,7 +7,7 @@ import android.os.Build
import androidx.annotation.RequiresExtension
import java.io.File
fun CronetConfig.configureClient() {
fun CronetConfig.configureClient(enableQuic: Boolean) {
config = {
setEnableBrotli(true)
@@ -16,8 +16,11 @@ fun CronetConfig.configureClient() {
setStoragePath(cache.path)
setEnableHttpCache(HttpEngine.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)
setEnableQuic(enableQuic)
if (enableQuic) {
addQuicHint("e-hentai.org", 443, 443)
addQuicHint("forums.e-hentai.org", 443, 443)
addQuicHint("exhentai.org", 443, 443)
}
}
}
@@ -3,10 +3,8 @@ package com.hippo.ehviewer.ktor
import com.hippo.ehviewer.util.isAtLeastQ
import io.ktor.client.engine.okhttp.OkHttpConfig
import okhttp3.AsyncDns
import okhttp3.ExperimentalOkHttpApi
import okhttp3.android.AndroidAsyncDns
@OptIn(ExperimentalOkHttpApi::class)
fun OkHttpConfig.configureClient() {
config {
if (isAtLeastQ) {
@@ -1,38 +1,36 @@
package com.hippo.ehviewer.legacy
import com.hippo.ehviewer.spider.SpiderInfo
import java.io.InputStream
import okio.buffer
import okio.source
import com.hippo.files.read
import kotlinx.io.readLineStrict
import okio.Path
fun readLegacySpiderInfo(inputStream: InputStream): SpiderInfo {
val source = inputStream.source().buffer()
fun read(): String = source.readUtf8LineStrict()
fun Path.readLegacySpiderInfo() = read {
fun read() = readLineStrict()
fun readInt() = read().toInt()
fun readLong() = read().toLong()
fun readInt(): Int = read().toInt()
fun readLong(): Long = read().toLong()
repeat(2) { read() } // We assert that only info v2
read() // Skip version, we assert it's v2
read() // Skip startPage
val gid = readLong()
val token = read()
read()
val previewPages = readInt()
read() // Skip mode
read() // Skip previewPages
val previewPerPage = readInt()
val pages = read().toInt()
val pTokenMap = hashMapOf<Int, String>()
val info = SpiderInfo(gid, token, pages, pTokenMap, previewPages, previewPerPage)
runCatching {
while (true) {
val line = read()
val pos = line.indexOf(" ")
if (pos > 0) {
val index = line.substring(0, pos).toInt()
val pToken = line.substring(pos + 1)
if (pToken.isNotEmpty()) {
pTokenMap[index] = pToken
val pages = readInt()
SpiderInfo(gid, token, pages, previewPerPage = previewPerPage).apply {
runCatching {
while (true) {
val line = read()
val pos = line.indexOf(" ")
if (pos > 0) {
val index = line.substring(0, pos).toInt()
val pToken = line.substring(pos + 1)
if (pToken.isNotEmpty()) {
pTokenMap[index] = pToken
}
}
}
}
}
return info
}
@@ -15,10 +15,8 @@
*/
package com.hippo.ehviewer.shortcuts
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.hippo.ehviewer.download.DownloadService
/**
@@ -27,8 +25,8 @@ import com.hippo.ehviewer.download.DownloadService
class ShortcutsActivity : AppCompatActivity() {
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent?.action) {
DownloadService.ACTION_START_ALL, DownloadService.ACTION_STOP_ALL -> ContextCompat.startForegroundService(this, Intent(this, DownloadService::class.java).setAction(intent.action))
when (val action = intent?.action) {
DownloadService.ACTION_START_ALL, DownloadService.ACTION_STOP_ALL -> DownloadService.startService(action)
}
finish()
}
@@ -3,6 +3,8 @@ package com.hippo.ehviewer.spider
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryTag
import com.hippo.ehviewer.client.data.PowerStatus
import com.hippo.ehviewer.client.data.SimpleTagsConverter
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.client.data.TagNamespace.Artist
@@ -14,8 +16,8 @@ import com.hippo.ehviewer.client.data.TagNamespace.Male
import com.hippo.ehviewer.client.data.TagNamespace.Mixed
import com.hippo.ehviewer.client.data.TagNamespace.Other
import com.hippo.ehviewer.client.data.TagNamespace.Parody
import com.hippo.files.openInputStream
import com.hippo.files.openOutputStream
import com.hippo.files.read
import com.hippo.files.write
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -23,12 +25,12 @@ import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import net.devrieze.xmlutil.serialization.kxio.decodeFromSource
import net.devrieze.xmlutil.serialization.kxio.encodeToSink
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.newWriter
import nl.adaptivity.xmlutil.serialization.XML
import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.xmlStreaming
import okio.Path
const val COMIC_INFO_FILE = "ComicInfo.xml"
@@ -52,8 +54,8 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
with(TagNamespace) {
when (this@getComicInfo) {
is GalleryDetail -> tagGroups.forEach { group ->
val list = group.tags.filterNot { it == TAG_ORIGINAL || it.startsWith('_') }
when (val ns = group.nameSpace) {
val list = group.tags.filterNot { (text, power, _) -> text == TAG_ORIGINAL || power == PowerStatus.Weak }.map(GalleryTag::text)
when (val ns = group.namespace) {
Artist, Cosplayer -> artists.addAll(list)
Group -> groups.addAll(list)
Character -> characters.addAll(list)
@@ -104,19 +106,11 @@ fun ComicInfo.toSimpleTags() = listOfNotNull(
teams,
).flatten().ifEmpty { null }
fun ComicInfo.write(file: Path) {
file.openOutputStream().bufferedWriter().use {
xmlStreaming.newWriter(it).use { writer ->
xml.encodeToWriter(writer, ComicInfo.serializer(), this)
}
}
}
fun writeComicInfo(info: ComicInfo, file: Path) = file.write { xml.encodeToSink(this, info) }
fun readComicInfo(file: Path): ComicInfo? = runCatching {
file.openInputStream().bufferedReader().use {
xmlStreaming.newReader(it).use { reader ->
xml.decodeFromReader(ComicInfo.serializer(), reader)
}
file.read {
xml.decodeFromSource<ComicInfo>(this)
}
}.getOrNull()
@@ -0,0 +1,113 @@
package com.hippo.ehviewer.spider
import androidx.collection.MutableObjectIntMap
import androidx.collection.mutableObjectIntMapOf
import arrow.fx.coroutines.fixedRate
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.executeSafely
import com.hippo.ehviewer.ktor.reset
import com.hippo.ehviewer.util.LowSpeedException
import com.hippo.ehviewer.util.ensureSuccess
import io.ktor.client.plugins.ConnectTimeoutException
import io.ktor.client.plugins.onDownload
import io.ktor.client.plugins.timeout
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.HttpStatement
import kotlin.concurrent.atomics.AtomicReference
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.io.IOException
class SpeedTracker(val window: Duration = 1.seconds) {
private val mutex = Mutex()
private val received = mutableObjectIntMapOf<Instant>()
private var start: Instant? = null
suspend fun reset() = mutex.withLock {
received.clear()
start = null
}
suspend fun track(bytes: Int) = Clock.System.now().let { now ->
mutex.withLock {
start = start ?: now
received[now] = bytes
}
}
fun speedFlow(sample: Duration = 1.seconds) = fixedRate(sample).map {
mutex.withLock {
start?.let { start ->
val now = Clock.System.now()
val passed = now - start
val window = passed.coerceIn(10.milliseconds, window)
val cutoff = now - window
received.removeIf { time, _ -> time < cutoff }
received.fold(0) { total, _, v -> total + v } / (window / 1.seconds)
} ?: 0.0
}
}
}
private typealias OnTimeout = (IOException) -> Unit
suspend inline fun <R> timeoutBySpeed(
url: String,
crossinline request: suspend (HttpRequestBuilder.() -> Unit) -> HttpStatement,
crossinline l: suspend (Long, Long, Int) -> Unit,
crossinline f: suspend (HttpResponse) -> R,
noinline t: OnTimeout = { throw it },
) = coroutineScope {
val onTimeout = AtomicReference<OnTimeout?>(t).let { { e: IOException -> it.exchange(null)?.invoke(e) } }
val watchdog = launch {
delay(Settings.connTimeout.value.seconds)
onTimeout(ConnectTimeoutException(url, Settings.connTimeout.value * 1000L))
}
val tracker = SpeedTracker(2.seconds)
request {
var prev = 0L
onDownload { done, total ->
val bytesRead = (done - prev).toInt()
tracker.track(bytesRead)
l(total!!, done, bytesRead)
prev = done
}
timeout { reset() }
}.executeSafely { resp ->
watchdog.cancel()
resp.status.ensureSuccess()
val speedWatchdog = launch {
val timeoutSpeed = speedLevelToSpeed(Settings.timeoutSpeed.value) * 1024.0
tracker.speedFlow(1.seconds).collect { speed ->
if (timeoutSpeed != 0.0 && speed < timeoutSpeed) {
onTimeout(LowSpeedException(url, speed.toLong()))
}
}
}
try {
f(resp)
} finally {
speedWatchdog.cancel()
}
}
}
fun speedLevelToSpeed(level: Int) = 2f.pow(level).roundToInt()
inline fun <T, R> MutableObjectIntMap<T>.fold(initial: R, operation: (acc: R, T, Int) -> R): R {
var accumulator = initial
forEach { key, value -> accumulator = operation(accumulator, key, value) }
return accumulator
}
@@ -28,7 +28,6 @@ import com.hippo.ehviewer.client.EhUtils.getSuitableTitle
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.client.executeSafely
import com.hippo.ehviewer.client.getImageKey
import com.hippo.ehviewer.coil.read
import com.hippo.ehviewer.coil.suspendEdit
@@ -39,6 +38,7 @@ import com.hippo.ehviewer.download.tempDownloadDir
import com.hippo.ehviewer.image.PathSource
import com.hippo.ehviewer.jni.archiveFdBatch
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.copyTo
import com.hippo.ehviewer.util.sendTo
import com.hippo.ehviewer.util.sha1
import com.hippo.files.delete
@@ -49,15 +49,10 @@ import com.hippo.files.list
import com.hippo.files.mkdirs
import com.hippo.files.moveTo
import com.hippo.files.openFileDescriptor
import com.hippo.files.openOutputStream
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.plugins.onDownload
import io.ktor.client.plugins.timeout
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.request
import io.ktor.http.isSuccess
import io.ktor.utils.io.copyTo
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
@@ -70,9 +65,8 @@ class SpiderDen(val info: GalleryInfo) {
private set
private var tempDownloadDir: Path? = null
private val saveAsCbz = Settings.saveAsCbz
private val saveAsCbz = Settings.saveAsCbz.value
private val archiveName = "$gid.cbz"
private val downloadTimeout = Settings.downloadTimeout * 1000L
private val lock = ReentrantReadWriteLock()
@@ -170,22 +164,12 @@ class SpiderDen(val info: GalleryInfo) {
url: String,
referer: String?,
notifyProgress: (Long, Long, Int) -> Unit,
) = ehRequest(url, referer) {
var prev = 0L
onDownload { done, total ->
notifyProgress(total!!, done, (done - prev).toInt())
prev = done
}
timeout {
requestTimeoutMillis = downloadTimeout
}
}.executeSafely {
if (it.status.isSuccess()) {
saveFromHttpResponse(index, it)
} else {
false
}
}
) = timeoutBySpeed(
url,
{ ehRequest(url, referer, builder = it) },
notifyProgress,
{ resp -> check(saveFromHttpResponse(index, resp)) },
)
private suspend inline fun saveResponseMeta(
index: Int,
@@ -198,7 +182,7 @@ class SpiderDen(val info: GalleryInfo) {
val file = resolve(tempFile.name.removeSuffix(TEMP_SUFFIX))
file.delete()
lock.write { fileCache.remove(file.name) }
tempFile.moveTo(file)
tempFile moveTo file
lock.write {
fileCache.remove(tempFile.name)
fileCache[file.name] = file
@@ -221,9 +205,7 @@ class SpiderDen(val info: GalleryInfo) {
val url = response.request.url.toString()
val extension = MimeTypeMap.getFileExtensionFromUrl(url).ifEmpty { "jpg" }
return saveResponseMeta(index, extension) { outFile ->
outFile.openOutputStream().use {
response.bodyAsChannel().copyTo(it.channel)
}
response.bodyAsChannel().copyTo(outFile)
FileHashRegex.find(url)?.let {
val expected = it.groupValues[1]
val actual = outFile.sha1()
@@ -284,7 +266,7 @@ class SpiderDen(val info: GalleryInfo) {
FileUtils.getExtensionFromFilename(source.name)!!
}
override fun close() {}
override fun close() = Unit
}
}
@@ -352,19 +334,15 @@ class SpiderDen(val info: GalleryInfo) {
suspend fun writeComicInfo(fetchMetadata: Boolean = true) {
downloadDir?.run {
resolve(COMIC_INFO_FILE).also {
runCatching {
if (info !is GalleryDetail && fetchMetadata) {
EhEngine.fillGalleryListByApi(listOf(info))
if (info !is GalleryDetail && fetchMetadata) {
EhEngine.fillGalleryListByApi(listOf(info))
}
info.getComicInfo().apply {
writeComicInfo(this, it)
DownloadManager.getDownloadInfo(gid)?.let { downloadInfo ->
downloadInfo.artistInfoList = DownloadArtist.from(gid, penciller.orEmpty())
EhDB.putDownloadArtist(gid, downloadInfo.artistInfoList)
}
info.getComicInfo().apply {
write(it)
DownloadManager.getDownloadInfo(gid)?.let { downloadInfo ->
downloadInfo.artistInfoList = DownloadArtist.from(gid, penciller.orEmpty())
EhDB.putDownloadArtist(gid, downloadInfo.artistInfoList)
}
}
}.onFailure {
logcat(it)
}
}
}
@@ -4,9 +4,10 @@ import com.hippo.ehviewer.coil.edit
import com.hippo.ehviewer.coil.read
import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.legacy.readLegacySpiderInfo
import com.hippo.files.openInputStream
import com.hippo.files.openOutputStream
import com.hippo.files.read
import com.hippo.files.write
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.io.readByteArray
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
@@ -26,8 +27,6 @@ class SpiderInfo(
val pTokenMap: MutableMap<Int, String> = hashMapOf(),
var previewPages: Int = -1,
var previewPerPage: Int = -1,
)
@@ -35,16 +34,12 @@ private val cbor = Cbor {
ignoreUnknownKeys = true
}
fun SpiderInfo.write(file: Path) {
file.openOutputStream().use {
it.write(cbor.encodeToByteArray(this))
}
}
fun SpiderInfo.write(file: Path) = file.write { write(cbor.encodeToByteArray(contextOf<SpiderInfo>())) }
fun SpiderInfo.saveToCache() {
runSuspendCatching {
spiderInfoCache.edit(gid.toString()) {
data.toFile().writeBytes(cbor.encodeToByteArray(this@saveToCache))
write(data)
}
}.onFailure {
logcat(it)
@@ -60,18 +55,14 @@ private val spiderInfoCache by lazy {
fun readFromCache(gid: Long): SpiderInfo? = spiderInfoCache.read(gid.toString()) {
runCatching {
cbor.decodeFromByteArray<SpiderInfo>(data.toFile().readBytes())
data.read { cbor.decodeFromByteArray<SpiderInfo>(readByteArray()) }
}.onFailure {
logcat(it)
}.getOrNull()
}
fun readCompatFromPath(file: Path): SpiderInfo? = runCatching {
file.openInputStream().use {
cbor.decodeFromByteArray<SpiderInfo>(it.readBytes())
}
file.read { cbor.decodeFromByteArray<SpiderInfo>(readByteArray()) }
}.getOrNull() ?: runCatching {
file.openInputStream().use { readLegacySpiderInfo(it) }
file.readLegacySpiderInfo()
}.getOrNull()
const val TOKEN_FAILED = "failed"
@@ -17,6 +17,8 @@
package com.hippo.ehviewer.spider
import androidx.annotation.IntDef
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.partially1
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
@@ -25,19 +27,19 @@ import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUrl.getGalleryDetailUrl
import com.hippo.ehviewer.client.EhUrl.getGalleryMultiPageViewerUrl
import com.hippo.ehviewer.client.EhUrl.referer
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.client.exception.QuotaExceededException
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
import com.hippo.ehviewer.client.parser.GalleryMultiPageViewerPTokenParser
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
import com.hippo.ehviewer.util.displayString
import com.hippo.files.find
import eu.kanade.tachiyomi.util.system.logcat
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.decrementAndFetch
import kotlin.concurrent.atomics.incrementAndFetch
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource
import kotlinx.coroutines.CancellationException
@@ -55,6 +57,7 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import moe.tarsin.coroutines.runSuspendCatching
import okio.FileNotFoundException
import okio.Path
import splitties.init.appCtx
@@ -67,8 +70,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
val spiderDen: SpiderDen = SpiderDen(galleryInfo)
private val mPageStateLock = Any()
private val mDownloadedPages = AtomicInteger(0)
private val mFinishedPages = AtomicInteger(0)
private val mDownloadedPages = AtomicInt(0)
private val mFinishedPages = AtomicInt(0)
private val mSpiderListeners: MutableList<OnSpiderListener> = ArrayList()
private var mReadReference = 0
@@ -112,8 +115,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
mSpiderListeners.forEach {
it.onPageSuccess(
index,
mFinishedPages.get(),
mDownloadedPages.get(),
mFinishedPages.load(),
mDownloadedPages.load(),
pageStates.size,
)
}
@@ -126,8 +129,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
it.onPageFailure(
index,
error,
mFinishedPages.get(),
mDownloadedPages.get(),
mFinishedPages.load(),
mDownloadedPages.load(),
pageStates.size,
)
}
@@ -138,16 +141,25 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
synchronized(mSpiderListeners) {
mSpiderListeners.forEach {
it.onFinish(
mFinishedPages.get(),
mDownloadedPages.get(),
mFinishedPages.load(),
mDownloadedPages.load(),
pageStates.size,
)
}
}
}
private fun notifyPageReady(index: Int) {
synchronized(mSpiderListeners) {
mSpiderListeners.forEach {
it.onPageReady(index)
}
}
}
private var downloadMode = false
val isReady
private val isReady
get() = this::spiderInfo.isInitialized && this::pageStates.isInitialized
private val updateLock = Mutex()
@@ -177,8 +189,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
i++
}
mDownloadedPages.lazySet(0)
mFinishedPages.lazySet(0)
mDownloadedPages.store(0)
mFinishedPages.store(0)
}
mWorkerScope.enterDownloadMode()
}
@@ -208,10 +220,16 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private suspend fun doPrepare() {
spiderDen.initDownloadDirIfExist()
spiderInfo = readSpiderInfoFromLocal() ?: readSpiderInfoFromInternet()
pageStates = IntArray(spiderInfo.pages)
check(spiderInfo.pages > 0)
notifyGetPages(spiderInfo.pages)
val pages = Either.catch {
spiderInfo = readSpiderInfoFromLocal() ?: readSpiderInfoFromInternet()
spiderInfo.pages
}.getOrElse {
logcat(it)
galleryInfo.pages
}
check(pages > 0)
pageStates = IntArray(pages)
notifyGetPages(pages)
}
suspend fun awaitReady() = prepareJob.await()
@@ -224,7 +242,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
if (!spiderDen.postArchive()) {
if (mWorkerScope.isDownloadMode) {
spiderDen.writeComicInfo()
runCatching {
spiderDen.writeComicInfo()
}.onFailure {
logcat(it)
}
}
runCatching {
writeSpiderInfoToLocal()
@@ -295,36 +317,31 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
?: readFromCache(galleryInfo.gid)?.takeIf { it.gid == galleryInfo.gid && it.token == galleryInfo.token }
private fun readPreviews(body: String, index: Int, spiderInfo: SpiderInfo) {
spiderInfo.previewPages = parsePreviewPages(body)
val (previewList, pageUrlList) = parsePreviewList(body)
if (previewList.isNotEmpty()) {
if (index == 0) {
spiderInfo.previewPerPage = previewList.size
} else {
spiderInfo.previewPerPage = previewList[0].position / index
}
private fun readPreviews(previews: List<GalleryPreview>, index: Int, spiderInfo: SpiderInfo) {
if (index == 0) {
spiderInfo.previewPerPage = previews.size
} else {
spiderInfo.previewPerPage = previews[0].position / index
}
pageUrlList.forEach {
val result = GalleryPageUrlParser.parse(it)
if (result != null) {
spiderInfo.pTokenMap[result.page] = result.pToken
previews.forEach {
if (it.pToken.isNotEmpty()) {
spiderInfo.pTokenMap[it.position] = it.pToken
}
}
}
private suspend fun readSpiderInfoFromInternet() = ehRequest(
private suspend fun readSpiderInfoFromInternet() = EhEngine.getPreviewList(
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
referer,
).fetchUsingAsText {
val pages = parsePages(this)
val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, pages)
readPreviews(this, 0, spiderInfo)
).run {
val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, total)
readPreviews(previews, 0, spiderInfo)
spiderInfo
}
private val isMpvAvailable = EhUtils.isMpvAvailable
suspend fun getPTokenFromMultiPageViewer(index: Int): String? {
val spiderInfo = spiderInfo
if (!isMpvAvailable) return null
val url = getGalleryMultiPageViewerUrl(
galleryInfo.gid,
galleryInfo.token,
@@ -343,18 +360,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
suspend fun getPTokenFromInternet(index: Int): String? {
val spiderInfo = spiderInfo
// Check previewIndex
var previewIndex: Int
previewIndex = if (spiderInfo.previewPerPage >= 0) {
val previewIndex = if (spiderInfo.previewPerPage > 0) {
index / spiderInfo.previewPerPage
} else {
0
}
if (spiderInfo.previewPages > 0) {
previewIndex = previewIndex.coerceAtMost(spiderInfo.previewPages - 1)
}
val url = getGalleryDetailUrl(
galleryInfo.gid,
galleryInfo.token,
@@ -362,8 +372,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
false,
)
return runSuspendCatching {
ehRequest(url, referer).fetchUsingAsText {
readPreviews(this, previewIndex, spiderInfo)
EhEngine.getPreviewList(url).run {
readPreviews(previews, previewIndex, spiderInfo)
spiderInfo.pTokenMap[index]
}
}.getOrElse {
@@ -382,19 +392,18 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private fun isStateDone(state: Int): Boolean = state == STATE_FINISHED || state == STATE_FAILED
fun updatePageState(index: Int, @State state: Int, error: String? = null) {
var oldState: Int
synchronized<Unit>(mPageStateLock) {
oldState = pageStates[index]
val oldState = pageStates[index]
pageStates[index] = state
if (!isStateDone(oldState) && isStateDone(state)) {
mDownloadedPages.incrementAndGet()
mDownloadedPages.incrementAndFetch()
} else if (isStateDone(oldState) && !isStateDone(state)) {
mDownloadedPages.decrementAndGet()
mDownloadedPages.decrementAndFetch()
}
if (oldState != STATE_FINISHED && state == STATE_FINISHED) {
mFinishedPages.incrementAndGet()
mFinishedPages.incrementAndFetch()
} else if (oldState == STATE_FINISHED && state != STATE_FINISHED) {
mFinishedPages.decrementAndGet()
mFinishedPages.decrementAndFetch()
}
}
@@ -404,8 +413,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
} else if (state == STATE_FINISHED) {
notifyPageSuccess(index)
}
if (mDownloadedPages.get() == size) {
if (mFinishedPages.get() == size) archiveJob.start()
if (mDownloadedPages.load() == size) {
if (mFinishedPages.load() == size) archiveJob.start()
notifyAllPageDownloaded()
}
}
@@ -424,6 +433,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int) {}
fun onPageFailure(index: Int, error: String?, finished: Int, downloaded: Int, total: Int) {}
fun onFinish(finished: Int, downloaded: Int, total: Int) {}
fun onPageReady(index: Int) {}
}
companion object {
@@ -459,11 +469,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private val mWorkerScope = object {
private val jobs = hashMapOf<Int, Job>()
private val semaphore = Semaphore(Settings.multiThreadDownload)
private val semaphore = Semaphore(Settings.multiThreadDownload.value)
private val pTokenLock = Mutex()
private var showKey: String? = null
private val showKeyLock = Mutex()
private val downloadDelay = Settings.downloadDelay.milliseconds
private val downloadDelay = Settings.downloadDelay.value.milliseconds
private var lastRequestTime = TimeSource.Monotonic.markNow()
var isDownloadMode = false
private set
@@ -476,11 +486,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
fun updateRAList(list: List<Int>, aliveBound: IntRange = 0..Int.MAX_VALUE) {
val absent = list.filterNot { index ->
(pageStates[index] == STATE_FINISHED).also { finished ->
if (finished) notifyPageSuccess(index)
}
}
if (isDownloadMode) return
synchronized(jobs) {
jobs.forEach { (i, job) ->
@@ -488,8 +493,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
job.cancel()
}
}
absent.forEach {
if (jobs[it]?.isActive != true) {
list.forEach {
if (pageStates[it] != STATE_FINISHED && jobs[it]?.isActive != true) {
doLaunchDownloadJob(it, false)
}
}
@@ -525,34 +530,32 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
fun launch(index: Int, force: Boolean = false, orgImg: Boolean) {
check(index in 0 until size)
val state = pageStates[index]
if (!force && state == STATE_FINISHED) return notifyPageSuccess(index)
if (!force && state == STATE_FINISHED) return notifyPageReady(index)
if (!isDownloadMode) {
synchronized(jobs) { doLaunchDownloadJob(index, force, orgImg) }
}
launch {
jobs[index]?.join()
if (pageStates[index] == STATE_FINISHED) notifyPageReady(index)
}
}
private suspend fun doInJob(index: Int, force: Boolean, orgImg: Boolean, skipHath: Boolean) {
suspend fun getPToken(index: Int): String? {
if (index !in 0 until size) return null
return spiderInfo.pTokenMap[index].takeIf { it != TOKEN_FAILED }
?: getPTokenFromInternet(index)
?: getPTokenFromInternet(index)
if (!isReady || index !in 0 until size) return null
return spiderInfo.pTokenMap[index]
?: getPTokenFromMultiPageViewer(index)
?: getPTokenFromInternet(index)
// Preview size may changed, so try to get pToken twice
?: getPTokenFromInternet(index)
}
val previousPToken: String?
val pToken: String
pTokenLock.withLock {
if (force) {
if (spiderInfo.pTokenMap[index] == TOKEN_FAILED) {
spiderInfo.pTokenMap.remove(index)
}
} else if (index in spiderDen) {
if (!force && index in spiderDen) {
return updatePageState(index, STATE_FINISHED)
}
pToken = getPToken(index)
?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE).also {
spiderInfo.pTokenMap[index] = TOKEN_FAILED
}
pToken = getPToken(index) ?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE)
previousPToken = getPToken(index - 1)
// The lock for delay should be acquired before anything else to maintain FIFO order
@@ -565,9 +568,9 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
var originImageUrl: String? = null
var error: String? = null
var forceHtml = false
val original = Settings.downloadOriginImage || orgImg
val original = Settings.downloadOriginImage.value || orgImg
runSuspendCatching {
repeat(2) { retries ->
repeat(3) { retries ->
var imageUrl: String? = null
var localShowKey: String?
@@ -591,7 +594,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
if (imageUrl == null) {
runSuspendCatching {
EhEngine.getGalleryPageApi(
spiderInfo.gid,
galleryInfo.gid,
index,
pToken,
localShowKey,
@@ -617,8 +620,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
if (retries == 1 && skipHathKey != null) {
originImageUrl += "?nl=$skipHathKey"
}
val pageUrl = EhUrl.getPageUrl(spiderInfo.gid, index, pToken)
EhEngine.getOriginalImageUrl(originImageUrl!!, pageUrl) to EhUrl.referer
val pageUrl = EhUrl.getPageUrl(galleryInfo.gid, index, pToken)
EhEngine.getOriginalImageUrl(originImageUrl!!, pageUrl) to referer
} else {
// Original image url won't change, so only set forceHtml in this case
forceHtml = true
@@ -626,26 +629,24 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
checkNotNull(targetImageUrl)
repeat(2) { times ->
runCatching {
logcat(WORKER_DEBUG_TAG) { "Start download image $index attempt #$times" }
val success = spiderDen.makeHttpCallAndSaveImage(
index,
targetImageUrl,
referer,
this@SpiderQueen::notifyPageDownload.partially1(index),
)
check(success)
logcat(WORKER_DEBUG_TAG) { "Download image $index succeed" }
updatePageState(index, STATE_FINISHED)
return
}.onFailure {
spiderDen.removeIntermediateFiles(index)
logcat(WORKER_DEBUG_TAG) { "Download image $index attempt #$times failed" }
if (it is CancellationException) throw it
error = it.displayString()
runCatching {
logcat(WORKER_DEBUG_TAG) { "Start download image $index" }
spiderDen.makeHttpCallAndSaveImage(
index,
targetImageUrl,
referer,
this@SpiderQueen::notifyPageDownload.partially1(index),
)
logcat(WORKER_DEBUG_TAG) { "Download image $index succeed" }
updatePageState(index, STATE_FINISHED)
return
}.onFailure {
spiderDen.removeIntermediateFiles(index)
logcat(WORKER_DEBUG_TAG) { "Download image $index failed" }
when (it) {
is CancellationException, is FileNotFoundException -> throw it
}
error = it.displayString()
}
}
}.onFailure {
@@ -654,6 +655,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
// TODO: Check IP ban
}
error = it.displayString()
if (error == "Invalid page.") {
pTokenLock.withLock {
spiderInfo.pTokenMap.remove(index)
}
}
}
updatePageState(index, STATE_FAILED, error)
}
@@ -17,8 +17,6 @@ package com.hippo.ehviewer.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -37,7 +35,6 @@ import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
@@ -58,29 +55,38 @@ import com.hippo.ehviewer.ui.destinations.ReaderScreenDestination
import com.hippo.ehviewer.ui.reader.ReaderScreenArgs
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.ui.tools.awaitConfirmationOrCancel
import com.hippo.ehviewer.ui.tools.awaitResult
import com.hippo.ehviewer.ui.tools.awaitSelectDate
import com.hippo.ehviewer.ui.tools.awaitSelectItem
import com.hippo.ehviewer.ui.tools.awaitSelectItemWithCheckBox
import com.hippo.ehviewer.ui.tools.awaitSelectItemWithIcon
import com.hippo.ehviewer.ui.tools.awaitSelectItemWithIconAndTextField
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.bgWork
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.isAtLeastT
import com.hippo.ehviewer.util.mapToLongArray
import com.hippo.ehviewer.util.requestPermission
import com.hippo.ehviewer.util.restartApplication
import com.hippo.ehviewer.util.toEpochMillis
import com.hippo.ehviewer.util.toLocalDateTime
import com.hippo.files.delete
import com.hippo.files.exists
import com.hippo.files.isDirectory
import com.hippo.files.openOutputStream
import com.hippo.files.write
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlin.time.Clock
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Clock
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.todayIn
import moe.tarsin.coroutines.runSuspendCatching
import moe.tarsin.string
import moe.tarsin.tip
import okio.Path
import splitties.init.appCtx
@@ -89,7 +95,7 @@ private fun removeNoMediaFile(downloadDir: Path) {
}
private fun ensureNoMediaFile(downloadDir: Path) {
(downloadDir / ".nomedia").openOutputStream().close()
(downloadDir / ".nomedia").apply { if (!exists()) write {} }
}
private val lck = Mutex()
@@ -97,7 +103,7 @@ private val lck = Mutex()
suspend fun keepNoMediaFileStatus(downloadDir: Path = downloadLocation) {
if (downloadDir.isDirectory) {
lck.withLock {
if (Settings.mediaScan) {
if (Settings.mediaScan.value) {
removeNoMediaFile(downloadDir)
} else {
ensureNoMediaFile(downloadDir)
@@ -108,26 +114,18 @@ suspend fun keepNoMediaFileStatus(downloadDir: Path = downloadLocation) {
fun getFavoriteIcon(favorited: Boolean) = if (favorited) Icons.Default.Favorite else Icons.Default.FavoriteBorder
suspend fun DialogState.startDownload(
context: Context,
forceDefault: Boolean,
vararg galleryInfos: BaseGalleryInfo,
) = with(context) {
context(_: DialogState, _: MainActivity)
suspend fun startDownload(forceDefault: Boolean, vararg galleryInfos: BaseGalleryInfo) {
if (isAtLeastT) {
requestPermission(Manifest.permission.POST_NOTIFICATIONS)
}
val (toStart, toAdd) = galleryInfos.partition { DownloadManager.containDownloadInfo(it.gid) }
if (toStart.isNotEmpty()) {
val intent = Intent(context, DownloadService::class.java)
intent.action = DownloadService.ACTION_START_RANGE
val list = toStart.mapToLongArray(GalleryInfo::gid)
intent.putExtra(DownloadService.KEY_GID_LIST, list)
ContextCompat.startForegroundService(context, intent)
DownloadService.startRangeDownload(list)
}
if (toAdd.isEmpty()) {
return with(findActivity<MainActivity>()) {
showTip(R.string.added_to_download_list)
}
return tip(R.string.added_to_download_list)
}
var justStart = forceDefault
var label: String? = null
@@ -144,21 +142,15 @@ suspend fun DialogState.startDownload(
if (justStart) {
// Got default label
for (gi in toAdd) {
val intent = Intent(context, DownloadService::class.java)
intent.action = DownloadService.ACTION_START
intent.putExtra(DownloadService.KEY_LABEL, label)
intent.putExtra(DownloadService.KEY_GALLERY_INFO, gi)
ContextCompat.startForegroundService(context, intent)
DownloadService.startDownload(gi, label)
}
// Notify
with(findActivity<MainActivity>()) {
showTip(R.string.added_to_download_list)
}
tip(R.string.added_to_download_list)
} else {
// Let use chose label
val list = DownloadManager.labelList
val items = buildList {
add(getString(R.string.default_download_label_name))
add(string(R.string.default_download_label_name))
list.forEach {
add(it.label)
}
@@ -171,11 +163,7 @@ suspend fun DialogState.startDownload(
val label1 = if (selected == 0) null else items[selected].takeIf { DownloadManager.containLabel(it) }
// Start download
for (gi in toAdd) {
val intent = Intent(context, DownloadService::class.java)
intent.action = DownloadService.ACTION_START
intent.putExtra(DownloadService.KEY_LABEL, label1)
intent.putExtra(DownloadService.KEY_GALLERY_INFO, gi)
ContextCompat.startForegroundService(context, intent)
DownloadService.startDownload(gi, label1)
}
// Save settings
if (checked) {
@@ -184,17 +172,16 @@ suspend fun DialogState.startDownload(
} else {
Settings.hasDefaultDownloadLabel = false
}
with(context.findActivity<MainActivity>()) {
showTip(R.string.added_to_download_list)
}
tip(R.string.added_to_download_list)
}
}
suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
context(_: DialogState)
suspend fun modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
val localFavorited = EhDB.containLocalFavorites(galleryInfo.gid)
if (Settings.hasSignedIn.value) {
val isFavorited = galleryInfo.favoriteSlot != NOT_FAVORITED
val defaultFavSlot = Settings.defaultFavSlot
val defaultFavSlot = Settings.defaultFavSlot.value
if (defaultFavSlot == -2) {
val localFav = getFavoriteIcon(localFavorited) to appCtx.getString(R.string.local_favorites)
val cloudFav = Settings.favCat.mapIndexed { index, name ->
@@ -232,13 +219,17 @@ private suspend fun doModifyFavorites(
slot: Int = NOT_FAVORITED,
localFavorited: Boolean = true,
note: String = "",
): Boolean {
) = with(galleryInfo) {
val add = when (slot) {
NOT_FAVORITED -> { // Remove from cloud favorites first
if (galleryInfo.favoriteSlot > LOCAL_FAVORITED) {
EhEngine.modifyFavorites(galleryInfo.gid, galleryInfo.token)
} else if (localFavorited) {
if (favoriteSlot > LOCAL_FAVORITED) {
EhEngine.modifyFavorites(gid, token)
favoriteSlot = if (localFavorited) LOCAL_FAVORITED else NOT_FAVORITED
favoriteName = null
favoriteNote = null
} else {
EhDB.removeLocalFavorites(galleryInfo)
favoriteSlot = NOT_FAVORITED
}
false
}
@@ -249,30 +240,27 @@ private suspend fun doModifyFavorites(
} else {
EhDB.putLocalFavorites(galleryInfo)
}
// Keep cloud favorite slot
if (favoriteSlot == NOT_FAVORITED) {
favoriteSlot = LOCAL_FAVORITED
} else if (favoriteSlot == LOCAL_FAVORITED) {
favoriteSlot = NOT_FAVORITED
}
!localFavorited
}
in 0..9 -> {
EhEngine.modifyFavorites(galleryInfo.gid, galleryInfo.token, slot, note)
galleryInfo.favoriteNote = note
EhEngine.modifyFavorites(gid, token, slot, note)
favoriteSlot = slot
favoriteName = Settings.favCat[slot]
favoriteNote = note
true
}
else -> throw EhException("Invalid favorite slot!")
}
if (add) { // Cloud favorites have priority
if (slot != LOCAL_FAVORITED || galleryInfo.favoriteSlot == NOT_FAVORITED) {
galleryInfo.favoriteSlot = slot
galleryInfo.favoriteName = Settings.favCat.getOrNull(slot)
FavouriteStatusRouter.modifyFavourites(galleryInfo.gid, slot)
}
} else if (slot != LOCAL_FAVORITED || galleryInfo.favoriteSlot == LOCAL_FAVORITED) {
val newSlot = if (galleryInfo.favoriteSlot > LOCAL_FAVORITED && localFavorited) LOCAL_FAVORITED else NOT_FAVORITED
galleryInfo.favoriteSlot = newSlot
galleryInfo.favoriteName = null
FavouriteStatusRouter.modifyFavourites(galleryInfo.gid, newSlot)
}
return add
FavouriteStatusRouter.notify(galleryInfo)
add
}
suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorites(
@@ -280,13 +268,16 @@ suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorite
localFavorited = EhDB.containLocalFavorites(galleryInfo.gid),
)
fun DestinationsNavigator.navToReader(info: BaseGalleryInfo, page: Int = -1) = navToReader(ReaderScreenArgs.Gallery(info, page))
context(_: DestinationsNavigator)
fun navToReader(info: BaseGalleryInfo, page: Int = -1) = navToReader(ReaderScreenArgs.Gallery(info, page))
fun DestinationsNavigator.navToReader(uri: Uri) = navToReader(ReaderScreenArgs.Archive(uri))
context(_: DestinationsNavigator)
fun navToReader(path: String) = navToReader(ReaderScreenArgs.Archive(path))
private fun DestinationsNavigator.navToReader(args: ReaderScreenArgs) = navigate(ReaderScreenDestination(args)) { launchSingleTop = true }
context(nav: DestinationsNavigator)
private fun navToReader(args: ReaderScreenArgs) = nav.navigate(ReaderScreenDestination(args)) { launchSingleTop = true }
context(DialogState, Context, DestinationsNavigator)
context(_: DialogState, _: MainActivity, _: DestinationsNavigator)
suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
val downloaded = DownloadManager.getDownloadState(info.gid) != DownloadInfo.STATE_INVALID
val favorited = info.favoriteSlot != NOT_FAVORITED
@@ -309,46 +300,42 @@ suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
}
}
val selected = awaitSelectItemWithIcon(items, EhUtils.getSuitableTitle(info))
with(findActivity<MainActivity>()) {
when (selected) {
0 -> {
EhDB.putHistoryInfo(info)
navToReader(info)
}
1 -> withUIContext {
if (downloaded) {
confirmRemoveDownload(info)
} else {
startDownload(this@with, false, info)
}
}
2 -> if (favorited) {
runSuspendCatching {
removeFromFavorites(info)
showTip(R.string.remove_from_favorite_success)
}.onFailure {
showTip(R.string.remove_from_favorite_failure)
}
} else {
runSuspendCatching {
modifyFavorites(info)
showTip(R.string.add_to_favorite_success)
}.onFailure {
showTip(R.string.add_to_favorite_failure)
}
}
3 -> showMoveDownloadLabel(info)
when (selected) {
0 -> {
EhDB.putHistoryInfo(info)
navToReader(info)
}
true
1 -> if (downloaded) {
confirmRemoveDownload(info)
} else {
startDownload(false, info)
}
2 -> if (favorited) {
runSuspendCatching {
removeFromFavorites(info)
tip(R.string.remove_from_favorite_success)
}.onFailure {
tip(R.string.remove_from_favorite_failure)
}
} else {
runSuspendCatching {
modifyFavorites(info)
tip(R.string.add_to_favorite_success)
}.onFailure {
tip(R.string.add_to_favorite_failure)
}
}
3 -> showMoveDownloadLabel(info)
}
}
private const val MAX_FAVNOTE_CHAR = 200
private suspend fun DialogState.confirmRemoveDownload(text: String): Boolean {
context(_: DialogState)
private suspend fun confirmRemoveDownload(text: String): Boolean {
val checked = awaitResult(
initial = Settings.removeImageFiles,
title = R.string.download_remove_dialog_title,
@@ -372,7 +359,8 @@ private suspend fun DialogState.confirmRemoveDownload(text: String): Boolean {
return checked
}
suspend fun DialogState.confirmRemoveDownload(info: GalleryInfo) {
context(_: DialogState)
suspend fun confirmRemoveDownload(info: GalleryInfo) {
val text = appCtx.getString(R.string.download_remove_dialog_message, EhUtils.getSuitableTitle(info))
val checked = confirmRemoveDownload(text)
withIOContext {
@@ -380,7 +368,8 @@ suspend fun DialogState.confirmRemoveDownload(info: GalleryInfo) {
}
}
suspend fun DialogState.confirmRemoveDownloadRange(list: Collection<DownloadInfo>) {
context(_: DialogState)
suspend fun confirmRemoveDownloadRange(list: Collection<DownloadInfo>) {
val text = appCtx.getString(R.string.download_remove_dialog_message_2, list.size)
val checked = confirmRemoveDownload(text)
withIOContext {
@@ -399,7 +388,8 @@ suspend fun DialogState.confirmRemoveDownloadRange(list: Collection<DownloadInfo
}
}
suspend fun DialogState.showMoveDownloadLabel(info: GalleryInfo) {
context(_: DialogState)
suspend fun showMoveDownloadLabel(info: GalleryInfo) {
val defaultLabel = appCtx.getString(R.string.default_download_label_name)
val labels = buildList {
add(defaultLabel)
@@ -413,7 +403,8 @@ suspend fun DialogState.showMoveDownloadLabel(info: GalleryInfo) {
DownloadManager.changeLabel(listOf(downloadInfo), label)
}
suspend fun DialogState.showMoveDownloadLabelList(list: Collection<DownloadInfo>): String? {
context(_: DialogState)
suspend fun showMoveDownloadLabelList(list: Collection<DownloadInfo>): String? {
val defaultLabel = appCtx.getString(R.string.default_download_label_name)
val labels = buildList {
add(defaultLabel)
@@ -427,7 +418,8 @@ suspend fun DialogState.showMoveDownloadLabelList(list: Collection<DownloadInfo>
return label
}
suspend fun DialogState.awaitSelectDate(): String? {
context(_: DialogState)
suspend fun awaitSelectDate(): String? {
val initial = LocalDate(2007, 3, 21)
val yesterday = Clock.System.todayIn(TimeZone.UTC).minus(1, DateTimeUnit.DAY)
val initialMillis = initial.toEpochMillis()
@@ -443,3 +435,11 @@ suspend fun DialogState.awaitSelectDate(): String? {
val date = dateMillis?.run { toLocalDateTime().date.toString() }
return date
}
context(_: Context, _: DialogState)
suspend fun showRestartDialog() {
awaitConfirmationOrCancel {
Text(stringResource(R.string.settings_restart))
}
restartApplication()
}
@@ -2,14 +2,16 @@ package com.hippo.ehviewer.ui
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.hippo.ehviewer.ui.screen.implicit
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalGlobalDialogState
import com.hippo.ehviewer.ui.tools.NoopTransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.togetherWith
@@ -18,20 +20,25 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.coroutines.CoroutineScope
@Composable
inline fun <R> AnimatedVisibilityScope.composing(
context(scope: AnimatedVisibilityScope)
inline fun <R> Screen(
navigator: DestinationsNavigator,
block: @Composable context(MainActivity, SnackbarHostState, DialogState, SharedTransitionScope, TransitionsVisibilityScope, DestinationsNavigator, CoroutineScope)
() -> R,
) = with(NoopTransitionsVisibilityScope) {
togetherWith(implicit<AnimatedVisibilityScope>()) {
block(
with(LocalContext.current) { remember { findActivity() } },
LocalSnackBarHostState.current,
LocalDialogState.current,
LocalSharedTransitionScope.current,
this,
navigator,
rememberCoroutineScope(),
)
) = Box(modifier = Modifier.fillMaxSize()) {
val dialogState = with(LocalGlobalDialogState.current) { rememberLocal() }
with(NoopTransitionsVisibilityScope) {
togetherWith(scope) {
block(
with(LocalContext.current) { remember { findActivity() } },
LocalSnackBarHostState.current,
dialogState,
LocalSharedTransitionScope.current,
this,
navigator,
rememberCoroutineScope(),
)
dialogState.Place()
}
}
}
@@ -41,7 +41,7 @@ abstract class EhActivity : AppCompatActivity() {
override fun onResume() {
interceptSecurityOrReturn()
super.onResume()
window.setSecureScreen(Settings.enabledSecurity)
window.setSecureScreen(Settings.enabledSecurity.value)
}
override fun finish() {
@@ -38,8 +38,8 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
private const val INDEX_URL = 2
private const val INDEX_PARENT = 9
private fun GalleryDetail.generateContent() = arrayOf(
R.string.key_gid to gid.toString(),
private fun GalleryDetail.content() = arrayOf(
R.string.key_gid to "$gid",
R.string.key_token to token,
R.string.key_url to EhUrl.getGalleryDetailUrl(gid, token),
R.string.key_title to title,
@@ -58,12 +58,12 @@ private fun GalleryDetail.generateContent() = arrayOf(
R.string.key_rating_count to ratingCount.toString(),
R.string.key_rating to rating.toString(),
R.string.key_torrents to torrentCount.toString(),
R.string.key_torrent_url to torrentUrl,
R.string.key_torrent_url to EhUrl.getTorrentUrl(gid, token),
R.string.favorite_name to favoriteName,
)
context(Context, DestinationsNavigator)
@Composable
context(_: Context, _: DestinationsNavigator)
fun GalleryInfoBottomSheet(detail: GalleryDetail) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
@@ -71,7 +71,7 @@ fun GalleryInfoBottomSheet(detail: GalleryDetail) {
modifier = Modifier.align(Alignment.CenterHorizontally),
style = MaterialTheme.typography.titleLarge,
)
val data = remember(detail) { detail.generateContent() }
val data = remember(detail) { detail.content() }
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
LazyColumn(contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues()) {
itemsIndexed(data) { index, (key, content) ->
@@ -25,12 +25,11 @@ import android.content.pm.verify.domain.DomainVerificationUserState.DOMAIN_STATE
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
@@ -42,7 +41,6 @@ import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
@@ -54,16 +52,13 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Whatshot
import androidx.compose.material3.DrawerDefaults
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerState2
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.ModalSideDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
@@ -78,7 +73,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.currentCompositeKeyHash
import androidx.compose.runtime.currentCompositeKeyHashCode
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
@@ -105,6 +100,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.navigation.compose.rememberNavController
@@ -135,9 +131,9 @@ import com.hippo.ehviewer.ui.screen.navWithUrl
import com.hippo.ehviewer.ui.settings.showNewVersion
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.NoopSharedTransitionScope
import com.hippo.ehviewer.ui.tools.awaitConfirmationOrCancel
import com.hippo.ehviewer.ui.tools.awaitInputText
import com.hippo.ehviewer.updater.AppUpdater
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.addTextToClipboard
@@ -177,19 +173,7 @@ private val navItems = arrayOf<Triple<Direction, Int, ImageVector>>(
Triple(SettingsScreenDestination, R.string.settings, Icons.Default.Settings),
)
val StartDestination
get() = navItems[Settings.launchPage].first
class MainActivity : EhActivity() {
private val sideSheet = mutableStateListOf<@Composable ColumnScope.(DrawerState2) -> Unit>()
@Composable
fun ProvideSideSheetContent(content: @Composable ColumnScope.(DrawerState2) -> Unit) {
DisposableEffect(content) {
sideSheet.add(0, content)
onDispose { sideSheet.remove(content) }
}
}
private var shareUrl: String? = null
@@ -219,7 +203,6 @@ class MainActivity : EhActivity() {
super.onCreate(savedInstanceState)
setMD3Content {
val configuration = LocalConfiguration.current
val dialogState = LocalDialogState.current
val navDrawerState = rememberDrawerState(DrawerValue.Closed)
val sideSheetState = rememberDrawerState2(DrawerValue.Closed)
val snackbarState = remember { SnackbarHostState() }
@@ -252,14 +235,12 @@ class MainActivity : EhActivity() {
if (!AppConfig.isBenchmark) {
val noNetwork = stringResource(R.string.no_network)
LaunchedEffect(Unit) {
runCatching { dialogState.checkDownloadLocation() }
runCatching { dialogState.checkAppLinkVerify() }
runCatching { checkDownloadLocation() }
runCatching { checkAppLinkVerify() }
if (hasNetwork) {
runSuspendCatching {
withIOContext {
AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
}
AppUpdater.checkForUpdate()?.let { showNewVersion(it) }
}
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
@@ -274,34 +255,32 @@ class MainActivity : EhActivity() {
LaunchedEffect(Unit) {
intentFlow.collect { intent ->
when (intent.action) {
Intent.ACTION_VIEW -> {
Intent.ACTION_VIEW -> with(navigator) {
val uri = intent.data ?: return@collect
when (uri.scheme) {
SCHEME_CONTENT, SCHEME_FILE -> {
navigator.navToReader(uri)
}
SCHEME_FILE -> navToReader(uri.path!!)
SCHEME_CONTENT -> navToReader(uri.toString())
else -> {
val url = uri.toString()
if (!navigator.navWithUrl(url)) {
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
if (!navWithUrl(url)) {
val new = awaitInputText(initial = url, title = cannotParse)
addTextToClipboard(new)
}
}
}
}
Intent.ACTION_SEND -> {
Intent.ACTION_SEND -> with(navigator) {
val type = intent.type
if ("text/plain" == type) {
val keyword = intent.getStringExtra(Intent.EXTRA_TEXT)
if (keyword != null && !navigator.navWithUrl(keyword)) {
navigator.navigate(ListUrlBuilder(mKeyword = keyword).asDst())
if (keyword != null && !navWithUrl(keyword)) {
navigate(ListUrlBuilder(mKeyword = keyword).asDst())
}
} else if (type != null && type.startsWith("image/")) {
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
if (null != uri) {
val hash = withIOContext { uri.toOkioPath().sha1() }
navigator.navigate(
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
hash = hash,
@@ -371,12 +350,13 @@ class MainActivity : EhActivity() {
onPauseOrDispose { job.cancel() }
}
val currentDestination by navController.currentDestinationAsState()
val drawerHandle = remember { mutableStateListOf<Int>() }
val drawerHandle = remember { mutableStateListOf<Long>() }
var snackbarFabPadding by remember { mutableStateOf(0.dp) }
val drawerEnabled = drawerHandle.isNotEmpty()
val density = LocalDensity.current
val adaptiveInfo = currentWindowAdaptiveInfo()
val needSignIn by Settings.needSignIn.collectAsState()
val launchPage by Settings.launchPage.collectAsState()
CompositionLocalProvider(
LocalNavDrawerState provides navDrawerState,
LocalSideSheetState provides sideSheetState,
@@ -441,25 +421,14 @@ class MainActivity : EhActivity() {
drawerState = navDrawerState,
gesturesEnabled = drawerEnabled && sideSheetState.isClosed || navDrawerState.isOpen,
) {
val sheet = sideSheet.firstOrNull()
val radius by remember {
snapshotFlow {
val step = calculateFraction(minOffset, 0f, navDrawerState.currentOffset)
with(density) { lerp(0, 10, step).dp.toPx() }
}
}.collectAsState(0f)
ModalSideDrawer(
drawerContent = {
if (sheet != null) {
ModalDrawerSheet(
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End),
) {
sheet(sideSheetState)
}
}
},
MutableSideSheet(
drawerState = sideSheetState,
modifier = Modifier.graphicsLayer {
if (radius != 0f) {
renderEffect = BlurEffect(radius, radius, TileMode.Clamp)
@@ -467,25 +436,23 @@ class MainActivity : EhActivity() {
clip = true
}
},
drawerState = sideSheetState,
gesturesEnabled = sheet != null && drawerEnabled,
enabled = drawerEnabled,
) {
// https://issuetracker.google.com/336140982
// SharedTransitionLayout {
CompositionLocalProvider(LocalSharedTransitionScope provides NoopSharedTransitionScope) {
val start = when {
needSignIn -> SignInScreenDestination
hasNetwork -> StartDestination
else -> DownloadsScreenDestination
SharedTransitionLayout {
CompositionLocalProvider(LocalSharedTransitionScope provides this) {
val start = when {
needSignIn -> SignInScreenDestination
hasNetwork -> navItems[launchPage].first
else -> DownloadsScreenDestination
}
DestinationsNavHost(
navGraph = NavGraphs.root,
start = start,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
}
DestinationsNavHost(
navGraph = NavGraphs.root,
start = start,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
}
// }
}
}
}
@@ -535,13 +502,13 @@ class MainActivity : EhActivity() {
try {
val intent = Intent(
android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS,
Uri.parse("package:$packageName"),
"package:$packageName".toUri(),
)
startActivity(intent)
} catch (_: Throwable) {
val intent = Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName"),
"package:$packageName".toUri(),
)
startActivity(intent)
}
@@ -549,8 +516,7 @@ class MainActivity : EhActivity() {
}
}
fun showTip(@StringRes id: Int, useToast: Boolean = false) {
val message = getString(id)
fun showTip(message: String, useToast: Boolean = false) {
if (useToast || !tipFlow.tryEmit(message)) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
@@ -558,13 +524,13 @@ class MainActivity : EhActivity() {
override fun onProvideAssistContent(outContent: AssistContent?) {
super.onProvideAssistContent(outContent)
shareUrl?.let { outContent?.webUri = Uri.parse(shareUrl) }
shareUrl?.let { outContent?.webUri = it.toUri() }
}
}
val LocalNavDrawerState = compositionLocalOf<DrawerState> { error("CompositionLocal LocalNavDrawerState not present!") }
val LocalSideSheetState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalSideSheetState not present!") }
val LocalDrawerHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalDrawerHandle not present!") }
val LocalDrawerHandle = compositionLocalOf<SnapshotStateList<Long>> { error("CompositionLocal LocalDrawerHandle not present!") }
val LocalSnackBarHostState = compositionLocalOf<SnackbarHostState> { error("CompositionLocal LocalSnackBarHostState not present!") }
val LocalSnackBarFabPadding = compositionLocalOf<State<Dp>> { error("CompositionLocal LocalSnackBarFabPadding not present!") }
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> { error("CompositionLocal LocalSharedTransitionScope not present!") }
@@ -572,7 +538,7 @@ val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> { err
@Composable
fun DrawerHandle(enabled: Boolean) {
if (enabled) {
val current = currentCompositeKeyHash
val current = currentCompositeKeyHashCode
val handle = LocalDrawerHandle.current
DisposableEffect(current) {
handle.add(current)
@@ -0,0 +1,64 @@
package com.hippo.ehviewer.ui
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.DrawerState2
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalSideDrawer
import androidx.compose.material3.ShapeDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.dp
private typealias Sheet = @Composable ColumnScope.(DrawerState2) -> Unit
val LocalSideSheetContainer = staticCompositionLocalOf<SnapshotStateList<Sheet>> { error("") }
val LocalSideSheetState = staticCompositionLocalOf<DrawerState2> { error("") }
@Composable
fun ProvideSideSheetContent(content: Sheet) {
val container = LocalSideSheetContainer.current
DisposableEffect(content) {
container.add(0, content)
onDispose { container.remove(content) }
}
}
@Composable
fun MutableSideSheet(drawerState: DrawerState2, modifier: Modifier, enabled: Boolean, content: @Composable () -> Unit) {
val sheet = remember { mutableStateListOf<Sheet>() }
val f = sheet.firstOrNull()
val width = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.width.toDp() }
CompositionLocalProvider(LocalSideSheetContainer provides sheet) {
ModalSideDrawer(
drawerContent = {
if (f != null) {
ModalDrawerSheet(
modifier = Modifier.widthIn(max = width - 112.dp),
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End),
) {
f(drawerState)
}
}
},
modifier = modifier,
drawerState = drawerState,
gesturesEnabled = f != null && enabled,
content = content,
)
}
}
@@ -21,80 +21,49 @@ package com.hippo.ehviewer.ui
import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.AuthenticationRequest.Biometric
import androidx.biometric.AuthenticationRequest.Companion.biometricRequest
import androidx.biometric.AuthenticationResult
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.biometric.auth.AuthPromptCallback
import androidx.biometric.auth.startClass2BiometricOrCredentialAuthentication
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.biometric.registerForAuthenticationResult
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
fun Context.isAuthenticationSupported(): Boolean {
context(ctx: Context)
fun isAuthenticationSupported(): Boolean {
val authenticators = BiometricManager.Authenticators.BIOMETRIC_WEAK or DEVICE_CREDENTIAL
return BiometricManager.from(this).canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS
return BiometricManager.from(ctx).canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS
}
class SecurityActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
if (isAuthenticationSupported()) {
startAuthentication(getString(R.string.settings_privacy_require_unlock))
startAuthentication()
} else {
onSuccess()
}
}
@Synchronized
private fun startAuthentication(
title: String,
subtitle: String? = null,
confirmationRequired: Boolean = true,
) {
if (isAuthenticating) return
isAuthenticating = true
startClass2BiometricOrCredentialAuthentication(
title = title,
subtitle = subtitle,
confirmationRequired = confirmationRequired,
executor = ContextCompat.getMainExecutor(this),
callback = callback(
{ _, _, _ -> onError() },
{ _, _ -> onSuccess() },
{ },
),
)
private val authLauncher = registerForAuthenticationResult { result ->
when (result) {
is AuthenticationResult.Success -> onSuccess()
is AuthenticationResult.Error -> onError()
}
}
inline fun callback(
crossinline onAuthenticationError: (
activity: FragmentActivity?,
errorCode: Int,
errString: CharSequence,
) -> Unit,
crossinline onAuthenticationSucceeded: (
activity: FragmentActivity?,
result: BiometricPrompt.AuthenticationResult,
) -> Unit,
crossinline onAuthenticationFailed: (
activity: FragmentActivity?,
) -> Unit,
) = object : AuthPromptCallback() {
override fun onAuthenticationError(
activity: FragmentActivity?,
errorCode: Int,
errString: CharSequence,
) = onAuthenticationError(activity, errorCode, errString)
override fun onAuthenticationSucceeded(
activity: FragmentActivity?,
result: BiometricPrompt.AuthenticationResult,
) = onAuthenticationSucceeded(activity, result)
override fun onAuthenticationFailed(activity: FragmentActivity?) = onAuthenticationFailed(activity)
@Synchronized
private fun startAuthentication() {
if (isAuthenticating) return
isAuthenticating = true
val request = biometricRequest(
title = getString(R.string.settings_privacy_require_unlock),
authFallback = Biometric.Fallback.DeviceCredential,
) {}
authLauncher.launch(request)
}
private fun onSuccess() {
@@ -125,7 +94,7 @@ val lockObserver = object : DefaultLifecycleObserver {
fun Context.interceptSecurityOrReturn() {
val lockedResumeTime = System.currentTimeMillis() / 1000
val lockedDelayTime = lockedResumeTime - lockedLastLeaveTime
if (lockedDelayTime < Settings.securityDelay * 60) {
if (lockedDelayTime < Settings.securityDelay.value * 60) {
locked = false
} else if (Settings.security.value && isAuthenticationSupported() && locked) {
startActivity(Intent(this, SecurityActivity::class.java))
@@ -6,26 +6,40 @@ import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation.NavBackStackEntry
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.LocalGlobalDialogState
import com.hippo.ehviewer.ui.tools.ProvideVectorPainterCache
import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle
import me.zhanghai.compose.preference.ProvidePreferenceTheme
import me.zhanghai.compose.preference.preferenceTheme
import soup.compose.material.motion.animation.materialSharedAxisXIn
import soup.compose.material.motion.animation.materialSharedAxisXOut
import soup.compose.material.motion.animation.rememberSlideDistance
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable DialogState.() -> Unit) = setContent {
EhTheme(useDarkTheme = isSystemInDarkTheme()) {
ProvideVectorPainterCache {
CompositionLocalProvider(
LocalDialogState provides remember { DialogState() }.apply { Intercept() },
) {
content()
val theme = preferenceTheme(
iconColor = MaterialTheme.colorScheme.primary,
titleTextStyle = MaterialTheme.typography.titleMedium,
)
ProvidePreferenceTheme(theme) {
ProvideVectorPainterCache {
val dialogState = remember { DialogState() }
CompositionLocalProvider(LocalGlobalDialogState provides dialogState) {
Box(modifier = Modifier.fillMaxSize()) {
content(dialogState)
dialogState.Place()
}
}
}
}
}
@@ -17,9 +17,9 @@ package com.hippo.ehviewer.ui
import android.content.ActivityNotFoundException
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
@@ -27,27 +27,23 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
private val intent = CustomTabsIntent.Builder().apply { setShowTitle(true) }.build()
fun Context.openBrowser(url: String) {
context(ctx: Context)
fun openBrowser(url: String) {
if (url.isEmpty()) return
try {
intent.launchUrl(this, Uri.parse(url))
intent.launchUrl(ctx, url.toUri())
} catch (e: ActivityNotFoundException) {
Toast.makeText(this, R.string.no_browser_installed, Toast.LENGTH_LONG).show()
Toast.makeText(ctx, R.string.no_browser_installed, Toast.LENGTH_LONG).show()
}
}
fun DestinationsNavigator.jumpToReaderByPage(url: String, detail: GalleryDetail): Boolean {
context(_: DestinationsNavigator)
fun jumpToReaderByPage(url: String, detail: GalleryDetail): Boolean {
GalleryPageUrlParser.parse(url)?.let {
if (it.gid == detail.gid) {
navToReader(detail.galleryInfo, it.page)
return true
}
}
if (url.startsWith("#c")) {
runCatching {
navToReader(detail.galleryInfo, url.replace("#c", "").toInt() - 1)
return true
}
}
return false
}
@@ -0,0 +1,40 @@
package com.hippo.ehviewer.ui.legacy
import android.graphics.drawable.DrawableWrapper
import android.text.Html
import coil3.asDrawable
import coil3.decode.DecodeUtils
import coil3.imageLoader
import coil3.request.crossfade
import coil3.size.Scale
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.util.toIntOrDefault
import kotlin.math.roundToInt
import splitties.init.appCtx
private val UrlRegex = "(?:[^-]+-){2}(\\d+)-(\\d+)-[^_]+_([^.]+)".toRegex()
class CoilImageGetter(private val onSuccess: () -> Unit) : Html.ImageGetter {
override fun getDrawable(source: String) = object : DrawableWrapper(null) {}.apply {
UrlRegex.find(source)?.run {
val srcWidth = groupValues[1].toInt()
val srcHeight = groupValues[2].toInt()
val dstWidth = groupValues[3].toIntOrDefault(200)
val dstHeight = dstWidth / 2 * 3
val multiplier = DecodeUtils.computeSizeMultiplier(srcWidth, srcHeight, dstWidth, dstHeight, Scale.FIT)
setBounds(0, 0, (srcWidth * multiplier).roundToInt(), (srcHeight * multiplier).roundToInt())
} ?: setBounds(0, 0, 200, 300)
with(appCtx) {
imageLoader.enqueue(
imageRequest {
data(source)
crossfade(false)
target { image ->
setDrawable(image.asDrawable(resources))
onSuccess()
}
},
)
}
}
}
@@ -0,0 +1,54 @@
/*
* Copyright 2015 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.ui.legacy
import android.annotation.SuppressLint
import android.content.Context
import android.text.Spanned
import android.text.style.URLSpan
import android.view.MotionEvent
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.getSpans
@SuppressLint("ViewConstructor")
class LinkifyTextView(
context: Context,
private val onUrlClick: (String) -> Unit,
) : AppCompatTextView(context) {
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
// Let the parent or grandparent of TextView to handles click aciton.
// Otherwise click effect like ripple will not work, and if touch area
// do not contain a url, the TextView will still get MotionEvent.
// onTouchEven must be called with MotionEvent.ACTION_DOWN for each touch
// action on it, so we analyze touched url here.
if (event.action == MotionEvent.ACTION_DOWN) {
(text as? Spanned)?.let { text ->
layout?.let { layout ->
val x = event.x + scrollX - totalPaddingLeft
val y = event.y + scrollY - totalPaddingTop
val line = layout.getLineForVertical(y.toInt())
val off = layout.getOffsetForHorizontal(line, x)
val spans = text.getSpans<URLSpan>(off, off)
if (spans.isNotEmpty()) {
onUrlClick(spans[0].url)
}
}
}
}
return super.onTouchEvent(event)
}
}
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.login
import arrow.core.Either.Companion.catch
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
@@ -11,39 +12,43 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
suspend fun CoroutineScope.refreshAccountInfo() = runSuspendCatching {
suspend fun CoroutineScope.refreshAccountInfo() = catch {
with(EhEngine.getProfile()) {
Settings.displayName.value = displayName
Settings.avatar.value = avatar
}
}.onFailure {
}.onLeft {
logcat(it)
}
@OptIn(DelicateCoroutinesApi::class)
fun postLogin() = GlobalScope.async(Dispatchers.IO) {
launch { refreshAccountInfo() }
runCatching {
// For the `star` cookie
// For the `star` cookie
catch {
EhEngine.getNews(false)
}.onLeft {
logcat(it)
}
// Get cookies for image limits
launch {
runCatching {
EhEngine.getUConfig(EhUrl.URL_UCONFIG_E)
EhCookieStore.flush()
}.onFailure {
logcat(it)
}
// Get cookies for image limits
launch {
catch {
EhEngine.getUConfig(EhUrl.URL_UCONFIG_E)
EhCookieStore.flush()
}.onLeft {
logcat(it)
}
}
// Sad panda check
// Sad panda check
catch {
EhEngine.getUConfig(EhUrl.URL_UCONFIG_EX)
EhCookieStore.flush()
Settings.gallerySite.value = EhUrl.SITE_EX
}.onFailure {
}.onLeft {
Settings.gallerySite.value = EhUrl.SITE_E
}
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.login
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -18,17 +19,18 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextObfuscationMode
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedSecureTextField
import androidx.compose.material3.OutlinedTextField
@@ -38,17 +40,17 @@ import androidx.compose.runtime.Composable
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
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
@@ -56,30 +58,32 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowWidthSizeClass
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.Screen
import com.hippo.ehviewer.ui.destinations.WebViewSignInScreenDestination
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.autofill
import com.hippo.ehviewer.ui.tools.awaitConfirmationOrCancel
import com.hippo.ehviewer.ui.tools.isExpanded
import com.hippo.ehviewer.ui.tools.thenIf
import com.hippo.ehviewer.util.displayString
import com.jamal.composeprefs3.ui.ifTrueThen
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.Job
import moe.tarsin.launchIO
import moe.tarsin.navigate
@Destination<RootGraph>(start = true)
@Composable
fun SignInScreen(navigator: DestinationsNavigator) {
fun AnimatedVisibilityScope.SignInScreen(navigator: DestinationsNavigator) = Screen(navigator) {
val windowSizeClass = LocalWindowSizeClass.current
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
var isProgressIndicatorVisible by rememberSaveable { mutableStateOf(false) }
var showUsernameError by rememberSaveable { mutableStateOf(false) }
@@ -87,9 +91,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
val username = rememberTextFieldState()
val password = rememberTextFieldState()
var passwordHidden by rememberSaveable { mutableStateOf(true) }
val context = LocalContext.current
var signInJob by remember { mutableStateOf<Job?>(null) }
val dialogState = LocalDialogState.current
fun signIn() {
if (signInJob?.isActive == true) return
@@ -109,14 +111,14 @@ fun SignInScreen(navigator: DestinationsNavigator) {
isProgressIndicatorVisible = true
EhUtils.signOut()
signInJob = coroutineScope.launchIO {
signInJob = launchIO {
runCatching {
EhEngine.signIn(username.text.toString(), password.text.toString())
}.onFailure {
withUIContext {
focusManager.clearFocus()
isProgressIndicatorVisible = false
dialogState.awaitConfirmationOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.get_it,
title = R.string.sign_in_failed,
showCancelButton = false,
@@ -140,32 +142,30 @@ fun SignInScreen(navigator: DestinationsNavigator) {
fun UsernameAndPasswordTextField() {
OutlinedTextField(
state = username,
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)).autofill(
autofillTypes = listOf(AutofillType.Username),
onFill = { username.setTextAndPlaceCursorAtEnd(it) },
),
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width))
.semantics { contentType = ContentType.Username }
.thenIf(!showUsernameError) { padding(bottom = 16.dp) },
label = { Text(stringResource(R.string.username)) },
supportingText = { if (showUsernameError) Text(stringResource(R.string.error_username_cannot_empty)) },
trailingIcon = { if (showUsernameError) Icon(imageVector = Icons.Filled.Info, contentDescription = null) },
supportingText = showUsernameError.ifTrueThen { Text(stringResource(R.string.error_username_cannot_empty)) },
trailingIcon = showUsernameError.ifTrueThen { Icon(imageVector = Icons.Filled.Info, contentDescription = null) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
lineLimits = TextFieldLineLimits.SingleLine,
isError = showUsernameError,
)
OutlinedSecureTextField(
state = password,
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)).autofill(
autofillTypes = listOf(AutofillType.Password),
onFill = { password.setTextAndPlaceCursorAtEnd(it) },
),
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width))
.semantics { contentType = ContentType.Password }
.thenIf(!showPasswordError) { padding(bottom = 16.dp) },
label = { Text(stringResource(R.string.password)) },
supportingText = { if (showPasswordError) Text(stringResource(R.string.error_password_cannot_empty)) },
supportingText = showPasswordError.ifTrueThen { Text(stringResource(R.string.error_password_cannot_empty)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
onKeyboardAction = { signIn() },
trailingIcon = {
if (showPasswordError) {
Icon(imageVector = Icons.Filled.Info, contentDescription = null)
} else {
IconButton(onClick = { passwordHidden = !passwordHidden }) {
IconButton(onClick = { passwordHidden = !passwordHidden }, shapes = IconButtonDefaults.shapes()) {
val visibilityIcon = if (passwordHidden) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
Icon(imageVector = visibilityIcon, contentDescription = null)
}
@@ -177,8 +177,8 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}
Box(contentAlignment = Alignment.Center) {
when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.COMPACT, WindowWidthSizeClass.MEDIUM -> {
when {
!windowSizeClass.isExpanded -> {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).systemBarsPadding().padding(dimensionResource(id = R.dimen.keyline_margin)),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -202,13 +202,15 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.keyline_margin)))
Row(modifier = Modifier.padding(top = dimensionResource(R.dimen.keyline_margin))) {
FilledTonalButton(
onClick = { context.openBrowser(EhUrl.URL_REGISTER) },
onClick = { openBrowser(EhUrl.URL_REGISTER) },
shapes = ButtonDefaults.shapes(),
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
) {
Text(text = stringResource(id = R.string.register))
}
Button(
onClick = ::signIn,
shapes = ButtonDefaults.shapes(),
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
) {
Text(text = stringResource(id = R.string.sign_in))
@@ -216,7 +218,8 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}
Row(modifier = Modifier.padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = { navigator.navigate(WebViewSignInScreenDestination) },
onClick = { navigate(WebViewSignInScreenDestination) },
shapes = ButtonDefaults.shapes(),
modifier = Modifier.weight(1f),
) {
Text(
@@ -234,6 +237,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Settings.gallerySite.value = EhUrl.SITE_E
Settings.needSignIn.value = false
},
shapes = ButtonDefaults.shapes(),
modifier = Modifier.weight(1f),
) {
Text(
@@ -249,7 +253,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}
}
}
WindowWidthSizeClass.EXPANDED -> {
else -> {
Row(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).systemBarsPadding().padding(dimensionResource(id = R.dimen.keyline_margin)),
verticalAlignment = Alignment.CenterVertically,
@@ -284,12 +288,14 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Row(horizontalArrangement = Arrangement.Center) {
Button(
onClick = ::signIn,
shapes = ButtonDefaults.shapes(),
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
) {
Text(text = stringResource(id = R.string.sign_in))
}
FilledTonalButton(
onClick = { context.openBrowser(EhUrl.URL_REGISTER) },
onClick = { openBrowser(EhUrl.URL_REGISTER) },
shapes = ButtonDefaults.shapes(),
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
) {
Text(text = stringResource(id = R.string.register))
@@ -298,7 +304,8 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.Center) {
TextButton(
onClick = { navigator.navigate(WebViewSignInScreenDestination) },
onClick = { navigate(WebViewSignInScreenDestination) },
shapes = ButtonDefaults.shapes(),
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
) {
Text(
@@ -316,6 +323,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Settings.gallerySite.value = EhUrl.SITE_E
Settings.needSignIn.value = false
},
shapes = ButtonDefaults.shapes(),
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
) {
Text(
@@ -334,7 +342,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}
}
if (isProgressIndicatorVisible) {
CircularProgressIndicator()
CircularWavyProgressIndicator()
}
}
}
@@ -1,49 +1,49 @@
package com.hippo.ehviewer.ui.login
import android.webkit.WebView
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import com.google.accompanist.web.AccompanistWebViewClient
import com.google.accompanist.web.WebView
import com.google.accompanist.web.rememberWebViewState
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.Screen
import com.hippo.ehviewer.util.bgWork
import com.hippo.ehviewer.util.setDefaultSettings
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
@Destination<RootGraph>
@Composable
fun AnimatedVisibilityScope.WebViewSignInScreen(navigator: DestinationsNavigator) = composing(navigator) {
fun AnimatedVisibilityScope.WebViewSignInScreen(navigator: DestinationsNavigator) = Screen(navigator) {
val state = rememberWebViewState(url = EhUrl.URL_SIGN_IN)
val client = remember {
object : AccompanistWebViewClient() {
override fun onPageFinished(view: WebView, url: String?) {
LaunchedEffect(state) {
snapshotFlow { !state.isLoading }.collect { hasFinished ->
if (hasFinished) {
if (EhCookieStore.isCloudflareBypassed()) {
Settings.desktopSite.value = false
}
if (EhCookieStore.hasSignedIn()) {
postLogin()
view.destroy()
launch { bgWork { awaitCancellation() } }
state.webView?.destroy()
bgWork { awaitCancellation() }
}
}
}
}
SideEffect {
EhUtils.signOut()
}
WebView(
state = state,
modifier = Modifier.fillMaxSize(),
onCreated = { it.setDefaultSettings() },
client = client,
onCreated = {
EhUtils.signOut()
it.setDefaultSettings()
},
)
}
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff. Show more