Compare commits

...
122 Commits
Author SHA1 Message Date
FooIbar dd2949638c Prepare for release 1.12.1
Releases / build (push) Has been cancelled
2024-09-28 22:57:17 +08:00
FooIbar f71aa4902b Prepare changelog for 1.12.1 (#1691)
[no ci]
2024-09-28 22:56:20 +08:00
FooIbar af7e411932 Refine GalleryMultiPageViewerPTokenParser 2024-09-28 00:01:21 +08:00
FooIbar c54ac1a4c0 Fix crash when download location is invalid 2024-09-27 19:57:47 +08:00
renovate[bot] 00d6a2e403 Update AGP to v8.8.0-alpha04 2024-09-27 02:08:47 +00:00
FooIbar b67d0938b0 Reapply "Add scroll bar to quick search and download label"
This reverts commit a52ab28534.
2024-09-26 19:12:46 +08:00
FooIbar 8447760cb5 Fix image download queue not respecting FIFO order 2024-09-25 22:58:36 +08:00
FooIbar 24d2072586 Use Downloads as start screen if device is offline (#1675) 2024-09-25 03:54:34 +00:00
renovate[bot] a52ab28534 Revert "Add scroll bar to quick search and download label"
This reverts commit 601713d8d7.
2024-09-25 08:25:04 +08:00
FooIbar 601713d8d7 Add scroll bar to quick search and download label 2024-09-25 07:38:20 +08:00
FooIbar 7bfa5be53c Fix concurrent creation of the same directory 2024-09-24 21:36:42 +08:00
renovate[bot] ae3a66903a Update dependency gradle to v8.10.2 2024-09-24 02:16:59 +00:00
renovate[bot] 5438fd5d9b Update AGP to v8.8.0-alpha03 2024-09-23 21:59:33 +00:00
FooIbar a74ffca03d Fix galleries with both artist and cosplayer tags
Legacy issue of E-Hentai's tag namespacing changes.
2024-09-24 00:38:24 +08:00
renovate[bot] c5a83d567e Update dependency libarchive to v3.7.6 2024-09-23 13:40:44 +00:00
FooIbar 1b1f781dd9 Update renovate config 2024-09-23 21:32:34 +08:00
renovate[bot] 988195e4fc Lock file maintenance 2024-09-23 00:49:24 +00:00
FooIbar 4d4124458d Fix reader prefetch when scroll direction changes 2024-09-22 16:19:24 +08:00
FooIbar 0ae2843676 Actually fix data import on Android V
This reverts commit 26921c8a
2024-09-21 23:10:50 +08:00
FooIbar beaddc9eeb Fix foreign key constraint violation on db import 2024-09-21 23:10:50 +08:00
FooIbar d843b3a14b Try to reduce false positives in ad detection 2024-09-21 20:05:01 +08:00
Dude so hot b9da0eda8b Cleanup 2024-09-20 20:01:49 +08:00
Dude so hot ba75cda036 Move navigation to use compose Rect 2024-09-20 20:01:49 +08:00
Dude so hot 9d59af29c5 Singleton Navigation 2024-09-20 20:01:49 +08:00
FooIbar 660d6e32f1 Cleanup 2024-09-20 19:50:13 +08:00
FooIbar c07c663105 Address deprecation 2024-09-20 19:50:13 +08:00
FooIbar 3673f6f7f9 Rename awaitPermissionOrCancel -> awaitConfirmationOrCancel 2024-09-20 19:50:13 +08:00
renovate[bot]andDude so hot e8120e12d0 ADT Page model (#1655)
* Enable when guards

* ADT Page model

* Refine

* Refine

* Remove type conversion

* Cleanup

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-09-20 16:19:59 +08:00
renovate[bot] f7459886b2 Update kotlinx.serialization to v1.7.3 2024-09-19 20:07:38 +00:00
FooIbar 6fe5319c08 Update Kotlin & KSP to 2.1.0-Beta1 2024-09-19 23:57:48 +08:00
FooIbar 3a42208a36 Merge GalleryPreviewScreen to GalleryDetailScreen (#1649) 2024-09-19 23:39:42 +08:00
FooIbar e4eacce5bf Update Compose BOM 2024-09-19 23:02:04 +08:00
FooIbar d1fdea8d3e Update libarchive to v3.7.5 2024-09-19 22:57:38 +08:00
98e452e39e Fastpath to create regioned hardware bitmap (#1647)
* kotlin side

* Impl

* Refine

* respect ahb align

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

* Don't copy unnecessary things

* Cleanup

* Recycle

* Revert "Disable `abortOnError` for lint in debug build"

This reverts commit cea06f0c

* Fix marshmallow build

* Fix cmake

* Address cr

* Refine

* Cleanup

* log it

---------

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-09-19 14:25:55 +00:00
FooIbar 02e8fcc534 Fix lint
This reverts commit cea06f0c
2024-09-19 21:54:28 +08:00
renovate[bot]andDude so hot 70852c2725 Crop bitmap before draw (#1646)
* Crop bitmap before draw

* Spilt

* Refine

* Cleanup

* Address code review

* Address code review

* Address code review

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-09-19 07:56:24 +00:00
renovate[bot] ea67d6e078 Update benchmark to v1.4.0-alpha01 2024-09-18 22:15:18 +00:00
renovate[bot] d2aa3b5347 Update androidx.room to v2.7.0-alpha08 2024-09-18 22:14:40 +00:00
renovate[bot] 937792ba91 Update dependency androidx.work:work-runtime to v2.10.0-alpha04 2024-09-18 18:53:24 +00:00
renovate[bot] fcfdaf59f8 Update dependency androidx.lifecycle:lifecycle-process to v2.9.0-alpha03 2024-09-18 17:22:46 +00:00
renovate[bot] 0d4da998a1 Update dependency androidx.profileinstaller:profileinstaller to v1.4.0 2024-09-18 17:21:32 +00:00
FooIbar 0e7a32d9af [Marshmallow] Add ISRG Root X1 as a trusted root 2024-09-18 17:54:41 +08:00
FooIbar 4bb6b0baf6 Switch to rxing for QR code detection 2024-09-16 17:54:14 +08:00
renovate[bot] 0591b3224f Lock file maintenance 2024-09-16 00:10:00 +00:00
renovate[bot]andDude so hot eaf8dd21ff Crop border in Rust (#1631)
* Fix deref_mut_direct_bytebuffer lifetime

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

* Framework

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

* Add Infra

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

* Implement

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

* Refine

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

* Impl

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

* Fixes

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

* Implement

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

* Update profile code

* Remove corrent but slow code that slow for 1000x

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

* Revert "Remove corrent but slow code that slow for 1000x"

This reverts commit 644d093ddc20f4a129ee304157767707337d149d.

* Fix speed

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

* refine

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

* refine

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

* Refine

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

* cleanup type conversion

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>

* Remove C cropborder impl

* impl nth to accel

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

* F**K

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-09-15 22:20:55 +08:00
Dude so hot 9bc6cccf26 buffer share same lifetime with jnienv
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot eb3b53ee5b Format
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot 731af586af Add safety hint for parse_marshal_inplace
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot 3acb70b8e1 Add safety hint for deref_mut_direct_bytebuffer
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot 836b1841e3 Add safety hint for with_bitmap_content
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot d37f4580a9 Rename to avoid clash
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot 70fc964b4d Fold get_bitmap_handle
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot c3c5fbfc79 Extract with_bitmap_content
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot 04103f9280 Extract detect_image_ad
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
Dude so hot 69370efcf5 Modularize QrCode
Signed-off-by: Dude so hot <djohn@fbi.gov>
2024-09-14 20:08:51 +08:00
renovate[bot]andDude so hot f0d3b0c8ae Misc updates (#1629)
* Respect Arch's ndk location

* Don't spam in debug build

* Cleanup

* Revert "Cleanup"

This reverts commit 45b9d76dd363aa1fd3d58b5102b68649028e313a.

* Revert "Respect Arch's ndk location"

This reverts commit 3fd773422d3ae440a6466e2fa161a45018fd8ed7.

* Respect Arch ndk location

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-09-14 10:45:36 +00:00
FooIbar 370c485048 Update NDK to r27b and address deprecation 2024-09-14 17:07:04 +08:00
FooIbar 7e4c910cd7 Fix blocked image placeholder content scale 2024-09-14 16:06:09 +08:00
renovate[bot] e8c1c80ce5 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.9.0 2024-09-13 16:20:18 +00:00
0d55f319c9 Fuckoff External Ads (#1617)
* Add Rust implementation to detect QRCode from bitmap

* Add QRCodeInterceptor Framework

* Let image take hasQRCode info

* Keep ReaderPage index internal

* Move imageCache to use ReaderPage as key

* Make PageLoader size & pages internal

* Refine

* Kill entry if QRCode

* Rewrite request to respect index mapping

* Debug: enable by default

* Fix crash

* Fixup

* Fix identity

* Revert to lruCache to avoid crash

* Fixup

* Clippy check for android only

* Fixup

* Resolve clippy

* Switch

* Apply switch

* Restart pageloader restore pages

* Cleanup

* Cleanup

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

* let it inference

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

* Don't detect border for ads

* Make coil interceptor runs in `Dispatchers.Default`

* Revert "Restart pageloader restore pages"

This reverts commit 8fd02fb7d9c677c119df72a767a6079f1899dc31.

* Revert drop page changes

* Use backGround color painter for ads

* Only check ads for tags contains extraneous ads

* Only check ads for head 5 images || tail 10 images

* Move strip ADs switch to AdvancedScreen

* Pick placeholder

* Show Placeholder

* Don't try to extract & decode

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

* cleanup

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

* Naming

* Naming

* Naming

* Naming

* For ArchivePageLoader as well

* Hint

* Fix flicker

* Refine

* Naming

* QR -> Qr

* Naming

* QR -> Qr

* Naming

* Refine

* Revert

* Remove header check

* Allow show ads

* Adjust

* Refine

* Revert "Don't try to extract & decode"

This reverts commit f35ef46f6ffc81485cf0f4c646ed79e524c29d32.

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

* be strict

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

* Just Extract, don't decode

* Refine

* Refine placeholder

---------

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-09-13 16:10:05 +00:00
FooIbar 42ed51094e Temporarily disable shared element transitions (#1621) 2024-09-13 12:10:26 +08:00
renovate[bot] 84c3fee5da Update dependency sh.calvin.reorderable:reorderable to v2.4.0-beta01 2024-09-13 00:33:59 +00:00
renovate[bot] b3af2c9481 Update AGP to v8.8.0-alpha02 2024-09-12 22:42:21 +00:00
renovate[bot] 33cab186fd Update dependency com.squareup.okio:okio-jvm to v3.9.1 2024-09-12 22:40:33 +00:00
FooIbar e3f52412ee Replace custom user agent with desktop site option 2024-09-12 20:22:54 +08:00
080810c707 Update dependency io.ktor:ktor-client-core to v3.0.0-rc-1 (#1424)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <99822064+revonateB0T@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-09-10 01:22:52 +00:00
renovate[bot] 964e827cbe Update AGP to v8.8.0-alpha01 2024-09-10 01:10:26 +00:00
FooIbar cae02acfc4 Fix cosplayer tag translation in download label 2024-09-10 06:11:25 +08:00
FooIbar 31ce4eac92 Update description for IP-based image limit 2024-09-09 22:13:49 +08:00
renovate[bot] bd5bd1e571 Implement Shared Element Transition Connector Framework (#1614)
undefined
2024-09-09 21:46:07 +08:00
renovate[bot] 2b4a6dc57a Update dependency gradle to v8.10.1 2024-09-09 10:25:31 +00:00
FooIbar 68932731b9 Fix gallery detail losing scroll position 2024-09-09 18:14:48 +08:00
renovate[bot] aab7ba0d53 Lock file maintenance 2024-09-09 01:22:24 +00:00
renovate[bot] 59bf4f2702 Update compose.destinations to v2.1.0-beta12 2024-09-08 22:47:54 +00:00
FooIbar e0e4b739f9 Add key and contentType for gallery detail 2024-09-08 22:04:12 +08:00
FooIbar 21d6b3bbb8 [no ci] Fix renovate config 2024-09-07 18:13:20 +08:00
FooIbar dae5d67515 [no ci] Fix renovate config 2024-09-07 17:59:56 +08:00
FooIbar a6a814bbbb [no ci] Update renovate config 2024-09-07 16:27:23 +08:00
FooIbar 0b0555af9d Use singleton empty WindowInsets 2024-09-07 16:13:54 +08:00
FooIbar 6e0d77a8ab Fix unfinished files being considered downloaded 2024-09-07 16:13:40 +08:00
FooIbar 5c06a69c8a Revert "Update agp to v8.7.0-beta01"
This reverts commit 63ec1aa217.
2024-09-06 14:25:38 +08:00
FooIbar 188a236a90 Fix reader padding and progress indicator theme 2024-09-06 10:00:21 +08:00
renovate[bot] 63ec1aa217 Update agp to v8.7.0-beta01 2024-09-05 21:38:21 +00:00
renovate[bot] 829316d8b9 Update dependency com.google.devtools.ksp to v2.0.20-1.0.25 2024-09-05 21:36:55 +00:00
renovate[bot] 337739fdad Update dependency androidx.profileinstaller:profileinstaller to v1.4.0-rc01 2024-09-05 14:44:41 +00:00
FooIbar f144ac701d Refine BitmapPainter 2024-09-05 22:36:19 +08:00
FooIbar 9c9b8b74cd Fix crash on random button click with empty list 2024-09-05 20:53:00 +08:00
renovate[bot] 154c7f3710 Update dependency com.google.accompanist:accompanist-drawablepainter to v0.36.0 2024-09-05 03:38:28 +00:00
renovate[bot] d3308bc987 Update dependency androidx.activity:activity-compose to v1.10.0-alpha02 2024-09-05 01:56:06 +00:00
renovate[bot] 04d9ee57d7 Update dependency androidx.core:core-splashscreen to v1.2.0-alpha02 2024-09-05 01:45:16 +00:00
FooIbar f3eecf18fb Switch to official Compose alpha BOM
This reverts commit c76440f167.
2024-09-05 09:45:04 +08:00
renovate[bot] 6591495d2d Update dependency androidx.work:work-runtime to v2.10.0-alpha03 2024-09-05 01:43:42 +00:00
renovate[bot] 2497835628 Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.0-beta01 2024-09-04 22:35:38 +00:00
renovate[bot] 8fad9b2abe Update androidx.lifecycle to v2.9.0-alpha02 2024-09-04 22:34:46 +00:00
renovate[bot] fdd0ec9fd2 Update dependency com.android.tools:desugar_jdk_libs_nio to v2.1.2 2024-09-04 10:52:07 +00:00
FooIbar 1219ff99ff Reset page state on page loader restart 2024-09-03 19:12:01 +08:00
FooIbar c60b14998a Fix crash when drawing > 100 MB bitmaps 2024-09-03 19:03:54 +08:00
renovate[bot] 380554bd19 Lock file maintenance 2024-09-02 01:56:54 +00:00
FooIbar 2b969030da Fix recent download label can't be set to null
This reverts commit 822a461acd.
2024-09-01 20:56:01 +08:00
github-actions[bot] da917de1f4 Update Chrome version for default User-Agent 2024-09-01 13:34:46 +08:00
renovate[bot] 541f9e89f3 Update agp to v8.7.0-alpha09 2024-08-30 15:14:11 +00:00
renovate[bot] f4a987c83f Update agp to v8.7.0-alpha08 2024-08-28 19:45:33 +00:00
renovate[bot] 37474ed6ac Update kotlinx.serialization to v1.7.2 2024-08-28 19:43:12 +00:00
renovate[bot] cb69a168f3 Update dependency com.android.tools:desugar_jdk_libs_nio to v2.1.1 2024-08-28 13:34:26 +00:00
FooIbar 94366553de Fix torrent count parsing
This partially reverts commit 634269c6a4.
2024-08-27 07:37:30 +08:00
FooIbar 383332bfbb Fix account-based image limit parsing (#1565)
* Fix account-based image limit parsing

* Fix format
2024-08-27 07:37:05 +08:00
renovate[bot] 4bb1a17d39 Update dependency com.android.tools:desugar_jdk_libs_nio to v2.1.0 2024-08-26 17:43:40 +00:00
renovate[bot] 94d68fe38b Lock file maintenance 2024-08-26 00:56:27 +00:00
renovate[bot] cf6c850d2a Update aboutlibraries to v11.2.3 2024-08-23 11:06:34 +00:00
renovate[bot] c8a3a0fc20 Update dependency sh.calvin.reorderable:reorderable to v2.4.0-alpha02 2024-08-23 01:22:29 +00:00
renovate[bot] 433896b267 Update dependency com.google.devtools.ksp to v2.0.20-1.0.24 2024-08-22 23:11:02 +00:00
renovate[bot] 7e9c1f00ef Update Kotlin & KSP to v2.0.20 2024-08-22 10:56:10 +00:00
renovate[bot] 2b5b0d695a Update dependency me.saket.telephoto:zoomable to v0.13.0 2024-08-22 03:41:35 +00:00
renovate[bot] 2265b0943e Update dependency androidx.core:core-ktx to v1.15.0-alpha02 2024-08-22 01:09:20 +00:00
renovate[bot] 78f12065ab Update dependency dev.chrisbanes.compose:compose-bom to v2024.08.00-alpha02 2024-08-21 22:04:09 +00:00
renovate[bot] f28c45a13a Update dependency androidx.profileinstaller:profileinstaller to v1.4.0-beta01 2024-08-21 22:03:53 +00:00
renovate[bot] e030500917 Update benchmark to v1.3.0 2024-08-21 21:01:47 +00:00
renovate[bot] 9c042cd323 Update androidx.room to v2.7.0-alpha07 2024-08-21 21:01:30 +00:00
FooIbar 44eea7a273 Adapt status bar content color for reader theme 2024-08-21 15:20:25 +08:00
FooIbar 834cfc5e8c Support scrolling with mouse wheel in reader 2024-08-21 15:20:16 +08:00
renovate[bot] 876bb68873 Update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.6.1 2024-08-20 17:10:58 +00:00
FooIbar 40c1b7ef21 Prepare for next iteration 2024-08-19 21:02:30 +08:00
111 changed files with 3036 additions and 1821 deletions

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
- name: Clippy Check
working-directory: ./app/src/main/rust
run: cargo clippy --all-targets --all-features -- -D warnings
run: cargo clippy --target aarch64-linux-android --all-features -- -D warnings
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
+32
View File
@@ -1,5 +1,37 @@
# 变更日志
## [1.12.1] - 2024-09-28
### 新功能
* 屏蔽含有二维码的图片(位于高级设置)
### 改进
* 移除高级设置中的自定义 User Agent,改为桌面版网站选项
* 画廊预览与画廊详情页面合并
* 为快速搜索和下载标签列表添加了滚动条
* 无网络条件下启动时自动转到下载页面
* 杂项优化
### Bug 修复
* 从左到右或右到左模式观看使用鼠标滚轮无法翻页 [#1541](https://github.com/FooIbar/EhViewer/issues/1541)
* 状态栏颜色未跟随阅读器主题变化 [#1542](https://github.com/FooIbar/EhViewer/issues/1542)
* 打开 EH 设置时闪退 [#1563](https://github.com/FooIbar/EhViewer/issues/1563)
* 画廊种子过多时显示没有种子 [#1561](https://github.com/FooIbar/EhViewer/issues/1561)
* 加载大于 100 MB 的位图时崩溃
* 阅读器加载小圆圈在黑色背景下不自动变色 [#1579](https://github.com/FooIbar/EhViewer/issues/1579)
* 因闪退造成的无法挽回的下载失败 [#1602](https://github.com/FooIbar/EhViewer/issues/1602)
* 某些情况下闪退 [#1548](https://github.com/FooIbar/EhViewer/issues/1548) [#1555](https://github.com/FooIbar/EhViewer/issues/1555) [#1568](https://github.com/FooIbar/EhViewer/issues/1568)
* Android 7.0 及以下的设备无法加载图片 [#1633](https://github.com/FooIbar/EhViewer/issues/1633)
* 某些情况下导入数据失败 [#1634](https://github.com/FooIbar/EhViewer/issues/1634)
* 下载时创建重复文件夹 [#1619](https://github.com/FooIbar/EhViewer/issues/1619)
* 滚动方向改变后预载图片不生效 [#1560](https://github.com/FooIbar/EhViewer/issues/1560)
* 选择无效的下载位置后闪退
* 杂项修复
## [1.12.0] - 2024-08-19
### 重大变更
+23 -24
View File
@@ -23,8 +23,25 @@ val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
android {
compileSdk = 35
buildToolsVersion = "35.0.0"
ndkVersion = "27.0.12077973"
androidResources.generateLocaleConfig = true
ndkVersion = "27.1.12297006"
androidResources {
generateLocaleConfig = true
localeFilters += listOf(
"zh",
"zh-rCN",
"zh-rHK",
"zh-rTW",
"es",
"ja",
"ko",
"fr",
"de",
"th",
"tr",
"nb-rNO",
)
}
splits {
abi {
@@ -68,25 +85,9 @@ android {
applicationId = "moe.tarsin.ehviewer"
minSdk = 26
targetSdk = 35
versionCode = 180059
versionName = "1.12.0"
versionCode = 180060
versionName = "1.12.1"
// versionNameSuffix = "-SNAPSHOT"
resourceConfigurations.addAll(
listOf(
"zh",
"zh-rCN",
"zh-rHK",
"zh-rTW",
"es",
"ja",
"ko",
"fr",
"de",
"th",
"tr",
"nb-rNO",
),
)
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
buildConfigField("long", "COMMIT_TIME", commitTime)
@@ -149,9 +150,6 @@ android {
}
debug {
applicationIdSuffix = ".debug"
lint {
abortOnError = false
}
}
create("benchmarkRelease") {
initWith(buildTypes.getByName("release"))
@@ -275,11 +273,12 @@ kotlin {
"-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",
+8 -4
View File
@@ -44,8 +44,9 @@ set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}"
ExternalProject_Add(
nettle
PREFIX nettle
URL https://ftp.gnu.org/gnu/nettle/nettle-3.10.tar.gz
URL_MD5 c61453139d5fb44e9cdcc5b684b26e55
GIT_REPOSITORY https://gitlab.com/gnutls/nettle.git
GIT_TAG nettle_3.10_release_20240616
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}'"
INSTALL_COMMAND ""
@@ -89,7 +90,7 @@ set(LIBARCHIVE_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Insert-link-libs.patch)
FetchContent_Declare(
libarchive
GIT_REPOSITORY https://github.com/libarchive/libarchive.git
GIT_TAG v3.7.4
GIT_TAG v3.7.6
PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH}
)
@@ -105,7 +106,10 @@ FetchContent_Declare(
FetchContent_MakeAvailable(Corrosion)
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml)
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 ()
# Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c hash.c natsort/strnatcmp.c)
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)
-241
View File
@@ -1,241 +0,0 @@
#include <jni.h>
#include <math.h>
#include <stdbool.h>
#include <android/bitmap.h>
#include <stdlib.h>
#include <string.h>
/** A line will be considered as having content if 0.25% of it is filled. */
const float filledRatioLimit = 0.0025;
/** When the threshold is closer to 1, less content will be cropped. **/
#define THRESHOLD 0.75
const uint8_t thresholdForBlack = (uint8_t) (255.0 * THRESHOLD);
const uint8_t thresholdForWhite = (uint8_t) (255.0 - 255.0 * THRESHOLD);
static uint8_t inline grey(uint32_t pixel) {
const uint8_t r = ((uint8_t *) &pixel)[0];
const uint8_t g = ((uint8_t *) &pixel)[1];
const uint8_t b = ((uint8_t *) &pixel)[2];
return (r * 77 + g * 151 + b * 28) >> 8;
}
static bool inline isBlackPixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
return grey(pixel) < thresholdForBlack;
}
static bool inline isWhitePixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
return grey(pixel) > thresholdForWhite;
}
/** Return the first x position where there is a substantial amount of fill,
* starting the search from the left. */
uint32_t findBorderLeft(uint32_t *pixels, uint32_t width, uint32_t height,
uint32_t top, uint32_t bottom) {
int x, y;
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
for (y = top; y < bottom; y += 2) {
if (isBlackPixel(pixels, width, 0, y)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, 0, y)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return 0;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan vertical lines in search of filled lines
for (x = 1; x < width; x++) {
uint32_t filledCount = 0;
for (y = top; y < bottom; y += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return x;
}
}
// No fill found... don't crop anything
return 0;
}
/** Return the first x position where there is a substantial amount of fill,
* starting the search from the right. */
uint32_t findBorderRight(uint32_t *pixels, uint32_t width, uint32_t height,
uint32_t top, uint32_t bottom) {
int x, y;
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
uint32_t lastX = width - 1;
for (y = top; y < bottom; y += 2) {
if (isBlackPixel(pixels, width, lastX, y)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, lastX, y)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return width;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan vertical lines in search of filled lines
for (x = width - 2; x > 0; x--) {
uint32_t filledCount = 0;
for (y = top; y < bottom; y += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return x + 1;
}
}
// No fill found... don't crop anything
return width;
}
/** Return the first y position where there is a substantial amount of fill,
* starting the search from the top. */
uint32_t findBorderTop(uint32_t *pixels, uint32_t width, uint32_t height) {
int x, y;
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
for (x = 0; x < width; x += 2) {
if (isBlackPixel(pixels, width, x, 0)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, x, 0)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return 0;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan horizontal lines in search of filled lines
for (y = 1; y < height; y++) {
uint32_t filledCount = 0;
for (x = 0; x < width; x += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return y;
}
}
// No fill found... don't crop anything
return 0;
}
/** Return the first y position where there is a substantial amount of fill,
* starting the search from the bottom. */
uint32_t findBorderBottom(uint32_t *pixels, uint32_t width, uint32_t height) {
int x, y;
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
uint32_t lastY = height - 1;
for (x = 0; x < width; x += 2) {
if (isBlackPixel(pixels, width, x, lastY)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, x, lastY)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return height;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan horizontal lines in search of filled lines
for (y = height - 2; y > 0; y--) {
uint32_t filledCount = 0;
for (x = 0; x < width; x += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return y + 1;
}
}
// No fill found... don't crop anything
return height;
}
JNIEXPORT jintArray JNICALL
Java_com_hippo_ehviewer_image_ImageKt_detectBorder(JNIEnv *env, jclass clazz, jobject bitmap) {
void *pixels = NULL;
AndroidBitmapInfo info;
memset(&info, 0, sizeof(info));
AndroidBitmap_getInfo(env, bitmap, &info);
AndroidBitmap_lockPixels(env, bitmap, &pixels);
uint32_t width = info.width;
uint32_t height = info.height;
uint32_t top = findBorderTop(pixels, width, height);
uint32_t bottom = findBorderBottom(pixels, width, height);
uint32_t left = findBorderLeft(pixels, width, height, top, bottom);
uint32_t right = findBorderRight(pixels, width, height, top, bottom);
int array[4] = {left, top, right, bottom};
AndroidBitmap_unlockPixels(env, bitmap);
jintArray ret = (*env)->NewIntArray(env, 4);
(*env)->SetIntArrayRegion(env, ret, 0, 4, array);
return ret;
}
+9 -2
View File
@@ -1,6 +1,13 @@
TOOLCHAIN=$ANDROID_HOME/ndk/$1/toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
SUFFIX=toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
CI_NDK_LOCATION=$ANDROID_HOME/ndk/$1/$SUFFIX
ARCH_NDK_LOCATION=$ANDROID_HOME/ndk-bundle/$SUFFIX
if test -e $CI_NDK_LOCATION ;then
TOOLCHAIN=$CI_NDK_LOCATION
else
TOOLCHAIN=$ARCH_NDK_LOCATION
fi
TARGET=$2
./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
./.bootstrap && ./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
AR=$TOOLCHAIN/llvm-ar \
CC="$TOOLCHAIN/clang -target $TARGET" \
RANLIB=$TOOLCHAIN/llvm-ranlib
@@ -69,6 +69,11 @@ interface SystemUiController {
isStatusBarVisible = value
isNavigationBarVisible = value
}
/**
* Property which holds whether the status bar icons + content are 'dark' or not.
*/
var statusBarDarkContentEnabled: Boolean
}
/**
@@ -149,4 +154,10 @@ internal class AndroidSystemUiController(
windowInsetsController?.hide(WindowInsetsCompat.Type.navigationBars())
}
}
override var statusBarDarkContentEnabled: Boolean
get() = windowInsetsController?.isAppearanceLightStatusBars == true
set(value) {
windowInsetsController?.isAppearanceLightStatusBars = value
}
}
@@ -22,7 +22,6 @@ import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.content.res.AppCompatResources
import androidx.collection.LruCache
import androidx.compose.runtime.snapshots.Snapshot
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil3.EventListener
@@ -30,7 +29,7 @@ import coil3.SingletonImageLoader
import coil3.asImage
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.ktor2.KtorNetworkFetcherFactory
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.crossfade
@@ -40,9 +39,12 @@ import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.coil.CropBorderInterceptor
import com.hippo.ehviewer.coil.DetectBorderInterceptor
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
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.cronet.cronetHttpClient
import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase
@@ -55,6 +57,7 @@ import com.hippo.ehviewer.legacy.cleanObsoleteCache
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.lockObserver
import com.hippo.ehviewer.ui.tools.dataStateFlow
import com.hippo.ehviewer.ui.tools.initSETConnection
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.Crash
import com.hippo.ehviewer.util.FavouriteStatusRouter
@@ -67,6 +70,7 @@ import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.plugins.cookies.HttpCookies
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import logcat.AndroidLogcatLogger
import logcat.LogPriority
@@ -83,6 +87,7 @@ class EhApplication :
Application(),
SingletonImageLoader.Factory {
override fun onCreate() {
initSETConnection()
// Initialize Settings on first access
lifecycleScope.launchIO {
val mode = Settings.theme
@@ -136,9 +141,6 @@ class EhApplication :
}
if (BuildConfig.DEBUG) {
StrictMode.enableDefaults()
Snapshot.registerApplyObserver { anies, _ ->
logcat(LogPriority.VERBOSE) { anies.toString() }
}
}
}
@@ -167,6 +169,7 @@ class EhApplication :
}
override fun newImageLoader(context: Context) = context.imageLoader {
interceptorCoroutineContext(Dispatchers.Default)
components {
serviceLoaderEnabled(false)
add(KtorNetworkFetcherFactory { ktorClient })
@@ -176,6 +179,9 @@ class EhApplication :
add(HardwareBitmapInterceptor)
}
add(CropBorderInterceptor)
add(DetectBorderInterceptor)
add(QrCodeInterceptor)
add(MapExtraInfoInterceptor)
if (isAtLeastP) {
add(AnimatedImageDecoder.Factory(false))
} else {
@@ -150,7 +150,7 @@ object EhDB {
if (artists.isNotEmpty()) {
val dao = db.downloadArtistDao()
dao.deleteByGid(gid)
dao.insert(artists)
dao.insertOrIgnore(artists)
}
}
@@ -9,6 +9,7 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import com.hippo.ehviewer.client.CHROME_MOBILE_USER_AGENT
import com.hippo.ehviewer.client.CHROME_USER_AGENT
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.data.FavListUrlBuilder
@@ -123,7 +124,7 @@ object Settings : DataStorePreferences(null) {
val security = boolPref("require_unlock", false)
val animateItems = boolPref("animate_items", true)
val displayName = stringOrNullPref("display_name", null)
val recentDownloadLabel = stringOrNullPref("recent_download_label", "")
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", null)
@@ -162,6 +163,7 @@ object Settings : DataStorePreferences(null) {
var downloadOriginImage by boolPref("download_origin_image", false)
var saveAsCbz by boolPref("save_as_cbz", false)
var archiveMetadata by boolPref("archive_metadata", 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)
@@ -170,7 +172,6 @@ object Settings : DataStorePreferences(null) {
var updateIntervalDays by intPref("update_interval_days", 7)
var lastDawnDays by intPref("last_dawn_days", 0)
var recentToplist by stringPref("recent_toplist", "11")
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
var lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
@@ -208,6 +209,10 @@ object Settings : DataStorePreferences(null) {
val zoomStart = intPref("pref_zoom_start_key", 1)
val showNavigationOverlayNewUser = boolPref("reader_navigation_overlay_new_user", true)
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) {
@@ -215,6 +220,9 @@ object Settings : DataStorePreferences(null) {
if (KEY_SHOW_TAG_TRANSLATIONS !in prefs) showTagTranslations = true
}
}
if (downloadFilterMode.value == DownloadsFilterMode.ARTIST.flag && recentDownloadLabel.value == null) {
recentDownloadLabel.value = ""
}
}
interface Delegate<R> {
@@ -64,6 +64,7 @@ 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
import java.io.File
import java.nio.ByteBuffer
import kotlin.math.ceil
@@ -333,7 +334,7 @@ object EhEngine {
suspend fun resetImageLimits() = ehRequest(EhUrl.URL_HOME) {
formBody {
append("reset_imagelimit", "Reset Limit")
append("reset_imagelimit", "Reset Quota")
}
}.fetchUsingAsText(HomeParser::parseResetLimits)
@@ -56,7 +56,8 @@ inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
val json = Json { ignoreUnknownKeys = true }
const val CHROME_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"
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"
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"
const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
@@ -141,3 +141,6 @@ fun GalleryInfo.findBaseInfo(): BaseGalleryInfo = when (this) {
}
fun GalleryInfo.asGalleryDetail(): GalleryDetail? = this as? GalleryDetail
val GalleryInfo.hasAds
get() = simpleTags?.any { "extraneous ads" in it } ?: false
@@ -61,7 +61,7 @@ object GalleryDetailParser {
RegexOption.DOT_MATCHES_ALL,
)
private val PATTERN_TORRENT =
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download[^<]+(\\d+)[^<]+</a")
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\\((.+?)\\)")
@@ -19,22 +19,18 @@ package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.parseAs
import com.hippo.ehviewer.util.ExceptionUtils
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
object GalleryMultiPageViewerPTokenParser {
private const val IMAGE_LIST_STRING = "var imagelist = "
fun parse(body: String): List<String> {
fun parse(body: String): List<String> = runCatching {
val index = body.indexOf(IMAGE_LIST_STRING)
val imagelist = body.substring(index + IMAGE_LIST_STRING.length, body.indexOf(";", index))
return runCatching {
imagelist.parseAs<List<Item>>().map(Item::token)
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw ParseException("Parse pToken from MPV error", it)
}
imagelist.parseAs<List<Item>>().map(Item::token)
}.getOrElse {
throw ParseException("Parse pToken from MPV error", it)
}
@Serializable
@@ -1,55 +1,58 @@
package com.hippo.ehviewer.coil
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Extras
import coil3.Image
import coil3.getExtra
import android.graphics.Bitmap
import android.hardware.HardwareBuffer
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toIntRect
import arrow.fx.coroutines.autoCloseable
import arrow.fx.coroutines.resourceScope
import coil3.asImage
import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.image.detectBorder
import com.hippo.ehviewer.image.copyBitmapToAHB
import com.hippo.ehviewer.util.isAtLeastQ
import eu.kanade.tachiyomi.util.system.logcat
import moe.tarsin.coroutines.runSuspendCatching
private const val CROP_THRESHOLD = 0.75f
private const val RATIO_THRESHOLD = 2
@RequiresApi(Build.VERSION_CODES.O)
private const val FORMAT = HardwareBuffer.RGBA_8888
private val maybeCropBorderKey = Extras.Key(default = false)
fun ImageRequest.Builder.maybeCropBorder(enable: Boolean) = apply {
extras[maybeCropBorderKey] = enable
}
val ImageRequest.maybeCropBorder: Boolean
get() = getExtra(maybeCropBorderKey)
class BitmapImageWithRect(
val rect: IntRect,
val image: BitmapImage,
) : Image by image
@RequiresApi(Build.VERSION_CODES.O)
private const val USAGE = HardwareBuffer.USAGE_CPU_WRITE_RARELY or HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE
object CropBorderInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val result = chain.proceed()
if (chain.request.maybeCropBorder && result is SuccessResult) {
if (result is SuccessResult) {
val image = result.image
if (image is BitmapImage) {
val bitmap = image.bitmap
val ratio = if (image.height > image.width) {
image.height / image.width
} else {
image.width / image.height
}
if (ratio < RATIO_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
val rect = IntRect(array[0], array[1], array[2], array[3])
if (rect.width > minWidth && rect.height > minHeight) {
val new = BitmapImageWithRect(rect, image)
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
}
if (image is BitmapImageWithExtraInfo) {
// Copy with cropped region
val srcSize = IntSize(image.width, image.height)
if (image.rect.size != srcSize) {
val (x, y) = image.rect.topLeft
val (w, h) = image.rect.size
val src = image.image.bitmap
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
// This is not ideal but I haven't figured out how to probe the threshold.
// All we know is that it's less than the maximum texture size.
val meetHardwareThreshold = maxOf(w, h) <= chain.request.hardwareThreshold
val bitmap = when {
isAtLeastQ && meetHardwareThreshold -> runSuspendCatching {
resourceScope {
val buffer = autoCloseable { HardwareBuffer.create(w, h, FORMAT, 1, USAGE) }
copyBitmapToAHB(src, buffer, x, y)
Bitmap.wrapHardwareBuffer(buffer, src.colorSpace)
}
}.onFailure { logcat(it) }.getOrNull()
else -> null
} ?: Bitmap.createBitmap(src, x, y, w, h)
src.recycle()
return result.copy(image = image.copy(image = bitmap.asImage(), rect = image.rect.size.toIntRect()))
}
}
}
@@ -0,0 +1,51 @@
package com.hippo.ehviewer.coil
import androidx.compose.ui.unit.IntRect
import coil3.Extras
import coil3.getExtra
import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.image.detectBorder
private const val CROP_THRESHOLD = 0.75f
private const val RATIO_THRESHOLD = 2
private val maybeCropBorderKey = Extras.Key(default = false)
fun ImageRequest.Builder.maybeCropBorder(enable: Boolean) = apply {
extras[maybeCropBorderKey] = enable
}
val ImageRequest.maybeCropBorder: Boolean
get() = getExtra(maybeCropBorderKey)
object DetectBorderInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult {
val result = chain.proceed()
if (chain.request.maybeCropBorder && result is SuccessResult) {
val image = result.image
if (image is BitmapImageWithExtraInfo && !image.hasQrCode) {
val bitmap = image.image.bitmap
val ratio = if (image.height > image.width) {
image.height / image.width
} else {
image.width / image.height
}
if (ratio < RATIO_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
val rect = IntRect(array[0], array[1], array[2], array[3])
if (rect.width > minWidth && rect.height > minHeight) {
val new = image.copy(rect = rect)
return result.copy(image = new)
}
}
}
}
return result
}
}
@@ -7,9 +7,9 @@ import com.hippo.ehviewer.EhApplication.Companion.imageCache
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.spider.DownloadInfoMagics.decodeMagicRequestOrUrl
import com.hippo.ehviewer.util.sendTo
import com.hippo.files.isDirectory
import com.hippo.files.isFile
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.lang.withIOContext
object DownloadThumbInterceptor : Interceptor {
private const val THUMB_FILE = "thumb.jpg"
@@ -18,22 +18,20 @@ object DownloadThumbInterceptor : Interceptor {
if (magicOrUrl != null) {
val (url, location) = decodeMagicRequestOrUrl(magicOrUrl)
if (location != null) {
val thumb = withIOContext { downloadLocation / location / THUMB_FILE }
if (withIOContext { thumb.isFile }) {
val thumb = downloadLocation / location / THUMB_FILE
if (thumb.isFile) {
val new = chain.request.newBuilder().data(thumb.toUri()).build()
val result = chain.withRequest(new).proceed()
if (result is SuccessResult) return result
}
val new = chain.request.newBuilder().data(url).build()
val result = chain.withRequest(new).proceed()
if (result is SuccessResult) {
withIOContext {
// 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 (result is SuccessResult && thumb.parent?.isDirectory == true) {
// 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
}
}
return result
@@ -3,8 +3,6 @@ package com.hippo.ehviewer.coil
import android.graphics.Bitmap
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Extras
import coil3.asImage
import coil3.getExtra
@@ -30,25 +28,18 @@ object HardwareBitmapInterceptor : Interceptor {
val result = chain.proceed()
val request = result.request
if (!request.allowHardware && result is SuccessResult) {
var rect: IntRect? = null
val image = result.image
val bitmap = when (image) {
is BitmapImageWithRect -> image.image.also { rect = image.rect }
is BitmapImage -> image
else -> return result
}.bitmap
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
// This is not ideal but I haven't figured out how to probe the threshold.
// All we know is that it's less than the maximum texture size.
if (maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
bitmap.recycle()
val newImage = hwBitmap.asImage()
return result.copy(
image = rect?.let { BitmapImageWithRect(it, newImage as BitmapImage) } ?: newImage,
request = request,
dataSource = result.dataSource,
)
if (image is BitmapImageWithExtraInfo) {
val bitmap = image.image.bitmap
val isHardware = bitmap.config == Bitmap.Config.HARDWARE
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
// This is not ideal but I haven't figured out how to probe the threshold.
// All we know is that it's less than the maximum texture size.
if (!isHardware && maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
bitmap.recycle()
return result.copy(image = image.copy(image = hwBitmap.asImage()))
}
}
}
}
@@ -0,0 +1,29 @@
package com.hippo.ehviewer.coil
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Image
import coil3.intercept.Interceptor
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
data class BitmapImageWithExtraInfo(
val image: BitmapImage,
val rect: IntRect = IntRect(0, 0, image.width, image.height),
val hasQrCode: Boolean = false,
) : Image by image
object MapExtraInfoInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val result = chain.proceed()
val needMap = with(chain.request) { maybeCropBorder || detectQrCode || !allowHardware }
if (needMap && result is SuccessResult) {
val image = result.image
if (image is BitmapImage) {
return result.copy(image = BitmapImageWithExtraInfo(image))
}
}
return result
}
}
@@ -13,7 +13,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
object MergeInterceptor : Interceptor {
private val pendingContinuationMap: HashMap<String, MutableList<CancellableContinuation<Unit>>> = hashMapOf()
@@ -25,7 +24,9 @@ object MergeInterceptor : Interceptor {
notifyScope.launch {
synchronized(pendingContinuationMapLock) {
// Wake up a pending continuation to continue executing task
val successor = pendingContinuationMap[key]?.removeFirstOrNull()?.apply { resume(Unit) { triggerSuccessor(key) } }
val successor = pendingContinuationMap[key]?.removeFirstOrNull()?.apply {
resume(Unit) { _, _, _ -> triggerSuccessor(key) }
}
// If no successor, delete this entry from hashmap
successor ?: pendingContinuationMap.remove(key)
}
@@ -42,14 +43,14 @@ object MergeInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val req = chain.request
val key = req.memoryCacheKey?.takeIf { it.isNormalPreviewKey || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorCoroutineContext) { chain.proceed() }
val key = req.memoryCacheKey?.takeIf { it.isNormalPreviewKey || Settings.preloadThumbAggressively } ?: return chain.proceed()
suspendCancellableCoroutine { continuation ->
synchronized(pendingContinuationMapLock) {
val existPendingContinuations = pendingContinuationMap[key]
if (existPendingContinuations == null) {
pendingContinuationMap[key] = EMPTY_LIST
continuation.resume(Unit) {
continuation.resume(Unit) { _, _, _ ->
triggerSuccessor(key)
}
} else {
@@ -63,7 +64,7 @@ object MergeInterceptor : Interceptor {
}
try {
return withContext(req.interceptorCoroutineContext) { chain.proceed() }.apply {
return chain.proceed().apply {
// Wake all pending continuations shared with the same memory key since we have written it to memory cache
notifyScope.launch {
synchronized(pendingContinuationMapLock) {
@@ -0,0 +1,33 @@
package com.hippo.ehviewer.coil
import coil3.Extras
import coil3.getExtra
import coil3.intercept.Interceptor
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.image.hasQrCode
private val detectQrCodeKey = Extras.Key(default = false)
fun ImageRequest.Builder.detectQrCode(enable: Boolean) = apply {
extras[detectQrCodeKey] = enable
}
val ImageRequest.detectQrCode: Boolean
get() = getExtra(detectQrCodeKey)
object QrCodeInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val result = chain.proceed()
if (chain.request.detectQrCode && result is SuccessResult) {
val image = result.image
if (image is BitmapImageWithExtraInfo) {
val hasQrCode = hasQrCode(image.image.bitmap)
val new = image.copy(hasQrCode = hasQrCode)
return result.copy(image = new)
}
}
return result
}
}
@@ -2,12 +2,13 @@ package com.hippo.ehviewer.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface DownloadArtistDao {
@Insert
suspend fun insert(downloadArtists: List<DownloadArtist>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnore(downloadArtists: List<DownloadArtist>)
@Query("DELETE FROM DOWNLOAD_ARTISTS WHERE GID = :gid")
suspend fun deleteByGid(gid: Long)
@@ -10,7 +10,7 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
@Dao
interface HistoryDao {
@Query("SELECT * FROM HISTORY ORDER BY TIME")
@Query("SELECT HISTORY.* FROM HISTORY JOIN GALLERIES USING(GID) ORDER BY TIME")
suspend fun list(): List<HistoryInfo>
@Query("SELECT GALLERIES.* FROM HISTORY JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
@@ -14,7 +14,7 @@ interface LocalFavoritesDao {
@Query("SELECT COUNT(*) FROM LOCAL_FAVORITES")
fun count(): Flow<Int>
@Query("SELECT * FROM LOCAL_FAVORITES ORDER BY TIME")
@Query("SELECT LOCAL_FAVORITES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY TIME")
suspend fun list(): List<LocalFavoriteInfo>
@Query("SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
@@ -27,7 +27,7 @@ interface LocalFavoritesDao {
fun joinListLazy(title: String): PagingSource<Int, BaseGalleryInfo>
@Query("SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY RANDOM() LIMIT 1")
suspend fun random(): BaseGalleryInfo
suspend fun random(): BaseGalleryInfo?
@Query("SELECT EXISTS(SELECT * FROM LOCAL_FAVORITES WHERE GID = :gid)")
suspend fun contains(gid: Long): Boolean
@@ -16,6 +16,7 @@
package com.hippo.ehviewer.gallery
import android.os.ParcelFileDescriptor
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.Settings.archivePasswds
import com.hippo.ehviewer.image.ByteBufferSource
import com.hippo.ehviewer.image.Image
@@ -52,6 +53,7 @@ class ArchivePageLoader(
private val file: Path,
gid: Long = 0,
startPage: Int = 0,
private val hasAds: Boolean = false,
passwdProvider: PasswdProvider? = null,
) : PageLoader2(gid, startPage) {
private lateinit var pfd: ParcelFileDescriptor
@@ -104,6 +106,8 @@ class ArchivePageLoader(
}
}
private fun mayBeAd(index: Int) = index > size - 10
private suspend fun doRealWork(index: Int) {
val buffer = extractToByteBuffer(index) ?: return notifyPageFailed(index, null)
check(buffer.isDirect)
@@ -120,7 +124,7 @@ class ArchivePageLoader(
src.close()
throw it
}
val image = Image.decode(src) ?: return notifyPageFailed(index, null)
val image = Image.decode(src, hasAds && Settings.stripExtraneousAds.value && mayBeAd(index)) ?: return notifyPageFailed(index, null)
runCatching {
currentCoroutineContext().ensureActive()
}.onFailure {
@@ -142,10 +146,6 @@ class ArchivePageLoader(
override val isReady: Boolean
get() = size != 0
override fun onCancelRequest(index: Int) {
mJobMap[index]?.cancel()
}
override val title by lazy {
FileUtils.getNameFromFilename(file.name)!!
}
@@ -66,10 +66,6 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
override val isReady: Boolean
get() = ::mSpiderQueen.isInitialized && mSpiderQueen.isReady
override fun onCancelRequest(index: Int) {
mSpiderQueen.cancelRequest(index)
}
override fun onGetPages(pages: Int) {}
override fun onGet509(index: Int) {}
@@ -0,0 +1,43 @@
package com.hippo.ehviewer.gallery
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import com.hippo.ehviewer.image.Image
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
class Page(
val index: Int,
val statusFlow: MutableStateFlow<PageStatus> = MutableStateFlow(PageStatus.Queued),
)
fun Page.unblock() = statusFlow.update { status ->
when (status) {
is PageStatus.Blocked -> PageStatus.Ready(status.ad)
else -> error("Call unblock on page not blocked!!!")
}
}
fun Page.reset() = statusFlow.update { PageStatus.Queued }
val Page.status
get() = statusFlow.value
val Page.statusObserved
@Composable
get() = statusFlow.collectAsState().value
val PageStatus.progressObserved
@Composable
get() = when (this) {
is PageStatus.Loading -> progress.collectAsState().value
else -> 0f
}
sealed interface PageStatus {
data object Queued : PageStatus
data class Blocked(val ad: Image) : PageStatus
data class Ready(val image: Image) : PageStatus
data class Error(val message: String?) : PageStatus
data class Loading(val progress: MutableStateFlow<Float>) : PageStatus
}
@@ -18,7 +18,8 @@
package com.hippo.ehviewer.image
import android.graphics.Bitmap
import androidx.compose.ui.unit.IntRect
import android.hardware.HardwareBuffer
import androidx.compose.ui.unit.IntSize
import arrow.core.Either
import arrow.core.left
import arrow.core.right
@@ -33,7 +34,8 @@ import coil3.request.allowHardware
import coil3.size.Dimension
import coil3.size.Precision
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.coil.BitmapImageWithRect
import com.hippo.ehviewer.coil.BitmapImageWithExtraInfo
import com.hippo.ehviewer.coil.detectQrCode
import com.hippo.ehviewer.coil.hardwareThreshold
import com.hippo.ehviewer.coil.maybeCropBorder
import com.hippo.ehviewer.jni.isGif
@@ -51,14 +53,15 @@ import okio.Path
import splitties.init.appCtx
class Image private constructor(image: CoilImage, private val src: ImageSource) {
val size = image.size
val rect = when (image) {
is BitmapImageWithRect -> image.rect
else -> image.run { IntRect(0, 0, width, height) }
val intrinsicSize = with(image) { IntSize(width, height) }
val allocationSize = image.size
val hasQrCode = when (image) {
is BitmapImageWithExtraInfo -> image.hasQrCode
else -> false
}
var innerImage: CoilImage? = when (image) {
is BitmapImageWithRect -> image.image
is BitmapImageWithExtraInfo -> image.image
else -> image
}
@@ -76,7 +79,7 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
companion object {
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(): CoilImage {
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(checkExtraneousAds: Boolean): CoilImage {
val req = appCtx.imageRequest {
onLeft { data(it.source) }
onRight { data(it.source.toUri()) }
@@ -85,6 +88,7 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
allowHardware(false)
hardwareThreshold(Settings.hardwareBitmapThreshold)
maybeCropBorder(Settings.cropBorder.value)
detectQrCode(checkExtraneousAds)
memoryCachePolicy(CachePolicy.DISABLED)
}
return when (val result = appCtx.imageLoader.execute(req)) {
@@ -93,7 +97,7 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
}
}
suspend fun decode(src: ImageSource): Image? {
suspend fun decode(src: ImageSource, checkExtraneousAds: Boolean = false): Image? {
return runCatching {
val image = when (src) {
is PathSource -> {
@@ -109,18 +113,18 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
src.close()
}
}
return decode(source)
return decode(source, checkExtraneousAds)
}
}
}
src.right().decodeCoil()
src.right().decodeCoil(checkExtraneousAds)
}
is ByteBufferSource -> {
if (isAtLeastP && !isAtLeastU) {
rewriteGifSource(src.source)
}
src.left().decodeCoil()
src.left().decodeCoil(checkExtraneousAds)
}
}
Image(image, src).apply {
@@ -146,3 +150,5 @@ interface ByteBufferSource : ImageSource {
}
external fun detectBorder(bitmap: Bitmap): IntArray
external fun hasQrCode(bitmap: Bitmap): Boolean
external fun copyBitmapToAHB(src: Bitmap, dst: HardwareBuffer, x: Int, y: Int)
@@ -15,6 +15,7 @@ import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.InternalAPI
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import io.ktor.utils.io.writeFully
import io.ktor.utils.io.writer
import java.nio.ByteBuffer
import kotlin.coroutines.CoroutineContext
@@ -46,6 +46,8 @@ import com.hippo.files.exists
import com.hippo.files.find
import com.hippo.files.isDirectory
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
@@ -54,7 +56,7 @@ 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.jvm.nio.copyTo
import io.ktor.utils.io.copyTo
import kotlin.io.path.readText
import kotlinx.coroutines.CancellationException
import okio.Path
@@ -90,10 +92,10 @@ class SpiderDen(val info: GalleryInfo) {
mode = value
if (mode == SpiderQueen.MODE_DOWNLOAD) {
if (downloadDir == null) {
downloadDir = getGalleryDownloadDir(info)
downloadDir = getGalleryDownloadDir(info).apply { mkdirs() }
}
if (saveAsCbz && tempDownloadDir == null) {
tempDownloadDir = info.tempDownloadDir!!
tempDownloadDir = info.tempDownloadDir!!.apply { mkdirs() }
}
}
}
@@ -103,9 +105,11 @@ class SpiderDen(val info: GalleryInfo) {
return sCache.read(key) { true } ?: false
}
private fun findImageFile(index: Int) = synchronized(fileCache) {
private fun findImageFile(index: Int, temp: Boolean = false) = synchronized(fileCache) {
val head = perFilename(index)
fileCache.entries.firstOrNull { (name) -> name.startsWith(head) }?.value
fileCache.entries.firstOrNull { (name) ->
name.startsWith(head) && temp == name.endsWith(TEMP_SUFFIX)
}?.value
}
private fun containInDownloadDir(index: Int): Boolean = findImageFile(index) != null
@@ -144,14 +148,17 @@ class SpiderDen(val info: GalleryInfo) {
return sCache.remove(key)
}
private fun removeFromDownloadDir(index: Int) = findImageFile(index)?.run {
private fun removeTempFile(index: Int) = findImageFile(index, temp = true)?.run {
delete()
synchronized(fileCache) {
fileCache.remove(name)
}
}
fun remove(index: Int) = removeFromCache(index).also { removeFromDownloadDir(index) }
fun removeIntermediateFiles(index: Int) {
removeFromCache(index)
removeTempFile(index)
}
private fun Path.findDownloadFileForIndex(index: Int, extension: String): Path {
val name = perFilename(index, extension)
@@ -185,7 +192,15 @@ class SpiderDen(val info: GalleryInfo) {
crossinline fops: suspend (Path) -> Unit,
): Boolean {
imageDir?.run {
fops(findDownloadFileForIndex(index, ext))
val tempFile = findDownloadFileForIndex(index, "$ext$TEMP_SUFFIX")
fops(tempFile)
val file = resolve(tempFile.name.removeSuffix(TEMP_SUFFIX))
file.delete()
tempFile.moveTo(file)
synchronized(fileCache) {
fileCache.remove(tempFile.name)
fileCache[file.name] = file
}
return true
}
@@ -328,7 +343,7 @@ class SpiderDen(val info: GalleryInfo) {
}
suspend fun initDownloadDir() {
downloadDir = getGalleryDownloadDir(info)
downloadDir = getGalleryDownloadDir(info).apply { mkdirs() }
}
suspend fun writeComicInfo(fetchMetadata: Boolean = true) {
@@ -353,6 +368,7 @@ class SpiderDen(val info: GalleryInfo) {
}
}
private const val TEMP_SUFFIX = ".tmp"
private val FileNameRegex = Regex("^\\d{8}\\.\\w{3,4}")
private val FileHashRegex = Regex("/([0-9a-f]{40})(?:-\\d+){3}-\\w+")
@@ -28,6 +28,7 @@ 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.data.GalleryInfo
import com.hippo.ehviewer.client.data.hasAds
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.client.exception.QuotaExceededException
import com.hippo.ehviewer.client.fetchUsingAsText
@@ -284,10 +285,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}
fun cancelRequest(index: Int) {
mWorkerScope.cancelDecode(index)
}
fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {
mWorkerScope.updateRAList(pages, pair)
}
@@ -507,15 +504,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private val showKeyLock = Mutex()
private val mDownloadDelay = Settings.downloadDelay.milliseconds
private val downloadTimeout = Settings.downloadTimeout.seconds
private val delayLock = Mutex()
private var lastRequestTime = TimeSource.Monotonic.markNow()
var isDownloadMode = false
private set
fun cancelDecode(index: Int) {
decoder.cancel(index)
}
@Synchronized
fun enterDownloadMode() {
if (isDownloadMode) return
@@ -548,13 +540,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
if (currentJob?.isActive != true) {
mFetcherJobMap[index] = launch {
runCatching {
if (!force && index in mSpiderDen) {
return@runCatching updatePageState(index, STATE_FINISHED)
}
delayLock.withLock {
delay(mDownloadDelay - lastRequestTime.elapsedNow())
lastRequestTime = TimeSource.Monotonic.markNow()
}
mSemaphore.withPermit {
doInJob(index, force, orgImg, skipHath)
}
@@ -591,7 +576,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
?: getPTokenFromInternet(index)
?: getPTokenFromMultiPageViewer(index)
}
updatePageState(index, STATE_DOWNLOADING)
val previousPToken: String?
val pToken: String
pTokenLock.withLock {
@@ -599,13 +583,20 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
if (mSpiderInfo.pTokenMap[index] == TOKEN_FAILED) {
mSpiderInfo.pTokenMap.remove(index)
}
} else if (index in mSpiderDen) {
return updatePageState(index, STATE_FINISHED)
}
pToken = getPToken(index)
?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE).also {
mSpiderInfo.pTokenMap[index] = TOKEN_FAILED
}
previousPToken = getPToken(index - 1)
// The lock for delay should be acquired before anything else to maintain FIFO order
delay(mDownloadDelay - lastRequestTime.elapsedNow())
lastRequestTime = TimeSource.Monotonic.markNow()
}
updatePageState(index, STATE_DOWNLOADING)
var skipHathKey: String? = null
var originImageUrl: String? = null
@@ -694,7 +685,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
updatePageState(index, STATE_FINISHED)
return
}.onFailure {
mSpiderDen.remove(index)
mSpiderDen.removeIntermediateFiles(index)
logcat(WORKER_DEBUG_TAG) { "Download image $index attempt #$times failed" }
error = when (it) {
is TimeoutCancellationException -> ERROR_TIMEOUT
@@ -749,9 +740,13 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}
private fun mayBeAd(index: Int) = index > size - 10
private val hasAds = galleryInfo.hasAds
private suspend fun doInJob(index: Int) {
val src = mSpiderDen.getImageSource(index) ?: return
val image = Image.decode(src)
val image = Image.decode(src, hasAds && Settings.stripExtraneousAds.value && mayBeAd(index))
checkNotNull(image)
runCatching {
currentCoroutineContext().ensureActive()
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -20,7 +21,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -62,10 +62,10 @@ private fun GalleryDetail.generateContent() = arrayOf(
R.string.favorite_name to favoriteName,
)
context(Context, DestinationsNavigator)
@Composable
fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigator) {
fun GalleryInfoBottomSheet(detail: GalleryDetail) {
Column(modifier = Modifier.fillMaxWidth()) {
val context = LocalContext.current
Text(
text = stringResource(id = R.string.gallery_info),
modifier = Modifier.align(Alignment.CenterHorizontally),
@@ -79,10 +79,10 @@ fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigat
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)).clickable {
if (index == INDEX_PARENT) {
if (content != null) {
navigator.navWithUrl(content)
navWithUrl(content)
}
} else {
context.addTextToClipboard(content, true)
addTextToClipboard(content, true)
if (index == INDEX_URL) {
// Save it to avoid detect the gallery
Settings.clipboardTextHashCode = data[index].hashCode()
@@ -26,7 +26,6 @@ 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
@@ -138,6 +137,7 @@ 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.updater.AppUpdater
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.addTextToClipboard
@@ -234,7 +234,7 @@ class MainActivity : EhActivity() {
suspend fun DialogState.checkDownloadLocation() {
val valid = withIOContext { downloadLocation.isDirectory }
if (!valid) {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.open_settings,
title = R.string.waring,
showCancelButton = false,
@@ -248,18 +248,24 @@ class MainActivity : EhActivity() {
}
}
val hasNetwork = remember { connectivityManager.activeNetwork != null }
if (!AppConfig.isBenchmark) {
val noNetwork = stringResource(R.string.no_network)
LaunchedEffect(Unit) {
runCatching { dialogState.checkDownloadLocation() }
runCatching { dialogState.checkAppLinkVerify() }
runSuspendCatching {
withIOContext {
AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
if (hasNetwork) {
runSuspendCatching {
withIOContext {
AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
}
}
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
}
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
} else {
snackbarState.showSnackbar(noNetwork)
}
}
}
@@ -463,16 +469,22 @@ class MainActivity : EhActivity() {
drawerState = sideSheetState,
gesturesEnabled = sheet != null && drawerEnabled,
) {
SharedTransitionLayout {
CompositionLocalProvider(LocalSharedTransitionScope provides this) {
DestinationsNavHost(
navGraph = NavGraphs.root,
start = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
// https://issuetracker.google.com/336140982
// SharedTransitionLayout {
CompositionLocalProvider(LocalSharedTransitionScope provides NoopSharedTransitionScope) {
val start = when {
Settings.needSignIn -> SignInScreenDestination
hasNetwork -> StartDestination
else -> DownloadsScreenDestination
}
DestinationsNavHost(
navGraph = NavGraphs.root,
start = start,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
}
// }
}
}
}
@@ -494,7 +506,7 @@ class MainActivity : EhActivity() {
val hasUnverified = userState.hostToStateMap.values.any { it == DOMAIN_STATE_NONE }
if (hasUnverified) {
var checked by mutableStateOf(false)
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.open_settings,
title = R.string.app_link_not_verified_title,
onCancelButtonClick = {
@@ -525,7 +537,7 @@ class MainActivity : EhActivity() {
Uri.parse("package:$packageName"),
)
startActivity(intent)
} catch (t: Throwable) {
} catch (_: Throwable) {
val intent = Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName"),
@@ -5,6 +5,7 @@ import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
@@ -19,7 +20,7 @@ import soup.compose.material.motion.animation.materialSharedAxisXOut
import soup.compose.material.motion.animation.rememberSlideDistance
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
EhTheme {
EhTheme(useDarkTheme = isSystemInDarkTheme()) {
ProvideVectorPainterCache {
val dialogState = remember { DialogState() }
dialogState.Intercept()
@@ -116,7 +116,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}.onFailure {
withUIContext {
focusManager.clearFocus()
dialogState.awaitPermissionOrCancel(
dialogState.awaitConfirmationOrCancel(
confirmText = R.string.get_it,
title = R.string.sign_in_failed,
showCancelButton = false,
@@ -45,6 +45,7 @@ import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.listThumbGenerator
import com.hippo.ehviewer.util.FileUtils
private val ids = Tuple9(1, 2, 3, 4, 5, 6, 7, 8, 9)
@@ -108,10 +109,12 @@ fun DownloadCard(
Row {
val thumb = remember {
movableContentOf<DownloadInfo> {
EhAsyncCropThumb(
key = it,
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
)
with(listThumbGenerator) {
EhAsyncCropThumb(
key = it,
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
)
}
}
}
if (selectMode) {
@@ -17,6 +17,7 @@ import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ui.tools.SETNodeGenerator
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.shouldCrop
@@ -27,7 +28,7 @@ fun requestOf(model: GalleryInfo): ImageRequest {
return remember(model) { context.imageRequest(model) }
}
context(SharedTransitionScope, TransitionsVisibilityScope)
context(SharedTransitionScope, TransitionsVisibilityScope, SETNodeGenerator)
@Composable
@NonRestartableComposable
fun EhAsyncThumb(
@@ -39,7 +40,6 @@ fun EhAsyncThumb(
model = requestOf(model),
contentDescription = null,
modifier = modifier
// https://github.com/FooIbar/EhViewer/issues/1527
// .sharedBounds(key = "${model.gid}")
.clip(ShapeDefaults.Medium),
onSuccess = onSuccess?.let { callback ->
@@ -48,7 +48,7 @@ fun EhAsyncThumb(
contentScale = contentScale,
)
context(SharedTransitionScope, TransitionsVisibilityScope)
context(SharedTransitionScope, TransitionsVisibilityScope, SETNodeGenerator)
@Composable
fun EhAsyncCropThumb(
key: GalleryInfo,
@@ -59,7 +59,6 @@ fun EhAsyncCropThumb(
model = requestOf(key),
contentDescription = null,
modifier = modifier
// https://github.com/FooIbar/EhViewer/issues/1527
// .sharedBounds(key = "${key.gid}")
.clip(ShapeDefaults.Medium),
onSuccess = {
@@ -37,6 +37,7 @@ import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.SadAndroid
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.detailThumbGenerator
@Composable
fun GalleryDetailHeaderInfoCard(
@@ -109,13 +110,15 @@ fun GalleryDetailHeaderCard(
) = ElevatedCard(modifier = modifier) {
Row {
Card {
EhAsyncCropThumb(
key = remember(info.gid) { info },
modifier = Modifier.size(
dimensionResource(id = R.dimen.gallery_detail_thumb_width),
dimensionResource(id = R.dimen.gallery_detail_thumb_height),
),
)
with(detailThumbGenerator) {
EhAsyncCropThumb(
key = remember(info.gid) { info },
modifier = Modifier.size(
dimensionResource(id = R.dimen.gallery_detail_thumb_width),
dimensionResource(id = R.dimen.gallery_detail_thumb_height),
),
)
}
}
Spacer(modifier = Modifier.weight(0.5F))
Column(
@@ -53,6 +53,7 @@ import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.ElevatedCard
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.listThumbGenerator
import com.hippo.ehviewer.util.FavouriteStatusRouter
private val ids = Tuple7(1, 2, 3, 4, 5, 6, 7)
@@ -108,10 +109,12 @@ fun GalleryInfoListItem(
) {
Row {
Card {
EhAsyncCropThumb(
key = info,
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
)
with(listThumbGenerator) {
EhAsyncCropThumb(
key = info,
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
)
}
}
ConstraintLayout(
modifier = Modifier.padding(start = 8.dp, top = 2.dp, end = 4.dp, bottom = 4.dp).fillMaxSize(),
@@ -227,13 +230,15 @@ fun GalleryInfoGridItem(
}
mutableFloatStateOf(ratio)
}
EhAsyncThumb(
model = info,
modifier = Modifier.aspectRatio(ratio),
onSuccess = {
ratio = (it.width.toFloat() / it.height).coerceIn(MIN_RATIO, MAX_RATIO)
},
)
with(listThumbGenerator) {
EhAsyncThumb(
model = info,
modifier = Modifier.aspectRatio(ratio),
onSuccess = {
ratio = (it.width.toFloat() / it.height).coerceIn(MIN_RATIO, MAX_RATIO)
},
)
}
val categoryColor = EhUtils.getCategoryColor(info.category)
Badge(
modifier = Modifier.align(Alignment.TopEnd).widthIn(min = 32.dp).height(24.dp),
@@ -0,0 +1,34 @@
package com.hippo.ehviewer.ui.reader
import android.graphics.Bitmap
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toAndroidRectF
import androidx.compose.ui.util.fastRoundToInt
class BitmapPainter(private val bitmap: Bitmap, override val intrinsicSize: Size) : Painter() {
private val srcRect = intrinsicSize.toRect().toAndroidRectF()
private val dstRect = RectF()
private val matrix = Matrix()
// Use the overload that takes a `Matrix` to bypass the 100 MB size limit
override fun DrawScope.onDraw() = drawIntoCanvas { canvas ->
dstRect.right = size.width.fastRoundToInt().toFloat()
dstRect.bottom = size.height.fastRoundToInt().toFloat()
matrix.setRectToRect(srcRect, dstRect, Matrix.ScaleToFit.FILL)
canvas.nativeCanvas.drawBitmap(bitmap, matrix, paint)
}
}
private val paint = Paint().apply {
isAntiAlias = true
isFilterBitmap = true
isDither = true
}
@@ -1,27 +1,36 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastFold
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageLoader2
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.CONTINUOUS_VERTICAL
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.RIGHT_TO_LEFT
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.VERTICAL
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
@Composable
fun GalleryPager(
@@ -31,10 +40,9 @@ fun GalleryPager(
pageLoader: PageLoader2,
showNavigationOverlay: Boolean,
onNavigationModeChange: () -> Unit,
onSelectPage: (ReaderPage) -> Unit,
onSelectPage: (Page) -> Unit,
onMenuRegionClick: () -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
val isPagerType = !ReadingModeType.isWebtoon(type)
val pagerNavigation by Settings.readerPagerNav.collectAsState()
@@ -53,11 +61,18 @@ fun GalleryPager(
} else {
onNavigationModeChange()
}
navigation.invertMode = TappingInvertMode.entries[invertMode]
navigation.regions
navigation.regions(TappingInvertMode.entries[invertMode])
}
val navigator by rememberUpdatedState(regions)
if (isPagerType) {
val channel = remember { Channel<Float>(Channel.CONFLATED) }
LaunchedEffect(channel) {
channel.receiveAsFlow().collectLatest { delta ->
if (delta != 0f) {
if (delta < 0) pagerState.moveToNext() else pagerState.moveToPrevious()
}
}
}
PagerViewer(
pagerState = pagerState,
isRtl = type == RIGHT_TO_LEFT,
@@ -66,8 +81,15 @@ fun GalleryPager(
navigator = { navigator },
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
contentPadding = contentPadding,
modifier = modifier,
modifier = modifier.pointerInput(channel) {
awaitPointerEventScope {
while (true) {
val event = awaitScrollEvent()
val delta = calculateMouseWheelScroll(event)
channel.trySend(delta)
}
}
},
)
} else {
WebtoonViewer(
@@ -77,9 +99,21 @@ fun GalleryPager(
navigator = { navigator },
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
contentPadding = contentPadding,
modifier = modifier,
)
}
NavigationOverlay(showNavigationOverlay, regions, modifier = Modifier.fillMaxSize())
}
private suspend fun AwaitPointerEventScope.awaitScrollEvent(): PointerEvent {
var event: PointerEvent
do {
event = awaitPointerEvent()
} while (event.type != PointerEventType.Scroll)
return event
}
private fun Density.calculateMouseWheelScroll(event: PointerEvent): Float {
// 64 dp value is taken from ViewConfiguration.java, replace with better solution
return event.changes.fastFold(0f) { acc, c -> acc + c.scrollDelta.y } * -64.dp.toPx()
}
@@ -46,12 +46,17 @@ fun NavigationOverlay(
val scaleX = maxWidth
val scaleY = maxHeight
regions.forEach { region ->
val rect = region.rectF
val rect = region.rect
val color = colorResource(region.type.colorRes)
val text = stringResource(region.type.nameRes)
Box(
modifier = Modifier.size(scaleX * rect.width(), scaleY * rect.height())
.offset(scaleX * rect.left, scaleY * rect.top).background(color),
modifier = Modifier.size(
width = scaleX * rect.width,
height = scaleY * rect.height,
).offset(
x = scaleX * rect.left,
y = scaleY * rect.top,
).background(color),
contentAlignment = Alignment.Center,
) {
Text(text, style = strokeStyle)
@@ -16,6 +16,7 @@ import com.hippo.ehviewer.BuildConfig.APPLICATION_ID
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageLoader2
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
@@ -25,7 +26,6 @@ import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastT
import com.hippo.ehviewer.util.requestPermission
import com.hippo.files.toOkioPath
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.system.logcat
import java.io.File
import kotlinx.datetime.Clock
@@ -42,7 +42,7 @@ private fun Context.provideImage(index: Int): Uri? {
}
context(SnackbarHostState, Context, PageLoader2)
suspend fun shareImage(page: ReaderPage, info: GalleryInfo? = null) {
suspend fun shareImage(page: Page, info: GalleryInfo? = null) {
val error = getString(R.string.error_cant_save_image)
val share = getString(R.string.share_image)
val noActivity = getString(R.string.error_cant_find_activity)
@@ -67,7 +67,7 @@ suspend fun shareImage(page: ReaderPage, info: GalleryInfo? = null) {
}
context(SnackbarHostState, Context, PageLoader2)
suspend fun copy(page: ReaderPage) {
suspend fun copy(page: Page) {
val error = getString(R.string.error_cant_save_image)
val copied = getString(R.string.copied_to_clipboard)
val uri = provideImage(page.index)
@@ -83,7 +83,7 @@ suspend fun copy(page: ReaderPage) {
}
context(SnackbarHostState, Context, PageLoader2)
suspend fun save(page: ReaderPage) {
suspend fun save(page: Page) {
val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
val cannotSave = getString(R.string.error_cant_save_image)
if (granted) {
@@ -137,7 +137,7 @@ suspend fun save(page: ReaderPage) {
}
context(SnackbarHostState, Context, PageLoader2)
suspend fun saveTo(page: ReaderPage) {
suspend fun saveTo(page: Page) {
val filename = getImageFilename(page.index)
if (filename == null) {
showSnackbar(getString(R.string.error_cant_save_image))
@@ -20,28 +20,34 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import coil3.BitmapImage
import coil3.DrawableImage
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.google.accompanist.drawablepainter.DrawablePainter
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageStatus
import com.hippo.ehviewer.gallery.progressObserved
import com.hippo.ehviewer.gallery.statusObserved
import com.hippo.ehviewer.image.Image
import eu.kanade.tachiyomi.source.model.Page
import com.hippo.ehviewer.ui.settings.AdsPlaceholderFile
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.CombinedCircularProgressIndicator
import kotlinx.coroutines.flow.drop
import moe.tarsin.kt.unreachable
@Composable
fun PagerItem(
page: ReaderPage,
page: Page,
pageLoader: PageLoader,
contentScale: ContentScale,
modifier: Modifier = Modifier,
@@ -49,21 +55,24 @@ fun PagerItem(
) {
LaunchedEffect(Unit) {
pageLoader.request(page.index)
page.statusFlow.drop(1).collect {
if (page.statusFlow.value == PageStatus.Queued) {
pageLoader.request(page.index)
}
}
}
val defaultError = stringResource(id = R.string.decode_image_error)
val state by page.status.collectAsState()
when (state) {
Page.State.QUEUE, Page.State.LOAD_PAGE, Page.State.DOWNLOAD_IMAGE -> {
when (val state = page.statusObserved) {
is PageStatus.Queued, is PageStatus.Loading -> {
Box(
modifier = modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT),
contentAlignment = Alignment.Center,
) {
val progress by page.progressFlow.collectAsState()
CombinedCircularProgressIndicator(progress = progress / 100f)
CombinedCircularProgressIndicator(progress = state.progressObserved)
}
}
Page.State.READY -> {
val image = page.image!!
is PageStatus.Ready -> {
val image = state.image
val painter = remember(image) { image.toPainter() }
val grayScale by Settings.grayScale.collectAsState()
val invert by Settings.invertedColors.collectAsState()
@@ -91,14 +100,36 @@ fun PagerItem(
},
)
}
Page.State.ERROR -> {
is PageStatus.Blocked -> {
SubcomposeAsyncImage(
model = AdsPlaceholderFile,
contentDescription = null,
modifier = modifier.fillMaxSize(),
contentScale = if (contentScale == ContentScale.Inside) ContentScale.Fit else contentScale,
) {
val placeholderState by painter.state.collectAsState()
if (placeholderState is AsyncImagePainter.State.Success) {
SubcomposeAsyncImageContent()
} else {
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT),
contentAlignment = Alignment.Center,
) {
if (placeholderState is AsyncImagePainter.State.Error) {
Text(text = stringResource(id = R.string.blocked_image))
}
}
}
}
}
is PageStatus.Error -> {
Box(modifier = modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT)) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = page.errorMsg ?: defaultError,
text = state.message ?: defaultError,
modifier = Modifier.padding(8.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
@@ -113,11 +144,7 @@ fun PagerItem(
}
private fun Image.toPainter() = when (val image = innerImage) {
is BitmapImage -> BitmapPainter(
image = image.bitmap.asImageBitmap(),
srcOffset = rect.topLeft,
srcSize = rect.size,
)
is BitmapImage -> BitmapPainter(image.bitmap, intrinsicSize.toSize())
is DrawableImage -> DrawablePainter(image.drawable)
else -> unreachable()
}
@@ -2,7 +2,6 @@ package com.hippo.ehviewer.ui.reader
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
@@ -32,9 +31,10 @@ import androidx.compose.ui.unit.toSize
import arrow.core.partially1
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageLoader2
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import com.hippo.ehviewer.gallery.PageStatus
import com.hippo.ehviewer.gallery.statusObserved
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import eu.kanade.tachiyomi.ui.reader.viewer.getAction
@@ -56,9 +56,8 @@ fun PagerViewer(
isVertical: Boolean,
pageLoader: PageLoader2,
navigator: () -> NavigationRegions,
onSelectPage: (ReaderPage) -> Unit,
onSelectPage: (Page) -> Unit,
onMenuRegionClick: () -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@@ -76,7 +75,6 @@ fun PagerViewer(
VerticalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
key = { it },
) { index ->
val page = items[index]
@@ -100,7 +98,6 @@ fun PagerViewer(
HorizontalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
reverseLayout = isRtl xor isRtlLayout,
key = { it },
) { index ->
@@ -125,7 +122,7 @@ fun PagerViewer(
@Composable
private fun PageContainer(
page: ReaderPage,
page: Page,
pageLoader: PageLoader2,
isRtl: Boolean,
scaleType: Int,
@@ -134,16 +131,16 @@ private fun PageContainer(
layoutSize: Size,
navigator: () -> NavigationRegions,
pagerState: PagerState,
onSelectPage: (ReaderPage) -> Unit,
onSelectPage: (Page) -> Unit,
onMenuRegionClick: () -> Unit,
scope: CoroutineScope,
) {
@Suppress("NAME_SHADOWING")
val isRtl by rememberUpdatedState(isRtl)
val zoomableState = rememberZoomableState(zoomSpec = PagerZoomSpec)
val status by page.status.collectAsState()
if (status == Page.State.READY && layoutSize != Size.Zero) {
val size = page.image!!.rect.size.toSize()
val status = page.statusObserved
if (status is PageStatus.Ready && layoutSize != Size.Zero) {
val size = status.image.intrinsicSize.toSize()
val contentScale = ContentScale.fromPreferences(scaleType, size, layoutSize)
zoomableState.contentScale = contentScale
LaunchedEffect(size, contentScale, alignment) {
@@ -188,11 +185,11 @@ private fun PageContainer(
with(pagerState) {
// Don't use `layoutSize` as it may capture outdated value
val size = layoutInfo.viewportSize.toSize()
val x = offset.x / size.width
val y = offset.y / size.height
val (w, h) = size
val (x, y) = offset
val distance = size.width
val bounds = size.toRect()
when (navigator().getAction(x, y)) {
when (navigator().getAction(Offset(x / w, y / h))) {
NavigationRegion.MENU -> onMenuRegionClick()
NavigationRegion.NEXT -> {
val canPan = if (isRtl) panRight(distance, bounds) else panLeft(distance, bounds)
@@ -10,10 +10,8 @@ import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
@@ -49,7 +47,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowInsetsControllerCompat
import arrow.core.Either
import arrow.core.raise.ensure
@@ -57,15 +54,22 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.hasAds
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.archiveFile
import com.hippo.ehviewer.gallery.ArchivePageLoader
import com.hippo.ehviewer.gallery.EhPageLoader
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageLoader2
import com.hippo.ehviewer.gallery.PageStatus
import com.hippo.ehviewer.gallery.status
import com.hippo.ehviewer.gallery.unblock
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.theme.EhTheme
import com.hippo.ehviewer.ui.tools.Await
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
import com.hippo.ehviewer.util.displayString
import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.annotation.Destination
@@ -79,6 +83,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlin.coroutines.resume
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
@@ -93,6 +98,14 @@ sealed interface ReaderScreenArgs : Parcelable {
data class Archive(val uri: Uri) : ReaderScreenArgs
}
@Composable
private fun Background(
color: Color,
content: @Composable () -> Unit,
) = Box(Modifier.fillMaxSize().background(color), contentAlignment = Alignment.Center) {
EhTheme(useDarkTheme = color != Color.White, content = content)
}
@Destination<RootGraph>
@Composable
fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
@@ -105,6 +118,18 @@ fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: Dest
}
val bgColor by collectBackgroundColorAsState()
val readingFailed = stringResource(R.string.error_reading_failed)
val uiController = rememberSystemUiController()
DisposableEffect(uiController) {
val lightStatusBar = uiController.statusBarDarkContentEnabled
onDispose {
uiController.statusBarDarkContentEnabled = lightStatusBar
}
}
LaunchedEffect(uiController) {
snapshotFlow { bgColor }.collect {
uiController.statusBarDarkContentEnabled = it == Color.White
}
}
Await(
{
Either.catch {
@@ -112,7 +137,7 @@ fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: Dest
}.fold({ it.displayString() }, { it })
},
placeholder = {
Box(Modifier.fillMaxSize().background(bgColor), contentAlignment = Alignment.Center) {
Background(bgColor) {
CircularProgressIndicator()
}
},
@@ -123,7 +148,7 @@ fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: Dest
ReaderScreen(pageLoader, info, navigator)
}
} else {
Box(Modifier.fillMaxSize().background(bgColor), contentAlignment = Alignment.Center) {
Background(bgColor) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
@@ -145,8 +170,10 @@ fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGall
.collect { setOrientation(it) }
}
LaunchedEffect(pageLoader) {
Settings.cropBorder.changesFlow().collect {
pageLoader.restart()
with(Settings) {
merge(cropBorder.changesFlow(), stripExtraneousAds.changesFlow()).collect {
pageLoader.restart()
}
}
}
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
@@ -191,57 +218,62 @@ fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGall
Settings.showNavigationOverlayNewUser.value = false
mutableStateOf(showOnStart)
}
GalleryPager(
type = readingMode,
pagerState = pagerState,
lazyListState = lazyListState,
pageLoader = pageLoader,
showNavigationOverlay = showNavigationOverlay,
onNavigationModeChange = { showNavigationOverlay = true },
onSelectPage = { page ->
if (Settings.readerLongTapAction.value) {
launch {
dialog { cont ->
fun dispose() = cont.resume(Unit)
val state = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = { dispose() },
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
sheetState = state,
contentWindowInsets = { WindowInsets(0) },
) {
ReaderPageSheetMeta(
retry = { pageLoader.retryPage(page.index) },
retryOrigin = { pageLoader.retryPage(page.index, true) },
share = { launchIO { with(pageLoader) { shareImage(page, info) } } },
copy = { launchIO { with(pageLoader) { copy(page) } } },
save = { launchIO { with(pageLoader) { save(page) } } },
saveTo = { launchIO { with(pageLoader) { saveTo(page) } } },
dismiss = { launch { state.hide().also { dispose() } } },
)
}
val onSelectPage = { page: Page ->
if (Settings.readerLongTapAction.value) {
launch {
val blocked = page.status is PageStatus.Blocked
dialog { cont ->
fun dispose() = cont.resume(Unit)
val state = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = { dispose() },
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
sheetState = state,
contentWindowInsets = { EmptyWindowInsets },
) {
ReaderPageSheetMeta(
retry = { pageLoader.retryPage(page.index) },
retryOrigin = { pageLoader.retryPage(page.index, true) },
share = { launchIO { with(pageLoader) { shareImage(page, info) } } },
copy = { launchIO { with(pageLoader) { copy(page) } } },
save = { launchIO { with(pageLoader) { save(page) } } },
saveTo = { launchIO { with(pageLoader) { saveTo(page) } } },
showAds = { page.unblock() }.takeIf { blocked },
dismiss = { launch { state.hide().also { dispose() } } },
)
}
}
}
},
onMenuRegionClick = { appbarVisible = !appbarVisible },
modifier = Modifier.background(bgColor).pointerInput(syncState) {
awaitEachGesture {
waitForUpOrCancellation()
syncState.reset()
showNavigationOverlay = false
}
},
contentPadding = if (fullscreen) {
}
}
EhTheme(useDarkTheme = bgColor != Color.White) {
val insets = if (fullscreen) {
if (cutoutShort) {
PaddingValues(0.dp)
EmptyWindowInsets
} else {
WindowInsets.displayCutout.asPaddingValues()
WindowInsets.displayCutout
}
} else {
WindowInsets.systemBars.asPaddingValues()
},
)
WindowInsets.systemBars
}
GalleryPager(
type = readingMode,
pagerState = pagerState,
lazyListState = lazyListState,
pageLoader = pageLoader,
showNavigationOverlay = showNavigationOverlay,
onNavigationModeChange = { showNavigationOverlay = true },
onSelectPage = onSelectPage,
onMenuRegionClick = { appbarVisible = !appbarVisible },
modifier = Modifier.background(bgColor).pointerInput(syncState) {
awaitEachGesture {
waitForUpOrCancellation()
syncState.reset()
showNavigationOverlay = false
}
}.fillMaxSize().windowInsetsPadding(insets),
)
}
val brightness by Settings.customBrightness.collectAsState()
val brightnessValue by Settings.customBrightnessValue.collectAsState()
val colorOverlayEnabled by Settings.colorFilter.collectAsState()
@@ -301,7 +333,7 @@ fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGall
// Yeah, I know color state should not be read here, but we have to do it...
scrimColor = scrim,
dragHandle = null,
contentWindowInsets = { WindowInsets(0) },
contentWindowInsets = { EmptyWindowInsets },
) {
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
isColorFilter = page == 2
@@ -322,7 +354,7 @@ private suspend fun preparePageLoader(args: ReaderScreenArgs) = when (args) {
val page = args.page
val archive = withIOContext { DownloadManager.getDownloadInfo(info.gid)?.archiveFile }
if (archive != null) {
ArchivePageLoader(archive, info.gid, page)
ArchivePageLoader(archive, info.gid, page, info.hasAds)
} else {
EhPageLoader(info, page)
}
@@ -12,14 +12,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageLoader2
import com.hippo.ehviewer.ui.main.plus
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import eu.kanade.tachiyomi.ui.reader.viewer.getAction
@@ -34,9 +34,8 @@ fun WebtoonViewer(
withGaps: Boolean,
pageLoader: PageLoader2,
navigator: () -> NavigationRegions,
onSelectPage: (ReaderPage) -> Unit,
onSelectPage: (Page) -> Unit,
onMenuRegionClick: () -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@@ -54,13 +53,12 @@ fun WebtoonViewer(
LazyColumn(
modifier = modifier.zoomable(
state = zoomableState,
onClick = {
onClick = { offset ->
scope.launch {
with(lazyListState) {
val size = layoutInfo.viewportSize
val x = it.x / size.width
val y = it.y / size.height
when (navigator().getAction(x, y)) {
val (w, h) = layoutInfo.viewportSize
val (x, y) = offset
when (navigator().getAction(Offset(x / w, y / h))) {
NavigationRegion.MENU -> onMenuRegionClick()
NavigationRegion.NEXT, NavigationRegion.RIGHT -> scrollDown()
NavigationRegion.PREV, NavigationRegion.LEFT -> scrollUp()
@@ -72,17 +70,14 @@ fun WebtoonViewer(
val info = lazyListState.layoutInfo.visibleItemsInfo.find { info ->
info.offset <= ofs.y && info.offset + info.size > ofs.y
}
if (info == null && withGaps) {
// Maybe user long-click on gaps? ¯\_(ツ)_/¯
return@zoomable
if (info != null) {
onSelectPage(items[info.index])
}
info ?: error("Internal error finding long click item!!! offset:$ofs")
onSelectPage(items[info.index])
},
onDoubleClick = DoubleTapZoom,
),
state = lazyListState,
contentPadding = contentPadding + PaddingValues(horizontal = sidePadding),
contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(if (withGaps) 15.dp else 0.dp),
) {
items(items, key = { it.index }) { page ->
@@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -107,6 +106,7 @@ import com.hippo.ehviewer.ui.main.plus
import com.hippo.ehviewer.ui.navToReader
import com.hippo.ehviewer.ui.showMoveDownloadLabelList
import com.hippo.ehviewer.ui.tools.Await
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
@@ -151,7 +151,10 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
val density = LocalDensity.current
val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(implicit<Context>()) && EhTagDatabase.initialized
val ehTags = EhTagDatabase.takeIf { canTranslate }
fun String.translateArtist() = ehTags?.getTranslation(TagNamespace.Artist.toPrefix(), this) ?: this
fun getTranslation(tag: String) = ehTags?.run {
getTranslation(TagNamespace.Artist.toPrefix(), tag)
?: getTranslation(TagNamespace.Cosplayer.toPrefix(), tag)
} ?: tag
val allName = stringResource(R.string.download_all)
val defaultName = stringResource(R.string.default_download_label_name)
val unknownName = stringResource(R.string.unknown_artists)
@@ -165,7 +168,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
when (label) {
"" -> allName
null -> emptyLabelName
else -> if (mode == DownloadsFilterMode.ARTIST) label.translateArtist() else label
else -> if (mode == DownloadsFilterMode.ARTIST) getTranslation(label) else label
}
},
)
@@ -215,7 +218,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
fun closeSheet() = launch { drawerState.close() }
TopAppBar(
title = { Text(text = labelsStr) },
windowInsets = WindowInsets(0, 0, 0, 0),
windowInsets = EmptyWindowInsets,
actions = {
if (DownloadsFilterMode.CUSTOM == filterMode) {
IconButton(
@@ -296,7 +299,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
}
var fromIndex by remember { mutableIntStateOf(-1) }
LazyColumn(
FastScrollLazyColumn(
modifier = Modifier.fillMaxSize(),
state = labelsListState,
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
@@ -337,7 +340,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
snapshotFlow { dismissState.currentValue }.collect {
if (it == SwipeToDismissBoxValue.EndToStart) {
runCatching {
awaitPermissionOrCancel(confirmText = R.string.delete) {
awaitConfirmationOrCancel(confirmText = R.string.delete) {
Text(text = stringResource(R.string.delete_label, item))
}
}.onSuccess {
@@ -380,7 +383,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
tonalElevation = 1.dp,
shadowElevation = elevation,
headlineContent = {
val name = if (filterMode == DownloadsFilterMode.ARTIST) label.translateArtist() else label
val name = if (filterMode == DownloadsFilterMode.ARTIST) getTranslation(label) else label
Text("$name [${downloadsCount.getOrDefault(item, 0)}]")
},
trailingContent = editEnable.ifTrueThen {
@@ -486,7 +489,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
onClick = {
expanded = false
launchIO {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = android.R.string.ok,
dismissText = android.R.string.cancel,
) {
@@ -648,7 +651,9 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
) {
if (!selectMode) {
onClick(Icons.Default.Shuffle) {
withUIContext { navToReader(list.random().galleryInfo) }
if (list.isNotEmpty()) {
withUIContext { navToReader(list.random().galleryInfo) }
}
}
onClick(Icons.AutoMirrored.Default.Sort) {
val oldMode = SortMode.from(sortMode)
@@ -74,6 +74,7 @@ import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
import com.hippo.ehviewer.ui.main.GalleryList
import com.hippo.ehviewer.ui.startDownload
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
import com.hippo.ehviewer.ui.tools.foldToLoadResult
import com.hippo.ehviewer.ui.tools.rememberInVM
@@ -185,7 +186,7 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator) =
val localFavCount by localFavCountFlow.collectAsState(0)
TopAppBar(
title = { Text(text = stringResource(id = R.string.collections)) },
windowInsets = WindowInsets(0, 0, 0, 0),
windowInsets = EmptyWindowInsets,
)
val scope = currentRecomposeScope
LaunchedEffect(Unit) {
@@ -356,8 +357,9 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator) =
if (!selectMode) {
if (isLocalFav) {
onClick(Icons.Default.Shuffle) {
val random = EhDB.randomLocalFav()
withUIContext { navigate(random.asDst()) }
EhDB.randomLocalFav()?.let { info ->
withUIContext { navigate(info.asDst()) }
}
}
}
onClick(EhIcons.Default.GoTo) {
@@ -383,7 +385,7 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator) =
}
onClick(Icons.Default.Delete) {
val info = checkedInfoMap.takeAndClear()
awaitPermissionOrCancel(title = R.string.delete_favorites_dialog_title) {
awaitConfirmationOrCancel(title = R.string.delete_favorites_dialog_title) {
Text(text = stringResource(R.string.delete_favorites_dialog_message, info.size))
}
val srcCat = urlBuilder.favCat
@@ -211,7 +211,7 @@ fun AnimatedVisibilityScope.GalleryCommentsScreen(gid: Long, navigator: Destinat
val filterAdded = stringResource(R.string.filter_added)
suspend fun showFilterCommenter(comment: GalleryComment) {
val commenter = comment.user ?: return
awaitPermissionOrCancel { Text(text = stringResource(R.string.filter_the_commenter, commenter)) }
awaitConfirmationOrCancel { Text(text = stringResource(R.string.filter_the_commenter, commenter)) }
Filter(FilterMode.COMMENTER, commenter).remember()
comments = comments.copy(comments = comments.comments.filterNot { it.user == commenter })
showSnackbar(filterAdded)
@@ -0,0 +1,801 @@
package com.hippo.ehviewer.ui.screen
import android.Manifest
import android.content.Context
import android.os.Environment
import android.text.TextUtils.TruncateAt.END
import androidx.annotation.StringRes
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ImageSearch
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SwapVerticalCircle
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.LocalPinnableContainer
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.cachedIn
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import androidx.window.core.layout.WindowWidthSizeClass
import arrow.core.partially1
import arrow.fx.coroutines.parMap
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhFilter.remember
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.GalleryTagGroup
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.client.data.asGalleryDetail
import com.hippo.ehviewer.client.data.findBaseInfo
import com.hippo.ehviewer.coil.justDownload
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ktbuilder.launchIn
import com.hippo.ehviewer.ui.GalleryInfoBottomSheet
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.confirmRemoveDownload
import com.hippo.ehviewer.ui.destinations.GalleryCommentsScreenDestination
import com.hippo.ehviewer.ui.getFavoriteIcon
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.main.EhPreviewItem
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip
import com.hippo.ehviewer.ui.main.GalleryDetailHeaderCard
import com.hippo.ehviewer.ui.main.GalleryTags
import com.hippo.ehviewer.ui.main.TorrentList
import com.hippo.ehviewer.ui.modifyFavorites
import com.hippo.ehviewer.ui.navToReader
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.startDownload
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconToggleButton
import com.hippo.ehviewer.ui.tools.GalleryDetailRating
import com.hippo.ehviewer.ui.tools.GalleryRatingBar
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.foldToLoadResult
import com.hippo.ehviewer.ui.tools.getClippedRefreshKey
import com.hippo.ehviewer.ui.tools.getLimit
import com.hippo.ehviewer.ui.tools.getOffset
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.bgWork
import com.hippo.ehviewer.util.flattenForEach
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.requestPermission
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import splitties.systemservices.downloadManager
context(CoroutineScope, DestinationsNavigator, DialogState, MainActivity, SnackbarHostState, SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun GalleryDetailContent(
galleryInfo: GalleryInfo,
contentPadding: PaddingValues,
getDetailError: String,
onRetry: () -> Unit,
modifier: Modifier,
) {
val keylineMargin = dimensionResource(R.dimen.keyline_margin)
val galleryDetail = galleryInfo.asGalleryDetail()
val windowSizeClass = LocalWindowSizeClass.current
val thumbColumns by Settings.thumbColumns.collectAsState()
val readText = stringResource(R.string.read)
val startPage by rememberInVM {
EhDB.getReadProgressFlow(galleryInfo.gid)
}.collectAsState(0)
val readButtonText = if (startPage == 0) {
readText
} else {
stringResource(R.string.read_from, startPage + 1)
}
val downloadState by DownloadManager.collectDownloadState(galleryInfo.gid)
val downloadButtonText = when (downloadState) {
DownloadInfo.STATE_INVALID -> stringResource(R.string.download)
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
DownloadInfo.STATE_DOWNLOAD -> stringResource(R.string.download_state_downloading)
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_downloaded)
DownloadInfo.STATE_FAILED -> stringResource(R.string.download_state_failed)
else -> error("Invalid DownloadState!!!")
}
fun onReadButtonClick() {
if (galleryDetail != null || downloadState != DownloadInfo.STATE_INVALID) {
navToReader(galleryInfo.findBaseInfo(), startPage)
}
}
fun onCategoryChipClick() {
val category = galleryInfo.category
if (category == EhUtils.NONE || category == EhUtils.PRIVATE || category == EhUtils.UNKNOWN) {
return
}
navigate(ListUrlBuilder(category = category).asDst())
}
fun onUploaderChipClick(galleryInfo: GalleryInfo) {
val uploader = galleryInfo.uploader
val disowned = uploader == "(Disowned)"
if (uploader.isNullOrEmpty() || disowned) {
return
}
navigate(ListUrlBuilder(mode = ListUrlBuilder.MODE_UPLOADER, mKeyword = uploader).asDst())
}
fun onGalleryInfoCardClick() {
galleryDetail ?: return
launch {
dialog { cont ->
ModalBottomSheet(
onDismissRequest = { cont.cancel() },
contentWindowInsets = { EmptyWindowInsets },
) {
GalleryInfoBottomSheet(galleryDetail)
}
}
}
}
val filterAdded = stringResource(R.string.filter_added)
fun showFilterUploaderDialog(galleryInfo: GalleryInfo) {
val uploader = galleryInfo.uploader
val disowned = uploader == "(Disowned)"
if (uploader.isNullOrEmpty() || disowned) {
return
}
launchIO {
awaitConfirmationOrCancel {
Text(text = stringResource(R.string.filter_the_uploader, uploader))
}
Filter(FilterMode.UPLOADER, uploader).remember()
showSnackbar(filterAdded)
}
}
fun onDownloadButtonClick() {
galleryDetail ?: return
if (DownloadManager.getDownloadState(galleryDetail.gid) == DownloadInfo.STATE_INVALID) {
launchUI { startDownload(implicit<MainActivity>(), false, galleryDetail.galleryInfo) }
} else {
launch { confirmRemoveDownload(galleryDetail) }
}
}
val previews = galleryDetail?.let { collectPreviewItems(it, thumbColumns) }
when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.COMPACT -> FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
contentPadding = contentPadding,
modifier = modifier.padding(horizontal = keylineMargin),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
) {
item(
key = "header",
span = { GridItemSpan(maxCurrentLineSpan) },
contentType = "header",
) {
GalleryDetailHeaderCard(
info = galleryInfo,
onInfoCardClick = ::onGalleryInfoCardClick,
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
onCategoryChipClick = ::onCategoryChipClick,
modifier = Modifier.fillMaxWidth().padding(vertical = keylineMargin),
)
}
item(
key = "body",
span = { GridItemSpan(maxCurrentLineSpan) },
contentType = "body",
) {
LocalPinnableContainer.current!!.run { remember { pin() } }
Column {
Row {
FilledTonalButton(
onClick = ::onDownloadButtonClick,
modifier = Modifier.padding(horizontal = 4.dp).weight(1F),
) {
Text(text = downloadButtonText, maxLines = 1)
}
Button(
onClick = ::onReadButtonClick,
modifier = Modifier.padding(horizontal = 4.dp).weight(1F),
) {
Text(text = readButtonText, maxLines = 1)
}
}
if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = onRetry)
} else if (galleryDetail != null) {
BelowHeader(galleryDetail)
} else {
Box(
modifier = Modifier.fillMaxSize().padding(keylineMargin),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
if (previews != null) {
galleryPreview(previews) { navToReader(galleryDetail.galleryInfo, it) }
}
}
WindowWidthSizeClass.EXPANDED -> FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
contentPadding = contentPadding,
modifier = modifier.padding(horizontal = keylineMargin),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
) {
item(
key = "header",
span = { GridItemSpan(maxCurrentLineSpan) },
contentType = "header",
) {
Row(modifier = Modifier.fillMaxWidth()) {
GalleryDetailHeaderCard(
info = galleryInfo,
onInfoCardClick = ::onGalleryInfoCardClick,
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
onCategoryChipClick = ::onCategoryChipClick,
modifier = Modifier.width(dimensionResource(id = R.dimen.gallery_detail_card_landscape_width)).padding(vertical = keylineMargin),
)
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = modifier.height(16.dp))
Button(
onClick = ::onReadButtonClick,
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
) {
Text(text = readButtonText, maxLines = 1)
}
Spacer(modifier = modifier.height(24.dp))
FilledTonalButton(
onClick = ::onDownloadButtonClick,
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
) {
Text(text = downloadButtonText, maxLines = 1)
}
}
}
}
item(
key = "body",
span = { GridItemSpan(maxCurrentLineSpan) },
contentType = "body",
) {
LocalPinnableContainer.current!!.run { remember { pin() } }
Column {
if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = onRetry)
} else if (galleryDetail != null) {
BelowHeader(galleryDetail)
} else {
Box(
modifier = Modifier.fillMaxSize().padding(keylineMargin),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
if (previews != null) {
galleryPreview(previews) { navToReader(galleryDetail.galleryInfo, it) }
}
}
}
}
context(Context, CoroutineScope, DestinationsNavigator, DialogState, SnackbarHostState)
@Composable
fun BelowHeader(galleryDetail: GalleryDetail) {
@Composable
fun EhIconButton(
icon: ImageVector,
text: String,
onClick: () -> Unit,
) = Column(horizontalAlignment = Alignment.CenterHorizontally) {
FilledTertiaryIconButton(onClick = onClick) {
Icon(
imageVector = icon,
contentDescription = null,
)
}
Text(text = text)
}
@Composable
fun GalleryDetailComment(commentsList: List<GalleryComment>) {
val maxShowCount = 2
val commentText = when {
commentsList.isEmpty() -> stringResource(R.string.no_comments)
commentsList.size <= maxShowCount -> stringResource(R.string.no_more_comments)
else -> stringResource(R.string.more_comment)
}
fun onNavigateToCommentScene() {
navigate(GalleryCommentsScreenDestination(galleryDetail.gid))
}
CrystalCard {
commentsList.take(maxShowCount).forEach { item ->
GalleryCommentCard(
modifier = Modifier.padding(vertical = 4.dp),
comment = item,
onCardClick = ::onNavigateToCommentScene,
onUserClick = ::onNavigateToCommentScene,
onUrlClick = { if (!jumpToReaderByPage(it, galleryDetail)) if (!navWithUrl(it)) openBrowser(it) },
) {
maxLines = 5
ellipsize = END
text = item.comment.parseAsHtml(imageGetter = CoilImageGetter(this))
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = dimensionResource(id = R.dimen.strip_item_padding_v))
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = ::onNavigateToCommentScene),
contentAlignment = Alignment.Center,
) {
Text(commentText)
}
}
}
suspend fun showNewerVersionDialog() {
val items = galleryDetail.newerVersions.map {
getString(R.string.newer_version_title, it.title, it.posted)
}
val selected = awaitSelectItem(items)
val info = galleryDetail.newerVersions[selected]
withUIContext {
// Can't use GalleryInfoArgs as thumbKey is null
navigate(info.gid asDstWith info.token)
}
}
val keylineMargin = dimensionResource(R.dimen.keyline_margin)
Spacer(modifier = Modifier.size(keylineMargin))
if (galleryDetail.newerVersions.isNotEmpty()) {
Box(contentAlignment = Alignment.Center) {
CrystalCard(
onClick = { launchIO { showNewerVersionDialog() } },
modifier = Modifier.fillMaxWidth().height(32.dp),
) {
}
Text(text = stringResource(id = R.string.newer_version_available))
}
Spacer(modifier = Modifier.size(keylineMargin))
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
) {
val favSlot by FavouriteStatusRouter.collectAsState(galleryDetail) { it }
val favButtonText = if (favSlot != NOT_FAVORITED) {
galleryDetail.favoriteName ?: stringResource(id = R.string.local_favorites)
} else {
stringResource(id = R.string.not_favorited)
}
val favoritesLock = remember { MutatorMutex() }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val removeSucceed = stringResource(R.string.remove_from_favorite_success)
val addSucceed = stringResource(R.string.add_to_favorite_success)
// val removeFailed = stringResource(R.string.remove_from_favorite_failure)
val addFailed = stringResource(R.string.add_to_favorite_failure)
FilledTertiaryIconToggleButton(
checked = favSlot != NOT_FAVORITED,
onCheckedChange = {
launchIO {
favoritesLock.mutate {
runSuspendCatching {
modifyFavorites(galleryDetail.galleryInfo)
}.onSuccess { add ->
if (add) {
showSnackbar(addSucceed)
} else {
showSnackbar(removeSucceed)
}
}.onFailure {
// TODO: We don't know if it's add or remove
showSnackbar(addFailed)
}
}
}
},
) {
Icon(
imageVector = getFavoriteIcon(favSlot != NOT_FAVORITED),
contentDescription = null,
)
}
Text(text = favButtonText)
}
EhIconButton(
icon = Icons.Default.Search,
text = stringResource(id = R.string.similar_gallery),
onClick = {
val keyword = EhUtils.extractTitle(galleryDetail.title)
val artistTag = galleryDetail.tags.getArtistTag()
if (null != keyword) {
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_NORMAL,
mKeyword = "\"" + keyword + "\"",
).asDst(),
)
} else if (artistTag != null) {
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_TAG,
mKeyword = artistTag,
).asDst(),
)
} else if (null != galleryDetail.uploader) {
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_UPLOADER,
mKeyword = galleryDetail.uploader,
).asDst(),
)
}
},
)
EhIconButton(
icon = Icons.Default.ImageSearch,
text = stringResource(id = R.string.search_cover),
onClick = {
val key = galleryDetail.thumbKey!!
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
hash = key.substringAfterLast('/').substringBefore('-'),
).asDst(),
)
},
)
val torrentText = stringResource(R.string.torrent_count, galleryDetail.torrentCount)
val permissionDenied = stringResource(R.string.permission_denied)
val downloadTorrentFailed = stringResource(R.string.download_torrent_failure)
val downloadTorrentStarted = stringResource(R.string.download_torrent_started)
val noTorrents = stringResource(R.string.no_torrents)
val torrentResult = remember(galleryDetail) {
async(Dispatchers.IO + Job(), CoroutineStart.LAZY) {
EhEngine.getTorrentList(galleryDetail.torrentUrl!!, galleryDetail.gid, galleryDetail.token)
}
}
suspend fun showTorrentDialog() {
val torrentList = bgWork { torrentResult.await() }
if (torrentList.isEmpty()) {
showSnackbar(noTorrents)
} else {
val selected = showNoButton(false) {
TorrentList(
items = torrentList,
onItemClick = { dismissWith(it) },
)
}
val url = selected.url
val name = "${selected.name}.torrent"
val r = android.app.DownloadManager.Request(url.toUri())
r.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
)
r.setNotificationVisibility(android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
EhCookieStore.getCookieHeader(url)?.let { r.addRequestHeader("Cookie", it) }
downloadManager.enqueue(r)
showSnackbar(downloadTorrentStarted)
}
}
EhIconButton(
icon = Icons.Default.SwapVerticalCircle,
text = torrentText,
onClick = {
launchIO {
if (galleryDetail.torrentCount > 0) {
val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
if (granted) {
runSuspendCatching {
showTorrentDialog()
}.onFailure {
logcat(it)
showSnackbar(downloadTorrentFailed)
}
} else {
showSnackbar(permissionDenied)
}
} else {
showSnackbar(noTorrents)
}
}
},
)
}
Spacer(modifier = Modifier.size(keylineMargin))
fun getAllRatingText(rating: Float, ratingCount: Int): String = getString(
R.string.rating_text,
getString(getRatingText(rating)),
rating,
ratingCount,
)
var ratingText by rememberSaveable {
mutableStateOf(getAllRatingText(galleryDetail.rating, galleryDetail.ratingCount))
}
val rateSucceed = stringResource(R.string.rate_successfully)
val rateFailed = stringResource(R.string.rate_failed)
val signInFirst = stringResource(R.string.sign_in_first)
fun showRateDialog() {
launchIO {
if (galleryDetail.apiUid < 0) {
showSnackbar(signInFirst)
return@launchIO
}
val pendingRating = awaitResult(galleryDetail.rating.coerceAtLeast(.5f), title = R.string.rate) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
var text by remember { mutableIntStateOf(getRatingText(expectedValue)) }
Text(text = stringResource(id = text), style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.size(keylineMargin))
GalleryRatingBar(
rating = expectedValue,
onRatingChange = {
expectedValue = it.coerceAtLeast(.5f)
text = getRatingText(expectedValue)
},
)
}
}
galleryDetail.runSuspendCatching {
EhEngine.rateGallery(apiUid, apiKey, gid, token, pendingRating)
}.onSuccess { result ->
galleryDetail.apply {
rating = result.rating
ratingCount = result.ratingCount
}
ratingText = getAllRatingText(result.rating, result.ratingCount)
showSnackbar(rateSucceed)
}.onFailure {
logcat(it)
showSnackbar(rateFailed)
}
}
}
CrystalCard(onClick = ::showRateDialog) {
Column(
modifier = Modifier.padding(8.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
GalleryDetailRating(rating = galleryDetail.rating)
Spacer(modifier = Modifier.size(keylineMargin))
Text(text = ratingText)
}
}
Spacer(modifier = Modifier.size(keylineMargin))
val tags = galleryDetail.tags
if (tags.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(text = stringResource(id = R.string.no_tags))
}
} else {
val copy = stringResource(android.R.string.copy)
val copyTrans = stringResource(R.string.copy_trans)
val showDefine = stringResource(R.string.show_definition)
val addFilter = stringResource(R.string.add_filter)
val filterAdded = stringResource(R.string.filter_added)
val upTag = stringResource(R.string.tag_vote_up)
val downTag = stringResource(R.string.tag_vote_down)
GalleryTags(
tagGroups = tags,
onTagClick = {
navigate(ListUrlBuilder(mode = ListUrlBuilder.MODE_TAG, mKeyword = it).asDst())
},
onTagLongClick = { translated, tag ->
val index = tag.indexOf(':')
val temp = if (index >= 0) {
tag.substring(index + 1)
} else {
tag
}
launchIO {
awaitSelectAction {
onSelect(copy) {
addTextToClipboard(tag)
}
if (temp != translated) {
onSelect(copyTrans) {
addTextToClipboard(translated)
}
}
onSelect(showDefine) {
openBrowser(EhUrl.getTagDefinitionUrl(temp))
}
onSelect(addFilter) {
awaitConfirmationOrCancel { Text(text = stringResource(R.string.filter_the_tag, tag)) }
Filter(FilterMode.TAG, tag).remember()
showSnackbar(filterAdded)
}
if (galleryDetail.apiUid >= 0) {
onSelect(upTag) { galleryDetail.voteTag(tag, 1) }
onSelect(downTag) { galleryDetail.voteTag(tag, -1) }
}
}()
}
},
)
}
Spacer(modifier = Modifier.size(keylineMargin))
if (Settings.showComments) {
GalleryDetailComment(galleryDetail.comments.comments)
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.strip_item_padding_v)))
}
}
@StringRes
private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt()) {
0 -> R.string.rating0
1 -> R.string.rating1
2 -> R.string.rating2
3 -> R.string.rating3
4 -> R.string.rating4
5 -> R.string.rating5
6 -> R.string.rating6
7 -> R.string.rating7
8 -> R.string.rating8
9 -> R.string.rating9
10 -> R.string.rating10
else -> R.string.rating_none
}
private fun List<GalleryTagGroup>.getArtistTag(): String? {
for (tagGroup in this) {
if (tagGroup.isNotEmpty()) {
val namespace = tagGroup.groupName
if (namespace == TagNamespace.Artist.value || namespace == TagNamespace.Cosplayer.value) {
return "$namespace:${tagGroup[0].removePrefix("_")}"
}
}
}
return null
}
@Composable
private fun Context.collectPreviewItems(detail: GalleryDetail, prefetchDistance: Int) = rememberInVM {
val pageSize = detail.previewList.size
val pages = detail.pages
val previewPagesMap = detail.previewList.associateBy { it.position } as MutableMap
Pager(
PagingConfig(
pageSize = pageSize,
prefetchDistance = prefetchDistance,
initialLoadSize = pageSize,
jumpThreshold = 2 * pageSize,
),
) {
object : PagingSource<Int, GalleryPreview>() {
override fun getRefreshKey(state: PagingState<Int, GalleryPreview>) = state.getClippedRefreshKey()
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GalleryPreview> = withIOContext {
val key = params.key ?: 0
val up = getOffset(params, key, pages)
val end = (up + getLimit(params, key) - 1).coerceAtMost(pages - 1)
detail.runSuspendCatching {
(up..end).filterNot { it in previewPagesMap }.map { it / pageSize }.toSet()
.parMap(concurrency = Settings.multiThreadDownload) { page ->
val url = EhUrl.getGalleryDetailUrl(gid, token, page, false)
EhEngine.getPreviewList(url).first
}.flattenForEach {
previewPagesMap[it.position] = it
if (Settings.preloadThumbAggressively) {
imageRequest(it) { justDownload() }.launchIn(viewModelScope)
}
}
}.foldToLoadResult {
val r = (up..end).map { requireNotNull(previewPagesMap[it]) }
val prevK = if (up <= 0 || r.isEmpty()) null else up
val nextK = if (end == pages - 1) null else end + 1
LoadResult.Page(r, prevK, nextK, up, pages - end - 1)
}
}
override val jumpingSupported = true
}
}.flow.cachedIn(viewModelScope)
}.collectAsLazyPagingItems()
private fun LazyGridScope.galleryPreview(data: LazyPagingItems<GalleryPreview>, onClick: (Int) -> Unit) {
items(
count = data.itemCount,
key = data.itemKey(key = { item -> item.position }),
contentType = { "preview" },
) { index ->
val item = data[index]
EhPreviewItem(item, index) { onClick(index) }
}
}
@@ -1,172 +1,92 @@
package com.hippo.ehviewer.ui.screen
import android.Manifest
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Environment
import android.os.Parcelable
import android.text.TextUtils.TruncateAt.END
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.FolderZip
import androidx.compose.material.icons.filled.ImageSearch
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.SwapVerticalCircle
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.LocalPinnableContainer
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml
import androidx.window.core.layout.WindowWidthSizeClass
import arrow.core.partially1
import coil3.imageLoader
import com.hippo.ehviewer.EhApplication.Companion.galleryDetailCache
import com.hippo.ehviewer.EhApplication.Companion.imageCache
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhFilter.remember
import com.hippo.ehviewer.client.EhUrl
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.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import com.hippo.ehviewer.client.data.GalleryTagGroup
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.client.data.asGalleryDetail
import com.hippo.ehviewer.client.data.findBaseInfo
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.NoHAtHClientException
import com.hippo.ehviewer.client.getImageKey
import com.hippo.ehviewer.coil.justDownload
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
import com.hippo.ehviewer.download.DownloadManager as EhDownloadManager
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.spider.SpiderDen
import com.hippo.ehviewer.ui.GalleryInfoBottomSheet
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.confirmRemoveDownload
import com.hippo.ehviewer.ui.destinations.GalleryCommentsScreenDestination
import com.hippo.ehviewer.ui.destinations.GalleryPreviewScreenDestination
import com.hippo.ehviewer.ui.getFavoriteIcon
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.main.ArchiveList
import com.hippo.ehviewer.ui.main.EhPreviewItem
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip
import com.hippo.ehviewer.ui.main.GalleryDetailHeaderCard
import com.hippo.ehviewer.ui.main.GalleryTags
import com.hippo.ehviewer.ui.main.TorrentList
import com.hippo.ehviewer.ui.modifyFavorites
import com.hippo.ehviewer.ui.navToReader
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.startDownload
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconToggleButton
import com.hippo.ehviewer.ui.tools.GalleryDetailRating
import com.hippo.ehviewer.ui.tools.GalleryRatingBar
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.ui.tools.rememberLambda
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.AppHelper
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.awaitActivityResult
import com.hippo.ehviewer.util.bgWork
import com.hippo.ehviewer.util.displayString
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.requestPermission
import com.hippo.files.delete
import com.hippo.files.toOkioPath
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.launchUI
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import moe.tarsin.coroutines.runSuspendCatching
import splitties.systemservices.downloadManager
@@ -185,47 +105,20 @@ data class TokenArgs(
val page: Int = 0,
) : GalleryDetailScreenArgs
@StringRes
private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt()) {
0 -> R.string.rating0
1 -> R.string.rating1
2 -> R.string.rating2
3 -> R.string.rating3
4 -> R.string.rating4
5 -> R.string.rating5
6 -> R.string.rating6
7 -> R.string.rating7
8 -> R.string.rating8
9 -> R.string.rating9
10 -> R.string.rating10
else -> R.string.rating_none
}
private fun List<GalleryTagGroup>.getArtistTag(): String? {
val namespace = TagNamespace.Artist.value
for (tagGroup in this) {
if (tagGroup.groupName == namespace && tagGroup.size > 0) {
return "$namespace:${tagGroup[0].removePrefix("_")}"
}
}
return null
}
@Destination<RootGraph>
@Composable
fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
var galleryInfo by remember {
var galleryInfo by rememberInVM {
val casted = args as? GalleryInfoArgs
mutableStateOf<GalleryInfo?>(casted?.galleryInfo)
}
val (gid, token) = remember {
when (args) {
is GalleryInfoArgs -> args.galleryInfo.run { gid to token }
is GalleryInfoArgs -> with(args.galleryInfo) { gid to token }
is TokenArgs -> args.gid to args.token
}
}
val galleryDetailUrl = remember { EhUrl.getGalleryDetailUrl(gid, token, 0, false) }
val activity = remember { findActivity<MainActivity>() }
var showReadAction by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(args, galleryInfo) {
if (showReadAction) {
@@ -244,15 +137,10 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
}
}
}
with(activity) {
ProvideAssistContent(galleryDetailUrl)
}
ProvideAssistContent(galleryDetailUrl)
var getDetailError by rememberSaveable { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val voteSuccess = stringResource(R.string.tag_vote_successfully)
val voteFailed = stringResource(R.string.vote_failed)
if (galleryInfo !is GalleryDetail && getDetailError.isBlank()) {
LaunchedEffect(Unit) {
val galleryDetail = galleryDetailCache[gid]
@@ -278,20 +166,6 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
}
}
suspend fun GalleryDetail.voteTag(tag: String, vote: Int) {
runSuspendCatching {
EhEngine.voteTag(apiUid, apiKey, gid, token, tag, vote)
}.onSuccess { result ->
if (result != null) {
showSnackbar(result)
} else {
showSnackbar(voteSuccess)
}
}.onFailure {
showSnackbar(voteFailed)
}
}
val archiveResult = remember(galleryInfo) {
async(Dispatchers.IO + Job(), CoroutineStart.LAZY) {
val detail = galleryInfo as GalleryDetail
@@ -330,7 +204,7 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
}
val name = "$gid-${EhUtils.getSuitableTitle(galleryDetail)}.zip"
try {
activity.startActivity(intent)
startActivity(intent)
withUIContext { addTextToClipboard(name, true) }
} catch (_: ActivityNotFoundException) {
val r = DownloadManager.Request(uri)
@@ -364,592 +238,6 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
}
}
val keylineMargin = dimensionResource(R.dimen.keyline_margin)
fun navigateToPreview(nextPage: Boolean = false) {
(galleryInfo as? GalleryDetail)?.let {
navigator.navigate(GalleryPreviewScreenDestination(it, nextPage))
}
}
fun LazyGridScope.galleryDetailPreview(gd: GalleryDetail) {
val previewList = gd.previewList
items(previewList) {
EhPreviewItem(
galleryPreview = it,
position = it.position,
onClick = { navToReader(gd.galleryInfo, it.position) },
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
val footerText = if (gd.previewPages <= 0 || previewList.isEmpty()) {
stringResource(R.string.no_previews)
} else if (gd.previewPages == 1) {
stringResource(R.string.no_more_previews)
} else {
stringResource(R.string.more_previews)
}
TextButton(onClick = ::navigateToPreview.partially1(true)) {
Text(footerText)
}
}
}
@Composable
fun BelowHeader(galleryDetail: GalleryDetail) {
@Composable
fun EhIconButton(
icon: ImageVector,
text: String,
onClick: () -> Unit,
) = Column(horizontalAlignment = Alignment.CenterHorizontally) {
FilledTertiaryIconButton(onClick = onClick) {
Icon(
imageVector = icon,
contentDescription = null,
)
}
Text(text = text)
}
@Composable
fun GalleryDetailComment(commentsList: List<GalleryComment>) {
val maxShowCount = 2
val commentText = when {
commentsList.isEmpty() -> stringResource(R.string.no_comments)
commentsList.size <= maxShowCount -> stringResource(R.string.no_more_comments)
else -> stringResource(R.string.more_comment)
}
fun onNavigateToCommentScene() {
navigator.navigate(GalleryCommentsScreenDestination(galleryDetail.gid))
}
CrystalCard {
commentsList.take(maxShowCount).forEach { item ->
GalleryCommentCard(
modifier = Modifier.padding(vertical = 4.dp),
comment = item,
onCardClick = ::onNavigateToCommentScene,
onUserClick = ::onNavigateToCommentScene,
onUrlClick = { if (!jumpToReaderByPage(it, galleryDetail)) if (!navWithUrl(it)) openBrowser(it) },
) {
maxLines = 5
ellipsize = END
text = item.comment.parseAsHtml(imageGetter = CoilImageGetter(this))
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = dimensionResource(id = R.dimen.strip_item_padding_v))
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = ::onNavigateToCommentScene),
contentAlignment = Alignment.Center,
) {
Text(commentText)
}
}
}
suspend fun showNewerVersionDialog() {
val items = galleryDetail.newerVersions.map {
getString(R.string.newer_version_title, it.title, it.posted)
}
val selected = awaitSelectItem(items)
val info = galleryDetail.newerVersions[selected]
withUIContext {
// Can't use GalleryInfoArgs as thumbKey is null
navigator.navigate(info.gid asDstWith info.token)
}
}
Spacer(modifier = Modifier.size(keylineMargin))
if (galleryDetail.newerVersions.isNotEmpty()) {
Box(contentAlignment = Alignment.Center) {
CrystalCard(
onClick = { launchIO { showNewerVersionDialog() } },
modifier = Modifier.fillMaxWidth().height(32.dp),
) {
}
Text(text = stringResource(id = R.string.newer_version_available))
}
Spacer(modifier = Modifier.size(keylineMargin))
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
) {
val favSlot by FavouriteStatusRouter.collectAsState(galleryDetail) { it }
val favButtonText = if (favSlot != NOT_FAVORITED) {
galleryDetail.favoriteName ?: stringResource(id = R.string.local_favorites)
} else {
stringResource(id = R.string.not_favorited)
}
val favoritesLock = remember { MutatorMutex() }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val removeSucceed = stringResource(R.string.remove_from_favorite_success)
val addSucceed = stringResource(R.string.add_to_favorite_success)
// val removeFailed = stringResource(R.string.remove_from_favorite_failure)
val addFailed = stringResource(R.string.add_to_favorite_failure)
FilledTertiaryIconToggleButton(
checked = favSlot != NOT_FAVORITED,
onCheckedChange = {
launchIO {
favoritesLock.mutate {
runSuspendCatching {
modifyFavorites(galleryDetail.galleryInfo)
}.onSuccess { add ->
if (add) {
showSnackbar(addSucceed)
} else {
showSnackbar(removeSucceed)
}
}.onFailure {
// TODO: We don't know if it's add or remove
showSnackbar(addFailed)
}
}
}
},
) {
Icon(
imageVector = getFavoriteIcon(favSlot != NOT_FAVORITED),
contentDescription = null,
)
}
Text(text = favButtonText)
}
EhIconButton(
icon = Icons.Default.Search,
text = stringResource(id = R.string.similar_gallery),
onClick = {
val keyword = EhUtils.extractTitle(galleryDetail.title)
val artistTag = galleryDetail.tags.getArtistTag()
if (null != keyword) {
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_NORMAL,
mKeyword = "\"" + keyword + "\"",
).asDst(),
)
} else if (artistTag != null) {
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_TAG,
mKeyword = artistTag,
).asDst(),
)
} else if (null != galleryDetail.uploader) {
navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_UPLOADER,
mKeyword = galleryDetail.uploader,
).asDst(),
)
}
},
)
EhIconButton(
icon = Icons.Default.ImageSearch,
text = stringResource(id = R.string.search_cover),
onClick = {
val key = galleryDetail.thumbKey!!
navigator.navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
hash = key.substringAfterLast('/').substringBefore('-'),
).asDst(),
)
},
)
val torrentText = stringResource(R.string.torrent_count, galleryDetail.torrentCount)
val permissionDenied = stringResource(R.string.permission_denied)
val downloadTorrentFailed = stringResource(R.string.download_torrent_failure)
val downloadTorrentStarted = stringResource(R.string.download_torrent_started)
val noTorrents = stringResource(R.string.no_torrents)
val torrentResult = remember(galleryDetail) {
async(Dispatchers.IO + Job(), CoroutineStart.LAZY) {
EhEngine.getTorrentList(galleryDetail.torrentUrl!!, gid, token)
}
}
suspend fun showTorrentDialog() {
val torrentList = bgWork { torrentResult.await() }
if (torrentList.isEmpty()) {
showSnackbar(noTorrents)
} else {
val selected = showNoButton(false) {
TorrentList(
items = torrentList,
onItemClick = { dismissWith(it) },
)
}
val url = selected.url
val name = "${selected.name}.torrent"
val r = DownloadManager.Request(url.toUri())
r.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
)
r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
EhCookieStore.getCookieHeader(url)?.let { r.addRequestHeader("Cookie", it) }
downloadManager.enqueue(r)
showSnackbar(downloadTorrentStarted)
}
}
EhIconButton(
icon = Icons.Default.SwapVerticalCircle,
text = torrentText,
onClick = {
launchIO {
if (galleryDetail.torrentCount > 0) {
val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
if (granted) {
runSuspendCatching {
showTorrentDialog()
}.onFailure {
logcat(it)
showSnackbar(downloadTorrentFailed)
}
} else {
showSnackbar(permissionDenied)
}
} else {
showSnackbar(noTorrents)
}
}
},
)
}
Spacer(modifier = Modifier.size(keylineMargin))
fun getAllRatingText(rating: Float, ratingCount: Int): String = getString(
R.string.rating_text,
getString(getRatingText(rating)),
rating,
ratingCount,
)
var ratingText by rememberSaveable {
mutableStateOf(getAllRatingText(galleryDetail.rating, galleryDetail.ratingCount))
}
val rateSucceed = stringResource(R.string.rate_successfully)
val rateFailed = stringResource(R.string.rate_failed)
fun showRateDialog() {
launchIO {
if (galleryDetail.apiUid < 0) {
showSnackbar(signInFirst)
return@launchIO
}
val pendingRating = awaitResult(galleryDetail.rating.coerceAtLeast(.5f), title = R.string.rate) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
var text by remember { mutableIntStateOf(getRatingText(expectedValue)) }
Text(text = stringResource(id = text), style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.size(keylineMargin))
GalleryRatingBar(
rating = expectedValue,
onRatingChange = {
expectedValue = it.coerceAtLeast(.5f)
text = getRatingText(expectedValue)
},
)
}
}
galleryDetail.runSuspendCatching {
EhEngine.rateGallery(apiUid, apiKey, gid, token, pendingRating)
}.onSuccess { result ->
galleryInfo = galleryDetail.apply {
rating = result.rating
ratingCount = result.ratingCount
}
ratingText = getAllRatingText(result.rating, result.ratingCount)
showSnackbar(rateSucceed)
}.onFailure {
logcat(it)
showSnackbar(rateFailed)
}
}
}
CrystalCard(onClick = ::showRateDialog) {
Column(
modifier = Modifier.padding(8.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
GalleryDetailRating(rating = galleryDetail.rating)
Spacer(modifier = Modifier.size(keylineMargin))
Text(text = ratingText)
}
}
Spacer(modifier = Modifier.size(keylineMargin))
val tags = galleryDetail.tags
if (tags.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(text = stringResource(id = R.string.no_tags))
}
} else {
val copy = stringResource(android.R.string.copy)
val copyTrans = stringResource(R.string.copy_trans)
val showDefine = stringResource(R.string.show_definition)
val addFilter = stringResource(R.string.add_filter)
val filterAdded = stringResource(R.string.filter_added)
val upTag = stringResource(R.string.tag_vote_up)
val downTag = stringResource(R.string.tag_vote_down)
GalleryTags(
tagGroups = tags,
onTagClick = {
val lub = ListUrlBuilder()
lub.mode = ListUrlBuilder.MODE_TAG
lub.keyword = it
navigate(lub.asDst())
},
onTagLongClick = { translated, tag ->
val index = tag.indexOf(':')
val temp = if (index >= 0) {
tag.substring(index + 1)
} else {
tag
}
launchIO {
awaitSelectAction {
with(activity) {
onSelect(copy) {
addTextToClipboard(tag)
}
if (temp != translated) {
onSelect(copyTrans) {
addTextToClipboard(translated)
}
}
onSelect(showDefine) {
openBrowser(EhUrl.getTagDefinitionUrl(temp))
}
onSelect(addFilter) {
awaitPermissionOrCancel { Text(text = stringResource(R.string.filter_the_tag, tag)) }
Filter(FilterMode.TAG, tag).remember()
showSnackbar(filterAdded)
}
if (galleryDetail.apiUid >= 0) {
onSelect(upTag) { galleryDetail.voteTag(tag, 1) }
onSelect(downTag) { galleryDetail.voteTag(tag, -1) }
}
}
}()
}
},
)
}
Spacer(modifier = Modifier.size(keylineMargin))
if (Settings.showComments) {
GalleryDetailComment(galleryDetail.comments.comments)
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.strip_item_padding_v)))
}
}
@Composable
fun GalleryDetailContent(
galleryInfo: GalleryInfo,
contentPadding: PaddingValues,
modifier: Modifier,
) {
val galleryDetail = galleryInfo.asGalleryDetail()
val windowSizeClass = LocalWindowSizeClass.current
val thumbColumns by Settings.thumbColumns.collectAsState()
val readText = stringResource(R.string.read)
val startPage by rememberInVM {
EhDB.getReadProgressFlow(galleryInfo.gid)
}.collectAsState(0)
val readButtonText = if (startPage == 0) {
readText
} else {
stringResource(R.string.read_from, startPage + 1)
}
val downloadState by EhDownloadManager.collectDownloadState(gid)
val downloadButtonText = when (downloadState) {
DownloadInfo.STATE_INVALID -> stringResource(R.string.download)
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
DownloadInfo.STATE_DOWNLOAD -> stringResource(R.string.download_state_downloading)
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_downloaded)
DownloadInfo.STATE_FAILED -> stringResource(R.string.download_state_failed)
else -> error("Invalid DownloadState!!!")
}
fun onReadButtonClick() {
if (galleryDetail != null || downloadState != DownloadInfo.STATE_INVALID) {
navToReader(galleryInfo.findBaseInfo(), startPage)
}
}
fun onCategoryChipClick() {
val category = galleryInfo.category
if (category == EhUtils.NONE || category == EhUtils.PRIVATE || category == EhUtils.UNKNOWN) {
return
}
val lub = ListUrlBuilder(category = category)
navigator.navigate(lub.asDst())
}
fun onUploaderChipClick(galleryInfo: GalleryInfo) {
val uploader = galleryInfo.uploader
val disowned = uploader == "(Disowned)"
if (uploader.isNullOrEmpty() || disowned) {
return
}
val lub = ListUrlBuilder()
lub.mode = ListUrlBuilder.MODE_UPLOADER
lub.keyword = uploader
navigator.navigate(lub.asDst())
}
var showBottomSheet by remember { mutableStateOf(false) }
if (showBottomSheet && galleryDetail != null) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
) {
GalleryInfoBottomSheet(galleryDetail, navigator)
}
}
fun onGalleryInfoCardClick() {
showBottomSheet = true
}
val filterAdded = stringResource(R.string.filter_added)
fun showFilterUploaderDialog(galleryInfo: GalleryInfo) {
val uploader = galleryInfo.uploader
val disowned = uploader == "(Disowned)"
if (uploader.isNullOrEmpty() || disowned) {
return
}
launchIO {
awaitPermissionOrCancel {
Text(text = stringResource(R.string.filter_the_uploader, uploader))
}
Filter(FilterMode.UPLOADER, uploader).remember()
showSnackbar(filterAdded)
}
}
val onDownloadButtonClick = rememberLambda(galleryInfo) {
galleryDetail ?: return@rememberLambda
if (EhDownloadManager.getDownloadState(galleryDetail.gid) == DownloadInfo.STATE_INVALID) {
launchUI { startDownload(activity, false, galleryDetail.galleryInfo) }
} else {
launch { confirmRemoveDownload(galleryDetail) }
}
}
when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.COMPACT -> LazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
contentPadding = contentPadding,
modifier = modifier.padding(horizontal = keylineMargin),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
) {
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
GalleryDetailHeaderCard(
info = galleryInfo,
onInfoCardClick = ::onGalleryInfoCardClick,
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
onCategoryChipClick = ::onCategoryChipClick,
modifier = Modifier.fillMaxWidth().padding(vertical = keylineMargin),
)
}
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
LocalPinnableContainer.current!!.run { remember { pin() } }
Column {
Row {
FilledTonalButton(
onClick = onDownloadButtonClick,
modifier = Modifier.padding(horizontal = 4.dp).weight(1F),
) {
Text(text = downloadButtonText, maxLines = 1)
}
Button(
onClick = ::onReadButtonClick,
modifier = Modifier.padding(horizontal = 4.dp).weight(1F),
) {
Text(text = readButtonText, maxLines = 1)
}
}
if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" })
} else if (galleryDetail != null) {
BelowHeader(galleryDetail)
} else {
Box(
modifier = Modifier.fillMaxSize().padding(keylineMargin),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
if (galleryDetail != null) {
galleryDetailPreview(galleryDetail)
}
}
WindowWidthSizeClass.EXPANDED -> LazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
contentPadding = contentPadding,
modifier = modifier.padding(horizontal = keylineMargin),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
) {
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
Row(modifier = Modifier.fillMaxWidth()) {
GalleryDetailHeaderCard(
info = galleryInfo,
onInfoCardClick = ::onGalleryInfoCardClick,
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
onCategoryChipClick = ::onCategoryChipClick,
modifier = Modifier.width(dimensionResource(id = R.dimen.gallery_detail_card_landscape_width)).padding(vertical = keylineMargin),
)
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = modifier.height(16.dp))
Button(
onClick = ::onReadButtonClick,
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
) {
Text(text = readButtonText, maxLines = 1)
}
Spacer(modifier = modifier.height(24.dp))
FilledTonalButton(
onClick = onDownloadButtonClick,
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
) {
Text(text = downloadButtonText, maxLines = 1)
}
}
}
}
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
LocalPinnableContainer.current!!.run { remember { pin() } }
Column {
if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" })
} else if (galleryDetail != null) {
BelowHeader(galleryDetail)
} else {
Box(
modifier = Modifier.fillMaxSize().padding(keylineMargin),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
if (galleryDetail != null) {
galleryDetailPreview(galleryDetail)
}
}
}
}
Scaffold(
topBar = {
LargeTopAppBar(
@@ -980,7 +268,7 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
}
IconButton(
onClick = {
AppHelper.share(activity, galleryDetailUrl)
AppHelper.share(implicit<MainActivity>(), galleryDetailUrl)
// In case the link is copied to the clipboard
Settings.clipboardTextHashCode = galleryDetailUrl.hashCode()
},
@@ -1040,7 +328,7 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
dropdown = false
val gd = galleryInfo as? GalleryDetail ?: return@DropdownMenuItem
launchIO {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.clear_all,
title = R.string.clear_image_cache,
) {
@@ -1071,7 +359,7 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
val downloadInfo = EhDownloadManager.getDownloadInfo(gid)
val canExport = downloadInfo?.state == DownloadInfo.STATE_FINISH
if (!canExport) {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
showCancelButton = false,
text = { Text(text = stringResource(id = R.string.download_gallery_first)) },
)
@@ -1112,6 +400,8 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
GalleryDetailContent(
galleryInfo = gi,
contentPadding = it,
getDetailError = getDetailError,
onRetry = { getDetailError = "" },
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
)
} else if (getDetailError.isNotBlank()) {
@@ -1126,3 +416,18 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
}
}
}
context(Context, SnackbarHostState)
suspend fun GalleryDetail.voteTag(tag: String, vote: Int) {
runSuspendCatching {
EhEngine.voteTag(apiUid, apiKey, gid, token, tag, vote)
}.onSuccess { result ->
if (result != null) {
showSnackbar(result)
} else {
showSnackbar(getString(R.string.tag_vote_successfully))
}
}.onFailure {
showSnackbar(getString(R.string.vote_failed))
}
}
@@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -126,6 +125,8 @@ import com.hippo.ehviewer.ui.main.GalleryList
import com.hippo.ehviewer.ui.main.ImageSearch
import com.hippo.ehviewer.ui.main.SearchFilter
import com.hippo.ehviewer.ui.tools.Await
import com.hippo.ehviewer.ui.tools.EmptyWindowInsets
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
@@ -283,7 +284,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
ProvideSideSheetContent { sheetState ->
TopAppBar(
title = { Text(text = stringResource(id = R.string.toplist)) },
windowInsets = WindowInsets(0),
windowInsets = EmptyWindowInsets,
)
toplists.forEach { (name, keyword) ->
ListItem(
@@ -307,7 +308,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
actions = {
IconButton(onClick = {
launch {
awaitPermissionOrCancel(title = R.string.quick_search, showCancelButton = false) {
awaitConfirmationOrCancel(title = R.string.quick_search, showCancelButton = false) {
Text(text = stringResource(id = R.string.add_quick_search_tip))
}
}
@@ -364,7 +365,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
)
}
},
windowInsets = WindowInsets(0),
windowInsets = EmptyWindowInsets,
)
Box(modifier = Modifier.fillMaxSize()) {
val quickSearchListState = rememberLazyListState()
@@ -374,7 +375,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
}
var fromIndex by remember { mutableIntStateOf(-1) }
LazyColumn(
FastScrollLazyColumn(
modifier = Modifier.fillMaxSize(),
state = quickSearchListState,
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
@@ -392,7 +393,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
snapshotFlow { dismissState.currentValue }.collect { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
runCatching {
awaitPermissionOrCancel(confirmText = R.string.delete) {
awaitConfirmationOrCancel(confirmText = R.string.delete) {
Text(text = stringResource(R.string.delete_quick_search, item.name))
}
}.onSuccess {
@@ -1,137 +0,0 @@
package com.hippo.ehviewer.ui.screen
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.cachedIn
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import arrow.fx.coroutines.parMap
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.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.coil.justDownload
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ktbuilder.launchIn
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.main.EhPreviewItem
import com.hippo.ehviewer.ui.navToReader
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
import com.hippo.ehviewer.ui.tools.foldToLoadResult
import com.hippo.ehviewer.ui.tools.getClippedRefreshKey
import com.hippo.ehviewer.ui.tools.getLimit
import com.hippo.ehviewer.ui.tools.getOffset
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.util.flattenForEach
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.withIOContext
import moe.tarsin.coroutines.runSuspendCatching
@Destination<RootGraph>
@Composable
fun AnimatedVisibilityScope.GalleryPreviewScreen(detail: GalleryDetail, toNextPage: Boolean, navigator: DestinationsNavigator) = composing(navigator) {
val scrollBehaviour = TopAppBarDefaults.pinnedScrollBehavior()
val pgSize = detail.previewList.size
val state = rememberLazyGridState(initialFirstVisibleItemIndex = if (toNextPage) pgSize else 0)
val thumbColumns by Settings.thumbColumns.collectAsState()
val data = rememberInVM {
val pages = detail.pages
val previewPagesMap = detail.previewList.associateBy { it.position } as MutableMap
Pager(
PagingConfig(
pageSize = pgSize,
prefetchDistance = thumbColumns,
initialLoadSize = pgSize,
jumpThreshold = 2 * pgSize,
),
) {
object : PagingSource<Int, GalleryPreview>() {
override fun getRefreshKey(state: PagingState<Int, GalleryPreview>) = state.getClippedRefreshKey()
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GalleryPreview> = withIOContext {
val key = params.key ?: 0
val up = getOffset(params, key, pages)
val end = (up + getLimit(params, key) - 1).coerceAtMost(pages - 1)
detail.runSuspendCatching {
(up..end).filterNot { it in previewPagesMap }.map { it / pgSize }.toSet()
.parMap(concurrency = Settings.multiThreadDownload) { page ->
val url = EhUrl.getGalleryDetailUrl(gid, token, page, false)
EhEngine.getPreviewList(url).first
}.flattenForEach {
previewPagesMap[it.position] = it
if (Settings.preloadThumbAggressively) {
imageRequest(it) { justDownload() }.launchIn(viewModelScope)
}
}
}.foldToLoadResult {
val r = (up..end).map { requireNotNull(previewPagesMap[it]) }
val prevK = if (up <= 0 || r.isEmpty()) null else up
val nextK = if (end == pages - 1) null else end + 1
LoadResult.Page(r, prevK, nextK, up, pages - end - 1)
}
}
override val jumpingSupported = true
}
}.flow.cachedIn(viewModelScope)
}.collectAsLazyPagingItems()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.gallery_previews)) },
navigationIcon = {
IconButton(onClick = { popBackStack() }) {
Icon(imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
}
},
scrollBehavior = scrollBehaviour,
)
},
) { paddingValues ->
FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
modifier = Modifier.nestedScroll(scrollBehaviour.nestedScrollConnection).padding(horizontal = dimensionResource(id = R.dimen.gallery_list_margin_h)),
state = state,
contentPadding = paddingValues,
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items(
count = data.itemCount,
key = data.itemKey(key = { item -> item.position }),
contentType = data.itemContentType(),
) { index ->
val item = data[index]
EhPreviewItem(item, index) { navToReader(detail.galleryInfo, index) }
}
}
}
}
@@ -102,7 +102,7 @@ fun AnimatedVisibilityScope.HistoryScreen(navigator: DestinationsNavigator) = co
trailingIcon = {
IconButton(onClick = {
launch {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.clear_all,
text = { Text(text = stringResource(id = R.string.clear_all_history)) },
)
@@ -189,7 +189,7 @@ fun SearchBarScreen(
fun deleteKeyword(keyword: String) {
scope.launch {
dialogState.awaitPermissionOrCancel(
dialogState.awaitConfirmationOrCancel(
confirmText = R.string.delete,
) {
Text(text = stringResource(id = R.string.delete_search_history, keyword))
@@ -139,7 +139,7 @@ fun AboutScreen(navigator: DestinationsNavigator) {
}
suspend fun DialogState.showNewVersion(context: Context, release: Release) {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.download,
title = R.string.new_version_available,
) {
@@ -5,7 +5,10 @@ import android.content.Intent
import android.net.Uri
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
import android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@@ -35,12 +38,11 @@ import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
import com.hippo.ehviewer.client.CHROME_USER_AGENT
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.observed
import com.hippo.ehviewer.ui.tools.rememberedAccessor
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.Crash
import com.hippo.ehviewer.util.ReadableTime
@@ -48,14 +50,16 @@ import com.hippo.ehviewer.util.displayPath
import com.hippo.ehviewer.util.getAppLanguage
import com.hippo.ehviewer.util.getLanguages
import com.hippo.ehviewer.util.isAtLeastO
import com.hippo.ehviewer.util.isAtLeastV
import com.hippo.ehviewer.util.sendTo
import com.hippo.ehviewer.util.setAppLanguage
import com.hippo.files.delete
import com.hippo.files.toOkioPath
import com.jamal.composeprefs3.ui.prefs.DropDownPref
import com.jamal.composeprefs3.ui.prefs.SwitchPref
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.system.logcat
import java.io.File
import java.util.zip.ZipEntry
@@ -64,6 +68,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path.Companion.toOkioPath
import splitties.init.appCtx
@Destination<RootGraph>
@Composable
@@ -73,7 +79,6 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
fun launchSnackBar(content: String) = coroutineScope.launch { snackbarHostState.showSnackbar(content) }
val dialogState = LocalDialogState.current
Scaffold(
topBar = {
TopAppBar(
@@ -94,6 +99,26 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
summary = stringResource(id = R.string.settings_advanced_save_parse_error_body_summary),
value = Settings::saveParseErrorBody,
)
val stripAds = Settings.stripExtraneousAds.asMutableState()
SwitchPreference(
title = stringResource(id = R.string.settings_block_extraneous_ads),
value = stripAds.rememberedAccessor,
)
AnimatedVisibility(visible = stripAds.value) {
LauncherPreference(
title = stringResource(id = R.string.settings_ads_placeholder),
contract = ActivityResultContracts.PickVisualMedia(),
key = PickVisualMediaRequest(mediaType = ImageOnly),
) { uri ->
withIOContext {
if (uri != null) {
uri.toOkioPath() sendTo AdsPlaceholderFile
} else {
AdsPlaceholderFile.delete()
}
}
}
}
SwitchPreference(
title = stringResource(id = R.string.settings_advanced_save_crash_log),
summary = stringResource(id = R.string.settings_advanced_save_crash_log_summary),
@@ -171,26 +196,17 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
title = stringResource(id = R.string.animate_items),
summary = stringResource(id = R.string.animate_items_summary),
)
var userAgent by Settings::userAgent.observed
val userAgentTitle = stringResource(id = R.string.user_agent)
Preference(
title = userAgentTitle,
summary = userAgent,
) {
coroutineScope.launch {
userAgent = dialogState.awaitInputText(
initial = userAgent,
title = userAgentTitle,
).trim().ifBlank { null } ?: CHROME_USER_AGENT
}
}
SwitchPreference(
title = stringResource(id = R.string.desktop_site),
summary = stringResource(id = R.string.desktop_site_summary),
value = Settings::desktopSite,
)
val exportFailed = stringResource(id = R.string.settings_advanced_export_data_failed)
val now = ReadableTime.getFilenamableTime()
LauncherPreference(
title = stringResource(id = R.string.settings_advanced_export_data),
summary = stringResource(id = R.string.settings_advanced_export_data_summary),
contract = ActivityResultContracts.CreateDocument("application/vnd.sqlite3"),
key = if (isAtLeastV) now else "$now.db",
contract = ActivityResultContracts.CreateDocument("application/octet-stream"),
key = ReadableTime.getFilenamableTime() + ".db",
) { uri ->
uri?.let {
context.runCatching {
@@ -209,7 +225,7 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
title = stringResource(id = R.string.settings_advanced_import_data),
summary = stringResource(id = R.string.settings_advanced_import_data_summary),
contract = ActivityResultContracts.GetContent(),
key = if (isAtLeastV) "application/vnd.sqlite3" else "application/octet-stream",
key = "application/octet-stream",
) { uri ->
uri?.let {
context.runCatching {
@@ -284,3 +300,5 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
}
}
}
val AdsPlaceholderFile = appCtx.filesDir.toOkioPath() / "AdsPlaceholder"
@@ -64,7 +64,6 @@ 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.launchNonCancellable
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
@@ -99,10 +98,8 @@ fun AnimatedVisibilityScope.DownloadScreen(navigator: DestinationsNavigator) = c
contentResolver.takePersistableUriPermission(treeUri, FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
val path = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)).toOkioPath()
check(path.isDirectory) { "$path is not a directory" }
keepNoMediaFileStatus(path) // Check if the directory is writable
downloadLocationState = path
launchNonCancellable {
keepNoMediaFileStatus(downloadLocationState)
}
}.onFailure {
logcat(it)
launchSnackBar(cannotGetDownloadLocation)
@@ -260,7 +257,7 @@ fun AnimatedVisibilityScope.DownloadScreen(navigator: DestinationsNavigator) = c
}
val list = downloadLocation.list().filter(::isRedundant)
if (list.isNotEmpty()) {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.delete,
title = R.string.settings_download_clean_redundancy,
) {
@@ -115,7 +115,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
) {
coroutineScope.launch {
val cookies = EhCookieStore.getIdentityCookies()
dialogState.awaitPermissionOrCancel(
dialogState.awaitConfirmationOrCancel(
confirmText = R.string.settings_eh_sign_out,
dismissText = R.string.settings_eh_clear_igneous,
showCancelButton = cookies.last().second != null,
@@ -160,7 +160,11 @@ fun EhScreen(navigator: DestinationsNavigator) {
var error by rememberSaveable { mutableStateOf<String?>(null) }
val summary by rememberUpdatedState(
result?.run {
stringResource(id = R.string.image_limits_summary, limits.current, limits.maximum)
when (limits.maximum) {
-1 -> stringResource(id = R.string.image_limits_restricted)
0 -> stringResource(id = R.string.image_limits_normal)
else -> stringResource(id = R.string.image_limits_summary, limits.current, limits.maximum)
}
} ?: placeholder,
)
suspend fun getImageLimits() {
@@ -181,7 +185,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
summary = summary,
) {
coroutineScope.launch {
dialogState.awaitPermissionOrCancel(
dialogState.awaitConfirmationOrCancel(
confirmText = R.string.reset,
title = R.string.image_limits,
confirmButtonEnabled = result?.run { limits.resetCost != 0 } ?: false,
@@ -190,12 +194,16 @@ fun EhScreen(navigator: DestinationsNavigator) {
Text(text = it)
} ?: result?.let { (limits, funds) ->
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
LinearProgressIndicator(
progress = { limits.current.toFloat() / limits.maximum },
modifier = Modifier.height(12.dp).fillMaxWidth(),
)
if (limits.maximum > 0) {
LinearProgressIndicator(
progress = { limits.current.toFloat() / limits.maximum },
modifier = Modifier.height(12.dp).fillMaxWidth(),
)
}
Text(text = summary)
Text(text = stringResource(id = R.string.reset_cost, limits.resetCost))
if (limits.resetCost > 0) {
Text(text = stringResource(id = R.string.reset_cost, limits.resetCost))
}
Text(text = stringResource(id = R.string.current_funds))
Row(
modifier = Modifier.fillMaxWidth(),
@@ -22,13 +22,13 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -127,7 +127,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
readOnly = true,
value = type,
onValueChange = {},
@@ -193,7 +193,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
actions = {
IconButton(onClick = {
scope.launch {
dialogState.awaitPermissionOrCancel(
dialogState.awaitConfirmationOrCancel(
title = R.string.filter,
showCancelButton = false,
) {
@@ -251,7 +251,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
IconButton(
onClick = {
scope.launch {
dialogState.awaitPermissionOrCancel(confirmText = R.string.delete) {
dialogState.awaitConfirmationOrCancel(confirmText = R.string.delete) {
Text(text = stringResource(id = R.string.delete_filter, filter.text))
}
filter.forget {
@@ -89,7 +89,7 @@ fun PrivacyScreen(navigator: DestinationsNavigator) {
summary = stringResource(id = R.string.clear_search_history_summary),
) {
coroutineScope.launch {
dialogState.awaitPermissionOrCancel(
dialogState.awaitConfirmationOrCancel(
confirmText = R.string.clear_all,
title = R.string.clear_search_history_confirm,
)
@@ -1,6 +1,5 @@
package com.hippo.ehviewer.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.scrollbar.LocalScrollbarStyle
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
@@ -19,8 +18,7 @@ import com.hippo.ehviewer.ui.tools.scrollbarStyle
import com.hippo.ehviewer.util.isAtLeastS
@Composable
fun EhTheme(content: @Composable () -> Unit) {
val useDarkTheme = isSystemInDarkTheme()
fun EhTheme(useDarkTheme: Boolean, content: @Composable () -> Unit) {
val amoled by Settings.blackDarkTheme.collectAsState()
val context = LocalContext.current
val colors = if (useDarkTheme) {
@@ -272,7 +272,7 @@ class DialogState {
)
}
suspend fun awaitPermissionOrCancel(
suspend fun awaitConfirmationOrCancel(
@StringRes confirmText: Int = android.R.string.ok,
@StringRes dismissText: Int = android.R.string.cancel,
@StringRes title: Int? = null,
@@ -459,7 +459,7 @@ class DialogState {
style = MaterialTheme.typography.headlineSmall,
)
}
LazyColumn {
FastScrollLazyColumn {
itemsIndexed(items) { index, text ->
CheckableItem(
text = text,
@@ -495,7 +495,7 @@ class DialogState {
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
style = MaterialTheme.typography.headlineSmall,
)
LazyColumn {
FastScrollLazyColumn {
itemsIndexed(items) { index, text ->
CheckableItem(
text = text,
@@ -1,10 +1,10 @@
package com.hippo.ehviewer.ui.tools
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -29,10 +29,10 @@ fun DropdownFilterChip(
label = {
Text(text = if (selectedItemIndex == 0) label else menuItems[selectedItemIndex])
},
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }, matchAnchorWidth = false) {
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }, matchTextFieldWidth = false) {
menuItems.forEachIndexed { index, item ->
DropdownMenuItem(
text = { Text(text = item) },
@@ -1,7 +1,6 @@
package com.hippo.ehviewer.ui.tools
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
@@ -11,6 +10,8 @@ import androidx.compose.foundation.scrollbar.rememberScrollbarAdapter
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@Composable
@@ -24,7 +25,7 @@ fun FastScrollLazyColumn(
userScrollEnabled: Boolean = true,
content: LazyListScope.() -> Unit,
) {
Box {
Layout({
LazyColumn(
modifier = modifier,
state = state,
@@ -38,8 +39,13 @@ fun FastScrollLazyColumn(
VerticalScrollbar(
adapter = rememberScrollbarAdapter(state),
isScrollInProgress = state.isScrollInProgress,
modifier = Modifier.align(Alignment.TopEnd),
contentPadding = contentPadding,
)
}) { measurables, constraints ->
val (column, scrollbar) = measurables.map { it.measure(constraints) }
layout(column.width, column.height) {
column.place(IntOffset.Zero)
scrollbar.placeRelative(column.width - scrollbar.width, 0)
}
}
}
@@ -1,41 +1,6 @@
package com.hippo.ehviewer.ui.tools
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
inline fun Modifier.thenIf(condition: Boolean, crossinline block: Modifier.() -> Modifier) =
if (condition) block() else this
val NoopTransitionsVisibilityScope = TransitionsVisibilityScope(emptySet())
@JvmInline
@Stable
value class TransitionsVisibilityScope(val scopes: Set<AnimatedVisibilityScope>)
context(TransitionsVisibilityScope)
@Composable
inline fun <T> togetherWith(scope: AnimatedVisibilityScope, block: @Composable TransitionsVisibilityScope.() -> T) =
block(remember(scopes, scope) { TransitionsVisibilityScope(scopes + scope) })
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun Modifier.sharedBounds(
key: String,
enter: EnterTransition = fadeIn(),
exit: ExitTransition = fadeOut(),
) = scopes.fold(this) { modifier, scope ->
modifier.sharedBounds(
rememberSharedContentState("$key + ${scope.transition.label}"),
scope,
enter,
exit,
)
}
@@ -1,16 +0,0 @@
package com.hippo.ehviewer.ui.tools
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.cache
import androidx.compose.runtime.currentComposer
@Composable
fun rememberLambda(calculation: @DisallowComposableCalls () -> Unit): () -> Unit =
currentComposer.cache(false) { calculation }
@Composable
fun rememberLambda(
key1: Any?,
calculation: @DisallowComposableCalls () -> Unit,
): () -> Unit = currentComposer.cache(currentComposer.changed(key1)) { calculation }
@@ -0,0 +1,162 @@
package com.hippo.ehviewer.ui.tools
import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import arrow.atomic.AtomicInt
val NoopTransitionsVisibilityScope = TransitionsVisibilityScope(emptySet())
@JvmInline
@Stable
value class TransitionsVisibilityScope(val scopes: Set<AnimatedVisibilityScope>)
context(TransitionsVisibilityScope)
@Composable
inline fun <T> togetherWith(scope: AnimatedVisibilityScope, block: @Composable TransitionsVisibilityScope.() -> T) =
block(remember(scopes, scope) { TransitionsVisibilityScope(scopes + scope) })
context(SharedTransitionScope, TransitionsVisibilityScope, SETNodeGenerator)
@Composable
fun Modifier.sharedBounds(
key: String,
enter: EnterTransition = fadeIn(),
exit: ExitTransition = fadeOut(),
) = remember(key) { summon(key) }.let { node ->
DisposableEffect(key) {
onDispose {
dispose(node)
}
}
scopes.fold(this) { modifier, scope ->
modifier.sharedBounds(
rememberSharedContentState("${node.syntheticKey} + ${scope.transition.label}"),
scope,
enter,
exit,
)
}
}
data class SETNode(
val contentKey: String,
val uniqueID: String,
)
val SETNode.syntheticKey: String
get() = contentKey + uniqueID
val listThumbGenerator = SETNodeGenerator()
val detailThumbGenerator = SETNodeGenerator()
fun initSETConnection() {
listThumbGenerator connectTo detailThumbGenerator
}
private val atomicIncId = AtomicInt()
class SETNodeGenerator {
private val tracker = hashMapOf<String, SETNode>()
private val opposites = mutableListOf<SETNodeGenerator>()
fun summon(contentKey: String): SETNode {
val node = opposites.firstNotNullOfOrNull { it.tracker[contentKey] } ?: SETNode(contentKey = contentKey, uniqueID = "${atomicIncId.getAndIncrement()}")
tracker[contentKey] = node
return node
}
fun dispose(node: SETNode) = tracker.remove(node.contentKey, node)
infix fun connectTo(other: SETNodeGenerator) {
opposites.add(other)
other.opposites.add(this)
}
}
@SuppressLint("ModifierFactoryUnreferencedReceiver")
object NoopSharedTransitionScope : SharedTransitionScope {
override val isTransitionActive: Boolean
get() = TODO("Not yet implemented")
override val Placeable.PlacementScope.lookaheadScopeCoordinates: LayoutCoordinates
get() = TODO("Not yet implemented")
override fun OverlayClip(clipShape: Shape): SharedTransitionScope.OverlayClip {
TODO("Not yet implemented")
}
@Composable
override fun rememberSharedContentState(key: Any): SharedTransitionScope.SharedContentState {
TODO("Not yet implemented")
}
override fun Modifier.renderInSharedTransitionScopeOverlay(
renderInOverlay: () -> Boolean,
zIndexInOverlay: Float,
clipInOverlayDuringTransition: (LayoutDirection, Density) -> Path?,
): Modifier {
TODO("Not yet implemented")
}
override fun Modifier.sharedBounds(
sharedContentState: SharedTransitionScope.SharedContentState,
animatedVisibilityScope: AnimatedVisibilityScope,
enter: EnterTransition,
exit: ExitTransition,
boundsTransform: BoundsTransform,
resizeMode: SharedTransitionScope.ResizeMode,
placeHolderSize: SharedTransitionScope.PlaceHolderSize,
renderInOverlayDuringTransition: Boolean,
zIndexInOverlay: Float,
clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip,
): Modifier {
TODO("Not yet implemented")
}
override fun Modifier.sharedElement(
state: SharedTransitionScope.SharedContentState,
animatedVisibilityScope: AnimatedVisibilityScope,
boundsTransform: BoundsTransform,
placeHolderSize: SharedTransitionScope.PlaceHolderSize,
renderInOverlayDuringTransition: Boolean,
zIndexInOverlay: Float,
clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip,
): Modifier {
TODO("Not yet implemented")
}
override fun Modifier.sharedElementWithCallerManagedVisibility(
sharedContentState: SharedTransitionScope.SharedContentState,
visible: Boolean,
boundsTransform: BoundsTransform,
placeHolderSize: SharedTransitionScope.PlaceHolderSize,
renderInOverlayDuringTransition: Boolean,
zIndexInOverlay: Float,
clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip,
): Modifier {
TODO("Not yet implemented")
}
override fun Modifier.skipToLookaheadSize(): Modifier {
TODO("Not yet implemented")
}
override fun LayoutCoordinates.toLookaheadCoordinates(): LayoutCoordinates {
TODO("Not yet implemented")
}
}
@@ -0,0 +1,5 @@
package com.hippo.ehviewer.ui.tools
import androidx.compose.foundation.layout.WindowInsets
val EmptyWindowInsets = WindowInsets(0, 0, 0, 0)
@@ -16,7 +16,7 @@
package com.hippo.ehviewer.util
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.jvm.nio.copyTo
import io.ktor.utils.io.copyTo
import java.io.File
import java.io.RandomAccessFile
import java.util.Locale
@@ -5,15 +5,15 @@ import io.ktor.client.statement.HttpResponse
import io.ktor.http.HttpStatusCode
import io.ktor.http.isSuccess
import io.ktor.utils.io.charsets.decode
import io.ktor.utils.io.core.ByteReadPacket
import java.nio.charset.CodingErrorAction
import kotlinx.io.Source
suspend fun HttpResponse.bodyAsUtf8Text(): String {
val decoder = Charsets.UTF_8.newDecoder().apply {
onMalformedInput(CodingErrorAction.REPLACE)
onUnmappableCharacter(CodingErrorAction.REPLACE)
}
val input = body<ByteReadPacket>()
val input = body<Source>()
return decoder.decode(input)
}
@@ -74,7 +74,7 @@ suspend fun Context.requestPermission(key: String): Boolean {
return awaitActivityResult(ActivityResultContracts.RequestPermission(), key)
}
suspend fun Context.pickVisualMedia(type: VisualMediaType): Uri? = awaitActivityResult(ActivityResultContracts.PickVisualMedia(), PickVisualMediaRequest.Builder().setMediaType(type).build())
suspend fun Context.pickVisualMedia(type: VisualMediaType): Uri? = awaitActivityResult(ActivityResultContracts.PickVisualMedia(), PickVisualMediaRequest(mediaType = type))
@RequiresApi(Build.VERSION_CODES.O)
suspend fun Context.requestInstallPermission(): Boolean {
@@ -16,7 +16,9 @@ val Uri.displayPath: String?
val context = appCtx
if (DocumentsContract.isDocumentUri(context, this)) {
val (type, path) = DocumentsContract.getDocumentId(this).split(":", limit = 2)
val (type, path) = DocumentsContract.getDocumentId(this).split(":", limit = 2).also {
if (it.size < 2) return toString()
}
if (authority == "com.android.externalstorage.documents") {
if (type == "primary") {
return Environment.getExternalStorageDirectory().path + "/" + path
@@ -2,7 +2,6 @@ package com.hippo.ehviewer.util
import android.os.Build
import android.os.ext.SdkExtensions
import androidx.core.os.BuildCompat
val isAtLeastN = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
val isAtLeastO = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
@@ -13,5 +12,4 @@ val isAtLeastR = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
val isAtLeastS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val isAtLeastT = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
val isAtLeastU = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
val isAtLeastV = BuildCompat.isAtLeastV()
val isAtLeastSExtension7 = isAtLeastR && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 7
@@ -26,7 +26,13 @@ class AndroidFileSystem(context: Context) : FileSystem() {
}
override fun atomicMove(source: Path, target: Path) {
TODO("Not yet implemented")
if (source.isPhysicalFile()) {
return physicalFileSystem.atomicMove(source, target)
}
source.runCatching {
DocumentsContract.renameDocument(contentResolver, toUri(), target.name)
}.getOrNull() ?: throw IOException("Failed to move $source to $target")
}
override fun canonicalize(path: Path): Path {
@@ -47,9 +53,9 @@ class AndroidFileSystem(context: Context) : FileSystem() {
}
}
dir.parent?.let {
DocumentsContract.createDocument(contentResolver, it.toUri(), Document.MIME_TYPE_DIR, dir.name)
} ?: throw IOException("Failed to create directory: $dir")
dir.parent?.runCatching {
DocumentsContract.createDocument(contentResolver, toUri(), Document.MIME_TYPE_DIR, dir.name)
}?.getOrNull() ?: throw IOException("Failed to create directory: $dir")
}
override fun createSymlink(source: Path, target: Path) {
@@ -69,7 +75,9 @@ class AndroidFileSystem(context: Context) : FileSystem() {
uri = DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getDocumentId(uri) + '/')
}
val deleted = DocumentsContract.deleteDocument(contentResolver, uri)
val deleted = runCatching {
DocumentsContract.deleteDocument(contentResolver, uri)
}.getOrDefault(false)
if (!deleted) {
throw IOException("Failed to delete $path")
}
@@ -168,25 +176,20 @@ class AndroidFileSystem(context: Context) : FileSystem() {
}
fun openFileDescriptor(path: Path, mode: String): ParcelFileDescriptor {
if (path.isPhysicalFile()) {
return ParcelFileDescriptor.open(path.toFile(), ParcelFileDescriptor.parseMode(mode))
}
if ('w' in mode && !exists(path)) {
val parent = path.parent ?: throw IOException("Failed to open file: $path")
createDirectories(parent)
if (path.isPhysicalFile()) {
physicalFileSystem.openReadWrite(path).close()
} else {
path.parent?.runCatching {
val displayName = path.name
val extension = displayName.substringAfterLast('.', "").ifEmpty { null }?.lowercase()
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "application/octet-stream"
DocumentsContract.createDocument(contentResolver, parent.toUri(), mimeType, displayName)
?: throw IOException("Failed to open file: $path")
}
DocumentsContract.createDocument(contentResolver, toUri(), mimeType, displayName)
}?.getOrNull() ?: throw IOException("Failed to open file: $path")
}
return if (path.isPhysicalFile()) {
ParcelFileDescriptor.open(path.toFile(), ParcelFileDescriptor.parseMode(mode))
} else {
contentResolver.openFileDescriptor(path.toUri(), mode)
} ?: throw IOException("Failed to open file: $path")
return contentResolver.openFileDescriptor(path.toUri(), mode) ?: throw IOException("Failed to open file: $path")
}
}
@@ -23,6 +23,10 @@ fun Path.delete() = SystemFileSystem.deleteRecursively(this)
fun Path.list() = SystemFileSystem.listOrNull(this).orEmpty()
fun Path.mkdirs() = SystemFileSystem.createDirectories(this)
fun Path.moveTo(target: Path) = SystemFileSystem.atomicMove(this, target)
fun Path.openFileDescriptor(mode: String) = SystemFileSystem.openFileDescriptor(this, mode)
fun Path.openInputStream(): FileInputStream =
@@ -1,31 +0,0 @@
package eu.kanade.tachiyomi.source.model
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
open class Page(val index: Int) {
val number: Int
get() = index + 1
var status = MutableStateFlow(State.QUEUE)
var errorMsg: String? = null
private val _progressFlow = MutableStateFlow(0)
val progressFlow = _progressFlow.asStateFlow()
var progress: Int
get() = _progressFlow.value
set(value) {
_progressFlow.value = value
}
enum class State {
QUEUE,
LOAD_PAGE,
DOWNLOAD_IMAGE,
READY,
ERROR,
}
}
@@ -14,11 +14,11 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
@@ -108,7 +108,7 @@ private fun DropdownIconButton(
) {
IconButton(
onClick = {},
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
) {
Icon(
imageVector = selectedItem.icon,
@@ -119,7 +119,7 @@ private fun DropdownIconButton(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.widthIn(min = minMenuWidth),
matchAnchorWidth = false,
matchTextFieldWidth = false,
) {
menuItems.forEach {
DropdownMenuItem(
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.FileCopy
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -37,6 +38,7 @@ fun ReaderPageSheetMeta(
copy: () -> Unit,
save: () -> Unit,
saveTo: () -> Unit,
showAds: (() -> Unit)?,
dismiss: () -> Unit,
) {
@Composable
@@ -52,6 +54,7 @@ fun ReaderPageSheetMeta(
modifier = Modifier.fillMaxSize() // Workaround for https://issuetracker.google.com/341594885
.verticalScroll(rememberScrollState()).navigationBarsPadding(),
) {
showAds?.let { Item(icon = Icons.Default.Visibility, text = R.string.show_blocked_image, onClick = it) }
Item(icon = Icons.Default.Refresh, text = R.string.refresh, onClick = retry)
Item(icon = Icons.Default.Refresh, text = R.string.refresh_original, onClick = retryOrigin)
Item(icon = Icons.Default.Share, text = R.string.action_share, onClick = share)
@@ -3,12 +3,16 @@ package eu.kanade.tachiyomi.ui.reader.loader
import androidx.annotation.CallSuper
import androidx.collection.lruCache
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.gallery.Page
import com.hippo.ehviewer.gallery.PageStatus
import com.hippo.ehviewer.gallery.reset
import com.hippo.ehviewer.gallery.status
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.util.OSUtils
import com.hippo.ehviewer.util.isAtLeastO
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
private const val MAX_CACHE_SIZE = 512 * 1024 * 1024
private const val MIN_CACHE_SIZE = 128 * 1024 * 1024
@@ -21,7 +25,7 @@ abstract class PageLoader {
} else {
(OSUtils.appMaxMemory / 3 * 2).toInt()
},
sizeOf = { _, v -> v.size.toInt() },
sizeOf = { _, v -> v.allocationSize.toInt() },
onEntryRemoved = { _, k, o, n ->
if (o.isRecyclable) {
n ?: notifyPageWait(k)
@@ -35,7 +39,7 @@ abstract class PageLoader {
val pages by lazy {
check(size > 0)
(0 until size).map { ReaderPage(it) }
(0 until size).map { Page(it) }
}
private val prefetchPageCount = Settings.preloadImage
@@ -58,6 +62,7 @@ abstract class PageLoader {
fun restart() {
cache.evictAll()
pages.forEach(Page::reset)
}
abstract val size: Int
@@ -79,7 +84,12 @@ abstract class PageLoader {
} else {
index - 1 downTo (index - prefetchPageCount).coerceAtLeast(0)
}
val pagesAbsent = prefetchRange.filter { pages[it].status.value == Page.State.QUEUE }
val pagesAbsent = prefetchRange.filter {
when (pages[it].status) {
PageStatus.Queued, is PageStatus.Error -> true
else -> false
}
}
val start = if (prefetchRange.step > 0) prefetchRange.first else prefetchRange.last
val end = if (prefetchRange.step > 0) prefetchRange.last else prefetchRange.first
prefetchPages(pagesAbsent, start - 5 to end + 5)
@@ -104,32 +114,27 @@ abstract class PageLoader {
protected abstract fun onForceRequest(index: Int, orgImg: Boolean)
fun cancelRequest(index: Int) {
onCancelRequest(index)
}
protected abstract fun onCancelRequest(index: Int)
fun notifyPageWait(index: Int) {
pages[index].progress = 0
pages[index].status.value = Page.State.QUEUE
pages[index].reset()
}
fun notifyPagePercent(index: Int, percent: Float) {
pages[index].progress = (percent * 100).toInt()
pages[index].status.compareAndSet(Page.State.QUEUE, Page.State.DOWNLOAD_IMAGE)
pages[index].statusFlow.update {
when (it) {
is PageStatus.Loading -> it.apply { progress.update { percent } }
else -> PageStatus.Loading(MutableStateFlow(percent))
}
}
}
fun notifyPageSucceed(index: Int, image: Image, replaceCache: Boolean = true) {
if (replaceCache) {
cache.put(index, image)
}
pages[index].image = image
pages[index].status.value = Page.State.READY
pages[index].statusFlow.update { if (image.hasQrCode) PageStatus.Blocked(image) else PageStatus.Ready(image) }
}
fun notifyPageFailed(index: Int, error: String?) {
pages[index].errorMsg = error
pages[index].status.value = Page.State.ERROR
pages[index].statusFlow.update { PageStatus.Error(error) }
}
}
@@ -1,8 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.model
import com.hippo.ehviewer.image.Image
import eu.kanade.tachiyomi.source.model.Page
class ReaderPage(index: Int) : Page(index) {
var image: Image? = null
}
@@ -1,7 +1,8 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.graphics.RectF
import androidx.annotation.StringRes
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import com.hippo.ehviewer.R
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
@@ -12,7 +13,6 @@ import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
import eu.kanade.tachiyomi.util.lang.invert
abstract class ViewerNavigation {
sealed class NavigationRegion(@StringRes val nameRes: Int, val colorRes: Int) {
data object MENU : NavigationRegion(R.string.action_menu, R.color.navigation_menu)
data object PREV : NavigationRegion(R.string.nav_zone_prev, R.color.navigation_prev)
@@ -21,37 +21,29 @@ abstract class ViewerNavigation {
data object RIGHT : NavigationRegion(R.string.nav_zone_right, R.color.navigation_right)
}
data class Region(
val rectF: RectF,
val type: NavigationRegion,
) {
data class Region(val rect: Rect, val type: NavigationRegion) {
fun invert(invertMode: TappingInvertMode): Region {
if (invertMode == TappingInvertMode.NONE) return this
return this.copy(
rectF = this.rectF.invert(invertMode),
)
return copy(rect = rect.invert(invertMode))
}
}
protected abstract val originalRegions: List<Region>
val regions get() = originalRegions.map { it.invert(invertMode) }
var invertMode = TappingInvertMode.NONE
fun regions(invertMode: TappingInvertMode = TappingInvertMode.NONE) = originalRegions.map { it.invert(invertMode) }
companion object {
fun fromPreference(value: Int, isVertical: Boolean) = when (value) {
1 -> LNavigation()
2 -> KindlishNavigation()
3 -> EdgeNavigation()
4 -> RightAndLeftNavigation()
5 -> DisabledNavigation()
else -> if (isVertical) LNavigation() else RightAndLeftNavigation()
1 -> LNavigation
2 -> KindlishNavigation
3 -> EdgeNavigation
4 -> RightAndLeftNavigation
5 -> DisabledNavigation
else -> if (isVertical) LNavigation else RightAndLeftNavigation
}
}
}
typealias NavigationRegions = List<ViewerNavigation.Region>
fun NavigationRegions.getAction(x: Float, y: Float) =
find { it.rectF.contains(x, y) }?.type ?: ViewerNavigation.NavigationRegion.MENU
fun NavigationRegions.getAction(offset: Offset) = find { offset in it.rect }?.type ?: ViewerNavigation.NavigationRegion.MENU
@@ -12,7 +12,6 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | M | M | M | N: Next
* +---+---+---+
*/
class DisabledNavigation : ViewerNavigation() {
override val originalRegions: List<Region> = emptyList()
object DisabledNavigation : ViewerNavigation() {
override val originalRegions = emptyList<Region>()
}
@@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
import android.graphics.RectF
import androidx.compose.ui.geometry.Rect
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
/**
@@ -13,19 +13,18 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | N | P | N | N: Next
* +---+---+---+
*/
class EdgeNavigation : ViewerNavigation() {
override val originalRegions: List<Region> = listOf(
object EdgeNavigation : ViewerNavigation() {
override val originalRegions = listOf(
Region(
rectF = RectF(0f, 0f, 0.33f, 1f),
rect = Rect(0f, 0f, 0.33f, 1f),
type = NavigationRegion.NEXT,
),
Region(
rectF = RectF(0.33f, 0.66f, 0.66f, 1f),
rect = Rect(0.33f, 0.66f, 0.66f, 1f),
type = NavigationRegion.PREV,
),
Region(
rectF = RectF(0.66f, 0f, 1f, 1f),
rect = Rect(0.66f, 0f, 1f, 1f),
type = NavigationRegion.NEXT,
),
)
@@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
import android.graphics.RectF
import androidx.compose.ui.geometry.Rect
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
/**
@@ -13,15 +13,14 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | P | N | N | N: Next
* +---+---+---+
*/
class KindlishNavigation : ViewerNavigation() {
override val originalRegions: List<Region> = listOf(
object KindlishNavigation : ViewerNavigation() {
override val originalRegions = listOf(
Region(
rectF = RectF(0.33f, 0.33f, 1f, 1f),
rect = Rect(0.33f, 0.33f, 1f, 1f),
type = NavigationRegion.NEXT,
),
Region(
rectF = RectF(0f, 0.33f, 0.33f, 1f),
rect = Rect(0f, 0.33f, 0.33f, 1f),
type = NavigationRegion.PREV,
),
)
@@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
import android.graphics.RectF
import androidx.compose.ui.geometry.Rect
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
/**
@@ -13,23 +13,22 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | N | N | N | N: Next
* +---+---+---+
*/
open class LNavigation : ViewerNavigation() {
override val originalRegions: List<Region> = listOf(
object LNavigation : ViewerNavigation() {
override val originalRegions = listOf(
Region(
rectF = RectF(0f, 0.33f, 0.33f, 0.66f),
rect = Rect(0f, 0.33f, 0.33f, 0.66f),
type = NavigationRegion.PREV,
),
Region(
rectF = RectF(0f, 0f, 1f, 0.33f),
rect = Rect(0f, 0f, 1f, 0.33f),
type = NavigationRegion.PREV,
),
Region(
rectF = RectF(0.66f, 0.33f, 1f, 0.66f),
rect = Rect(0.66f, 0.33f, 1f, 0.66f),
type = NavigationRegion.NEXT,
),
Region(
rectF = RectF(0f, 0.66f, 1f, 1f),
rect = Rect(0f, 0.66f, 1f, 1f),
type = NavigationRegion.NEXT,
),
)
@@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer.navigation
import android.graphics.RectF
import androidx.compose.ui.geometry.Rect
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
/**
@@ -13,15 +13,14 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
* | N | M | P | N: Move Left
* +---+---+---+
*/
class RightAndLeftNavigation : ViewerNavigation() {
override val originalRegions: List<Region> = listOf(
object RightAndLeftNavigation : ViewerNavigation() {
override val originalRegions = listOf(
Region(
rectF = RectF(0f, 0f, 0.33f, 1f),
rect = Rect(0f, 0f, 0.33f, 1f),
type = NavigationRegion.LEFT,
),
Region(
rectF = RectF(0.66f, 0f, 1f, 1f),
rect = Rect(0.66f, 0f, 1f, 1f),
type = NavigationRegion.RIGHT,
),
)
@@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.util.lang
import androidx.compose.ui.geometry.Rect
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
fun Rect.invert(invertMode: TappingInvertMode): Rect {
val horizontal = invertMode.shouldInvertHorizontal
val vertical = invertMode.shouldInvertVertical
return when {
horizontal && vertical -> Rect(1f - right, 1f - bottom, 1f - left, 1f - top)
vertical -> Rect(left, 1f - bottom, right, 1f - top)
horizontal -> Rect(1f - right, top, 1f - left, bottom)
else -> this
}
}
@@ -1,15 +0,0 @@
package eu.kanade.tachiyomi.util.lang
import android.graphics.RectF
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
fun RectF.invert(invertMode: TappingInvertMode): RectF {
val horizontal = invertMode.shouldInvertHorizontal
val vertical = invertMode.shouldInvertVertical
return when {
horizontal && vertical -> RectF(1f - this.right, 1f - this.bottom, 1f - this.left, 1f - this.top)
vertical -> RectF(this.left, 1f - this.bottom, this.right, 1f - this.top)
horizontal -> RectF(1f - this.right, this.top, 1f - this.left, this.bottom)
else -> this
}
}
@@ -321,6 +321,8 @@
<string name="settings_read_volume_page_fast">快</string>
<string name="settings_read_volume_page_slow">慢</string>
<string name="settings_read_reverse_volume">翻转音量键</string>
<string name="settings_block_extraneous_ads">[实验性] 屏蔽外部广告</string>
<string name="settings_ads_placeholder">[可选] 选择替换外部广告的图片</string>
<string name="settings_download">下载</string>
<string name="settings_download_download_location">下载路径</string>
<string name="settings_download_cant_get_download_location">无法获取下载路径</string>
@@ -423,6 +425,8 @@
<string name="insufficient_funds">余额不足</string>
<string name="image_limits">图片配额</string>
<string name="image_limits_summary">已用:%1$d / %2$d</string>
<string name="image_limits_normal">无限制</string>
<string name="image_limits_restricted">图片分辨率限制为 1280x</string>
<string name="reset_cost">重置花费:%d GP</string>
<string name="reset_limits_succeed">成功重置图片配额</string>
<string name="reset">重置</string>
@@ -468,6 +472,7 @@
<string name="action_add_tag">添加标签</string>
<string name="action_add_tag_tip">添加新标签,用逗号「,」分割</string>
<string name="comment_user_uploader">%s (上传者)</string>
<string name="no_network">无网络</string>
<string name="settings_eh_metered_network_warning">流量计费网络警告</string>
<string name="metered_network_warning">正在使用流量计费网络</string>
<string name="read_from">从第 %d 页阅读</string>
@@ -493,6 +498,8 @@
<string name="preload_thumb_aggressively">激进地预载缩略图</string>
<string name="animate_items">列表项目动画</string>
<string name="animate_items_summary">如遇崩溃/掉帧请尝试禁用此项</string>
<string name="desktop_site">桌面版网站</string>
<string name="desktop_site_summary">更高的默认图片分辨率</string>
<string name="auto_updates">自动检查更新</string>
<string name="update_frequency_never">从不</string>
<string name="update_frequency_daily">每天</string>
@@ -602,6 +609,8 @@
<string name="dark_theme_off">总是关闭</string>
<string name="dark_theme_on">总是开启</string>
<string name="pref_crop_borders">裁剪边缘</string>
<string name="blocked_image">已屏蔽的图片</string>
<string name="show_blocked_image">显示已屏蔽的图片</string>
<!-- Tachiyomi Reader End-->
<plurals name="page_count">
+9 -1
View File
@@ -337,6 +337,8 @@
<string name="settings_read_volume_page_fast">Fast</string>
<string name="settings_read_volume_page_slow">Slow</string>
<string name="settings_read_reverse_volume">Reverse volume key</string>
<string name="settings_block_extraneous_ads">[Experimental] Block extraneous ads</string>
<string name="settings_ads_placeholder">[Optional] Pick placeholder to replace ads</string>
<string name="settings_download">Download</string>
<string name="settings_download_download_location">Download location</string>
<string name="settings_download_cant_get_download_location">Can\'t get download location</string>
@@ -444,6 +446,8 @@
<string name="insufficient_funds">Insufficient Funds</string>
<string name="image_limits">Image Limits</string>
<string name="image_limits_summary">Used: %1$d / %2$d</string>
<string name="image_limits_normal">No restrictions</string>
<string name="image_limits_restricted">Image resolution restricted to 1280x</string>
<string name="reset_cost">Reset Cost: %d GP</string>
<string name="reset_limits_succeed">Image limit was successfully reset</string>
<string name="reset">Reset</string>
@@ -485,6 +489,7 @@
<string name="action_add_tag">Add tag</string>
<string name="action_add_tag_tip">Enter new tags, separated with comma</string>
<string name="comment_user_uploader">%s (Uploader)</string>
<string name="no_network">No network</string>
<string name="settings_eh_metered_network_warning">Metered network warning</string>
<string name="metered_network_warning">Connected to metered networks</string>
<string name="read_from">Read page %d</string>
@@ -512,7 +517,8 @@
<string name="preload_thumb_aggressively">Preload thumbs aggressively</string>
<string name="animate_items">List item animations</string>
<string name="animate_items_summary">Try disabling this if you are facing crashes / frame drops</string>
<string name="user_agent">User Agent</string>
<string name="desktop_site">Desktop site</string>
<string name="desktop_site_summary">Higher default image resolution</string>
<string name="auto_updates">Automatically check for updates</string>
<string name="update_frequency_never">Never</string>
<string name="update_frequency_daily">Daily</string>
@@ -621,6 +627,8 @@
<string name="dark_theme_follow_system">Follow system</string>
<string name="dark_theme_off">Always off</string>
<string name="dark_theme_on">Always on</string>
<string name="blocked_image">Blocked image</string>
<string name="show_blocked_image">Show blocked image</string>
<!-- Tachiyomi Reader End-->
<plurals name="page_count">
@@ -16,7 +16,4 @@
~ If not, see <https://www.gnu.org/licenses/>.
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">ehtracker.org</domain>
</domain-config>
</network-security-config>
</network-security-config>
+748 -23
View File
@@ -2,6 +2,21 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_log-sys"
version = "0.3.1"
@@ -20,16 +35,85 @@ dependencies = [
]
[[package]]
name = "anyhow"
version = "1.0.86"
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6"
[[package]]
name = "autocfg"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
[[package]]
name = "bit-set"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bit_reverse"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99528ca30abb9495c7e106bf7c3177b257c62040fc0f2909fe470b0f43097296"
[[package]]
name = "bitflags"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
[[package]]
name = "bumpalo"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "bytemuck"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94bbb0ad554ad961ddc5da507a12a29b14e4ae5bda06b19f575a3e6079d2e2ae"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.7.1"
version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50"
checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3"
[[package]]
name = "cc"
version = "1.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0"
dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
@@ -43,6 +127,51 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-targets 0.52.6",
]
[[package]]
name = "chrono-tz"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [
"chrono",
"chrono-tz-build",
"phf",
]
[[package]]
name = "chrono-tz-build"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [
"parse-zoneinfo",
"phf",
"phf_codegen",
]
[[package]]
name = "codepage-437"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40c1169585d8d08e5675a39f2fc056cd19a258fc4cba5e3bbf4a9c1026de535"
dependencies = [
"csv",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -53,22 +182,116 @@ dependencies = [
"memchr",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "csv"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "csv-core"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70"
dependencies = [
"memchr",
]
[[package]]
name = "ehviewer_rust"
version = "0.0.0"
dependencies = [
"android_logger",
"anyhow",
"image",
"jni",
"jni_fn",
"log",
"ndk",
"quick-xml",
"regex-lite",
"rxing",
"serde",
"serde_cbor",
"tl",
]
[[package]]
name = "encoding"
version = "0.2.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec"
dependencies = [
"encoding-index-japanese",
"encoding-index-korean",
"encoding-index-simpchinese",
"encoding-index-singlebyte",
"encoding-index-tradchinese",
]
[[package]]
name = "encoding-index-japanese"
version = "1.20141219.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91"
dependencies = [
"encoding_index_tests",
]
[[package]]
name = "encoding-index-korean"
version = "1.20141219.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81"
dependencies = [
"encoding_index_tests",
]
[[package]]
name = "encoding-index-simpchinese"
version = "1.20141219.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7"
dependencies = [
"encoding_index_tests",
]
[[package]]
name = "encoding-index-singlebyte"
version = "1.20141219.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a"
dependencies = [
"encoding_index_tests",
]
[[package]]
name = "encoding-index-tradchinese"
version = "1.20141219.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18"
dependencies = [
"encoding_index_tests",
]
[[package]]
name = "encoding_index_tests"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569"
[[package]]
name = "env_filter"
version = "0.1.2"
@@ -78,12 +301,91 @@ dependencies = [
"log",
]
[[package]]
name = "equivalent"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "fancy-regex"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2"
dependencies = [
"bit-set",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "half"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "iana-time-zone"
version = "0.1.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "image"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10"
dependencies = [
"bytemuck",
"byteorder-lite",
"num-traits",
]
[[package]]
name = "indexmap"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "jni"
version = "0.21.1"
@@ -117,6 +419,27 @@ dependencies = [
"syn",
]
[[package]]
name = "js-sys"
version = "0.3.70"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.158"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439"
[[package]]
name = "log"
version = "0.4.22"
@@ -129,6 +452,195 @@ version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "multimap"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
dependencies = [
"serde",
]
[[package]]
name = "ndk"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags",
"jni-sys",
"log",
"ndk-sys",
"num_enum",
"raw-window-handle",
"thiserror",
]
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_enum"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179"
dependencies = [
"num_enum_derive",
]
[[package]]
name = "num_enum_derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]]
name = "phf"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_shared"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
dependencies = [
"siphasher",
]
[[package]]
name = "proc-macro-crate"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.86"
@@ -140,28 +652,117 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.36.1"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96a05e2e8efddfa51a84ca47cec303fac86c8541b686d37cac5efc0e094417bc"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.36"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "raw-window-handle"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "regex"
version = "1.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a"
[[package]]
name = "regex-syntax"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
[[package]]
name = "rxing"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "786ea2f74b327842bab8c9e611fa3e40f1549d7931fb6e63d1fc2f84e06c2323"
dependencies = [
"bit_reverse",
"chrono",
"chrono-tz",
"codepage-437",
"encoding",
"fancy-regex",
"multimap",
"num",
"once_cell",
"regex",
"rxing-one-d-proc-derive",
"thiserror",
"unicode-segmentation",
"uriparse",
"urlencoding",
]
[[package]]
name = "rxing-one-d-proc-derive"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59c92a4da773eed06d54210368684b4325bc70a0f8b0cc4d50a106bf71b954f2"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "ryu"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -173,9 +774,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.208"
version = "1.0.210"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cff085d2cb684faa248efb494c39b68e522822ac0de72ccf08109abde717cfb2"
checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a"
dependencies = [
"serde_derive",
]
@@ -192,9 +793,9 @@ dependencies = [
[[package]]
name = "serde_derive"
version = "1.0.208"
version = "1.0.210"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24008e81ff7613ed8e5ba0cfaf24e2c2f1e5b8a0495711e44fcd4882fca62bcf"
checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
dependencies = [
"proc-macro2",
"quote",
@@ -202,10 +803,22 @@ dependencies = [
]
[[package]]
name = "syn"
version = "2.0.75"
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6af063034fc1935ede7be0122941bafa9bacb949334d090b77ca98b5817c7d9"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "siphasher"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "syn"
version = "2.0.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed"
dependencies = [
"proc-macro2",
"quote",
@@ -214,18 +827,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "1.0.63"
version = "1.0.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.63"
version = "1.0.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3"
dependencies = [
"proc-macro2",
"quote",
@@ -239,10 +852,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b130bd8a58c163224b44e217b4239ca7b927d82bf6cc2fea1fc561d15056e3f7"
[[package]]
name = "unicode-ident"
version = "1.0.12"
name = "toml_datetime"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
[[package]]
name = "toml_edit"
version = "0.22.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b072cee73c449a636ffd6f32bd8de3a9f7119139aff882f44943ce2986dc5cf"
dependencies = [
"indexmap",
"toml_datetime",
"winnow",
]
[[package]]
name = "unicode-ident"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "uriparse"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff"
dependencies = [
"fnv",
"lazy_static",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "walkdir"
@@ -254,6 +906,61 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
dependencies = [
"cfg-if",
"once_cell",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
[[package]]
name = "winapi-util"
version = "0.1.9"
@@ -263,6 +970,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
@@ -401,3 +1117,12 @@ name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f"
dependencies = [
"memchr",
]
+8 -2
View File
@@ -6,8 +6,11 @@ edition = "2021"
[lib]
crate-type = ["cdylib"]
[features]
api-level-26 = ["ndk/api-level-26"]
[dependencies]
regex-lite = { version = "0.1" }
regex-lite = "0.1"
jni = "0.21"
jni_fn = "0.1"
quick-xml = { default-features = false, version = "0.36" }
@@ -16,7 +19,10 @@ log = "0.4"
android_logger = { default-features = false, version = "0.14" }
serde_cbor = "0.11"
serde = { version = "1.0", features = ["derive"] }
anyhow = { version = "1.0" }
anyhow = "1.0"
image = { default-features = false, version = "0.25" }
rxing = { default-features = false, version = "0.6" }
ndk = { version = "0.9", features = ["bitmap"] }
[profile.release]
panic = "abort"
+3
View File
@@ -0,0 +1,3 @@
mod border;
mod copy_ahb;
mod qr_code;
+95
View File
@@ -0,0 +1,95 @@
use crate::{jni_throwing, with_bitmap_content};
use anyhow::Context;
use image::{ImageBuffer, Luma, Pixel, Rgba};
use jni::objects::JClass;
use jni::sys::{jintArray, jobject};
use jni::JNIEnv;
use jni_fn::jni_fn;
/** A line will be considered as having content if 0.25% of it is filled. */
static FILLED_RATIO_LIMIT: f32 = 0.0025;
/** When the threshold is closer to 1, less content will be cropped. **/
static THRESHOLD: f32 = 0.75;
static THRESHOLD_FOR_BLACK: u8 = (255.0 * THRESHOLD) as u8;
static THRESHOLD_FOR_WHITE: u8 = (255.0 - 255.0 * THRESHOLD) as u8;
fn is_white(pixel: &Luma<u8>) -> bool {
pixel.0[0] > THRESHOLD_FOR_WHITE
}
fn is_black(pixel: &Luma<u8>) -> bool {
pixel.0[0] < THRESHOLD_FOR_BLACK
}
fn line_not_filled_by<'pixel>(
line: impl Iterator<Item = &'pixel Rgba<u8>>,
white: bool,
limit: i32,
) -> bool {
let f = if white { is_black } else { is_white };
line.step_by(2).filter(|p| f(&p.to_luma())).count() as i32 > limit
}
fn try_count_lines<'pixel>(
iter: impl Iterator<Item = impl Iterator<Item = &'pixel Rgba<u8>>>,
white: bool,
limit: i32,
) -> i32 {
let mut count = 1;
for line in iter {
if line_not_filled_by(line, white, limit) {
break;
} else {
count += 1;
}
}
count
}
fn detect_border_lines<'pixel>(
mut iter: impl Iterator<Item = impl Iterator<Item = &'pixel Rgba<u8>>>,
line_len: u32,
) -> i32 {
let first_row = iter.next().expect("Image is empty!");
let filled_limit = (line_len as f32 * FILLED_RATIO_LIMIT / 2.0).round() as i32;
let (mut black, mut white) = (0, 0);
for pixel in first_row.step_by(2) {
let luma = &pixel.to_luma();
if is_black(luma) {
black += 1;
} else if is_white(luma) {
white += 1;
}
}
match (black > filled_limit, white > filled_limit) {
// Black is dominant color.
(true, false) => try_count_lines(iter, false, filled_limit),
// White is dominant color.
(false, true) => try_count_lines(iter, true, filled_limit),
_ => 0,
}
}
fn detect_border(image: &ImageBuffer<Rgba<u8>, &[u8]>) -> Option<[i32; 4]> {
let (w, h) = image.dimensions();
let top = detect_border_lines(image.rows(), w);
let bottom = detect_border_lines(image.rows().rev(), w);
let iter = (0..w).map(|row| (0..h).map(move |col| image.get_pixel(row, col)));
let left = detect_border_lines(iter.clone(), h);
let right = detect_border_lines(iter.rev(), h);
Some([left, top, w as i32 - right, h as i32 - bottom])
}
#[no_mangle]
#[allow(non_snake_case)]
#[jni_fn("com.hippo.ehviewer.image.ImageKt")]
pub fn detectBorder(mut env: JNIEnv, _class: JClass, object: jobject) -> jintArray {
jni_throwing(&mut env, |env| {
let slice = with_bitmap_content(env, object, |img| Ok(detect_border(&img)))?;
let array = env.new_int_array(4)?;
env.set_int_array_region(&array, 0, &slice.context("Image too small!")?)?;
Ok(array.into_raw())
})
}
+32
View File
@@ -0,0 +1,32 @@
#![cfg(feature = "api-level-26")]
use crate::{jni_throwing, with_bitmap_content};
use anyhow::Context;
use image::{GenericImage, GenericImageView, ImageBuffer};
use jni::objects::JClass;
use jni::sys::{jint, jobject};
use jni::JNIEnv;
use jni_fn::jni_fn;
use ndk::hardware_buffer::{HardwareBuffer, HardwareBufferUsage};
use std::{ops::Deref, ptr::slice_from_raw_parts_mut};
#[no_mangle]
#[allow(non_snake_case)]
#[jni_fn("com.hippo.ehviewer.image.ImageKt")]
pub fn copyBitmapToAHB(mut env: JNIEnv, _: JClass, bm: jobject, ahb: jobject, x: jint, y: jint) {
let ahb = unsafe { HardwareBuffer::from_jni(env.get_raw(), ahb) };
jni_throwing(&mut env, |env| {
with_bitmap_content(env, bm, |src| {
let desc = ahb.describe();
let (w, h, stride) = (desc.width, desc.height, desc.stride);
let view = src.view(x as u32, y as u32, w, h);
let ptr = ahb.lock(HardwareBufferUsage::CPU_WRITE_RARELY, None, None)? as *mut u8;
let s = unsafe { &mut *slice_from_raw_parts_mut(ptr, (stride * h * 4) as usize) };
let dst = ImageBuffer::from_raw(stride, h, s).context("Illegal AHB format!");
let result = dst.map(|mut dst| dst.copy_from(view.deref(), 0, 0));
ahb.unlock()?;
result??;
Ok(())
})
})
}
+41
View File
@@ -0,0 +1,41 @@
use crate::{jni_throwing, with_bitmap_content};
use image::{ImageBuffer, Rgba};
use jni::objects::JClass;
use jni::sys::{jboolean, jobject};
use jni::JNIEnv;
use jni_fn::jni_fn;
use rxing::common::HybridBinarizer;
use rxing::qrcode::detector::FinderPatternFinder;
use rxing::{BinaryBitmap, DecodingHintDictionary, Point, RGBLuminanceSource};
use std::f32::consts::FRAC_1_SQRT_2;
use std::ptr::slice_from_raw_parts;
fn detect_image_ad(image: ImageBuffer<Rgba<u8>, &[u8]>) -> bool {
let (width, height) = (image.width() as usize, image.height() as usize);
let buffer = image.into_raw();
let pixels = unsafe { &*slice_from_raw_parts(buffer.as_ptr() as *const u32, buffer.len()) };
let source = RGBLuminanceSource::new_with_width_height_pixels(width, height, pixels);
let image = BinaryBitmap::new(HybridBinarizer::new(source));
match FinderPatternFinder::new(image.get_black_matrix()).find(&DecodingHintDictionary::new()) {
Ok(info) => {
// Check if the region is squarish
let top_left = Point::from(info.getTopLeft());
let top_right = Point::from(info.getTopRight());
let bottom_left = Point::from(info.getBottomLeft());
let length = top_right.distance(top_left);
let epsilon = length * 0.05;
(bottom_left.distance(top_left) - length).abs() < epsilon
&& (bottom_left.distance(top_right) * FRAC_1_SQRT_2 - length).abs() < epsilon
}
Err(_) => false,
}
}
#[no_mangle]
#[allow(non_snake_case)]
#[jni_fn("com.hippo.ehviewer.image.ImageKt")]
pub fn hasQrCode(mut env: JNIEnv, _class: JClass, object: jobject) -> jboolean {
jni_throwing(&mut env, |env| {
with_bitmap_content(env, object, |image| Ok(detect_image_ad(image) as jboolean))
})
}
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more