Compare commits

..
182 Commits
Author SHA1 Message Date
FooIbar fe4ab6fdab Prepare for release 1.13.0
Releases / build (push) Has been cancelled
2024-11-01 21:03:04 +08:00
FooIbar e60b6922c3 Prevent coil from adding cache-control headers
ConnectivityChecker is not reliable when used with VPN on old platforms.
2024-11-01 19:23:14 +08:00
Re*Index. (ot_inc) d3d2c907ad Update & Fix Japanese strings 2024-11-01 17:03:25 +08:00
FooIbar 84306e0b28 Use shallow clone to fetch git dependencies 2024-11-01 12:44:47 +08:00
FooIbar f7c2148d5f Update description of "view original image" 2024-11-01 12:05:40 +08:00
FooIbar 46e5a0e121 Update renovate config 2024-11-01 11:39:34 +08:00
renovate[bot] 2f5b52f12c Update AGP to v8.8.0-alpha09 2024-10-31 22:58:11 +00:00
renovate[bot] 20f9120583 Update plugin com.android.settings to v8.8.0-alpha09 2024-10-31 22:55:48 +00:00
FooIbar e65428cae1 Use SecureTextField for password 2024-10-31 23:51:26 +08:00
Dude so hot e5862e807c Fix to not add padding manually 2024-10-31 23:08:53 +08:00
Dude so hot 911fb3cae0 Update to use bracketCase 2024-10-31 22:17:45 +08:00
d76821f6a6 Unify decode phase to PageLoader (#1848)
* Make PageLoader size RAII

* Unify decode phase to PageLoader

* Fixes

* Fixes

* Fixes

* Refine

* Refine name

* Fixes

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Refine

* Revert

* Fixes

* Fixes

* Fixes

* Fixes

* Fixes

* Invalidate page cache on retryPage

* Value class

* Cancel bounds takes IntRange

* SpiderQueen awaitReade spread exception

* Reorder

* Fix

* Fix

* Refine

* Split install procress

* Interface default method

* Revert

* Revert

* Fix to clear error state

* Update app/src/main/kotlin/com/hippo/ehviewer/gallery/PageLoader.kt

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

* Refine

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-10-31 12:30:52 +00:00
renovate[bot] 1d5a680a5b Update dependency androidx.work:work-runtime to v2.10.0 2024-10-31 01:19:56 +00:00
renovate[bot] 2f38e17fd6 Update dependency androidx.core:core-ktx to v1.15.0 2024-10-31 01:19:11 +00:00
renovate[bot] 7de4bea5f7 Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.0 2024-10-31 00:30:00 +00:00
renovate[bot] 35db8baa2a Update dependency androidx.compose:compose-bom-alpha to v2024.10.01 2024-10-31 00:29:50 +00:00
renovate[bot] e774dd3a6c Update benchmark to v1.4.0-alpha04 2024-10-30 21:13:16 +00:00
renovate[bot] f3619c6b43 Update dependency androidx.activity:activity-compose to v1.10.0-alpha03 2024-10-30 21:12:43 +00:00
renovate[bot] b807081f9d Update androidx.room to v2.7.0-alpha11 2024-10-30 19:03:46 +00:00
renovate[bot] 0807d7f1ce Update androidx.lifecycle to v2.9.0-alpha06 2024-10-30 19:03:18 +00:00
FooIbar 8b6894716b Remove fixed ktor workaround 2024-10-30 23:21:00 +08:00
Dude so hot cb733d8e38 Move ktlint to stable 2024-10-30 23:11:19 +08:00
FooIbar d9c7daebea Fix save image filename for external archives 2024-10-30 23:00:34 +08:00
FooIbar bbe8fd6b8f Use .editorconfig to disable ktlint rule (#1844) 2024-10-30 13:58:53 +00:00
Dude so hot 8f8fe513fa Cleanup ctor 2024-10-30 20:30:53 +08:00
Dude so hot 8a4df1f2ba Disable ktlint_standard_class-signature 2024-10-30 20:30:53 +08:00
Dude so hot dacbf5edb3 Fix thumb extension for webp 2024-10-30 19:08:52 +08:00
Dude so hot 4e145f46db Demagic DownloadThumbInterceptor 2024-10-30 19:08:52 +08:00
renovate[bot] 53dc622376 Update dependency io.ktor:ktor-client-okhttp to v3.0.1 2024-10-29 23:37:20 +00:00
renovate[bot] aab8fabb1f Disable SaveBodyPlugin & fix non-redirect Ktor client defaults 2024-10-29 16:21:19 +00:00
FooIbar 86bb240633 Disable Cronet on some incompatible devices 2024-10-29 23:41:40 +08:00
Dude so hot c6b3aec1af Fix build 2024-10-29 23:35:59 +08:00
Dude so hot e6c4b32a47 Use defaultRequest 2024-10-29 23:35:59 +08:00
Dude so hot b71e83047d Install UA for ktor client 2024-10-29 23:35:59 +08:00
Dude so hot 8609336240 Move to use BlackholeDecoder.Factory() 2024-10-29 23:35:59 +08:00
FooIbar ded11a81a5 Refine cache key for v2 gallery preview 2024-10-29 21:17:45 +08:00
renovate[bot]andDude so hot 0c00ea9292 Refactor PageLoader (#1830)
* Cleanup PageLoader

* Refactor ReaderScreen

* Refactor EhPageLoader

* Refine

* Refine

* Refine

* Refine

* Refine

* Don't inline

* Fix recompose

* Merge PageLoader

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-29 08:10:52 +00:00
FooIbar 672b53d8a9 Migrate to settings plugin and update NDK to r27c 2024-10-29 15:27:08 +08:00
FooIbar bc5032b2d0 Fix off-screen fetcher jobs blocking decode queue 2024-10-29 06:56:24 +08:00
renovate[bot] 4cefa439ec Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-rc02 2024-10-28 21:26:43 +00:00
FooIbar 4d2b7b3ad0 Adapt to the upcoming thumbnail changes (#1823) 2024-10-28 22:55:27 +08:00
FooIbar 58f85cc436 Clean up or parameter as it's no longer required 2024-10-28 20:14:52 +08:00
FooIbar 5c50d7622b Unify normal and small thumbnails parsing 2024-10-28 20:14:52 +08:00
renovate[bot] e90b31bf69 Update Rust crate quick-xml to 0.37 2024-10-28 00:48:23 +00:00
renovate[bot] b43d60138b Lock file maintenance 2024-10-28 00:47:49 +00:00
FooIbar 9f4faebfe6 Use ktor plugin for download timeout 2024-10-28 00:00:48 +08:00
FooIbar b6f84d8de6 Group ad utils and fix a main thread IO 2024-10-27 22:58:15 +08:00
FooIbar 230eec7c59 Revert "Block AD in Large preview (#1694)"
This reverts commit 04ee1a63

Reason for revert: Obsoleted by the upcoming changes
2024-10-27 22:58:15 +08:00
renovate[bot]andDude so hot 3b9cfefd5f Fix Small Preview CrossFade not work (#1816)
* Fix Small Preview CrossFade not work

* Abstract

* Update MergeInterceptor.kt

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-27 12:01:53 +00:00
FooIbar c1b701f1f9 Only check file integrity for images served by H@H
URLs returned by non-H@H servers may not contain file hash for resampled
images.
2024-10-27 16:10:47 +08:00
FooIbar 4744caaacc Show search warnings (if any) when no hits found 2024-10-27 15:48:46 +08:00
FooIbar b58912462a Make image limits description unambiguous 2024-10-27 12:36:56 +08:00
FooIbar f3cadf594d Fix crash on devices without WallpaperService 2024-10-27 11:30:37 +08:00
FooIbar a8834ee21c Fix menu click glitch after pager type change 2024-10-26 22:18:52 +08:00
FooIbar 0c59403e49 Add support for arrow key navigation (#1808)
* Add support for arrow key navigation

* Refactor key event handler to use Compose KeyEvent

Also removed volume key configurations.

* Fix focus requester
2024-10-26 12:54:29 +00:00
FooIbar 431a771672 Relax regex for the upcoming WebP adoption 2024-10-26 13:54:16 +08:00
Dude so hot b043b06208 Refine TagNamespace 2024-10-26 10:45:23 +08:00
renovate[bot] abccf713df Update AGP to v8.8.0-alpha08 2024-10-25 19:21:42 +00:00
renovate[bot] 4b83f6f28f Update dependency com.google.devtools.ksp to v2.1.0-Beta2-1.0.26 2024-10-25 19:19:06 +00:00
renovate[bot]andDude so hot e843a547a5 Make GalleryTagGroup immutable & non-empty (#1798)
* Make GalleryTagGroup immutable & non-empty

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-25 15:18:12 +00:00
小白-白 40d9bfa30b Fix thumb parsing when labels are enabled 2024-10-25 22:00:53 +08:00
小白-白 69669f0a50 Fix archive download 2024-10-25 21:04:00 +08:00
Dude so hot 4c20d27d33 Optimize regex with Chatgpt 2024-10-25 18:34:00 +08:00
小白-白 06eb277b34 Fix preview parser 2024-10-25 16:18:52 +08:00
renovate[bot] 86120277c7 Update dependency androidx.work:work-runtime to v2.10.0-rc01 2024-10-24 18:16:09 +00:00
renovate[bot]andDude so hot 62927cb6a8 Kill Legacy TextView (#1786)
* Kill Legacy TextView

* Fix textStyle

* Fix color

* Add breakToTextAndUrl

* Impl breakToTextAndUrl

* Fix OOB

* Update GalleryCommentsScreen.kt

* Fix bug

* Fix color

* Refine regex

* Add undeline as well

* Refine

* Revert

* Don't break if not show image

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-24 23:38:27 +08:00
renovate[bot]andDude so hot 22f31f2ebc Remember pageloader in ViewModel (#1780)
* Remember pageloader in ViewModel

* Fix hang on

* Refine

* Move ***InVM functions to ViewModelState

* Add rememberInVM overload takes no key

* Support asyncInVM & launchInVM with key

* Move UNCHECKED_CAST code

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-23 21:57:28 +08:00
FooIbar c8cfaeecf6 Clean up java.nio APIs usage to avoid desugaring 2024-10-23 18:03:45 +08:00
FooIbar 714d334067 Misc cleanup 2024-10-23 15:20:43 +08:00
FooIbar ca324995b3 Clean up ExceptionUtils with Either.catch 2024-10-23 15:20:43 +08:00
FooIbar f301eab5b1 Fix preview list not recomposing on detail refresh 2024-10-23 11:27:04 +08:00
FooIbar 87ac60a3ab Clean up GalleryListUrlParser/Builder 2024-10-22 22:52:36 +08:00
FooIbar a2f89f07fa Use e-hentai thumbnail server by default 2024-10-22 21:40:21 +08:00
renovate[bot]andDude so hot 57d7d2b798 Kill Switch (#1778)
* Kill Switch

* Refine comment

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-21 13:32:01 +00:00
Dude so hot 6f3bc25b64 Don't apply NonCancellable to H@H large images 2024-10-21 20:54:37 +08:00
Dude so hot 62477dac87 Fix NamedSemaphore naming 2024-10-21 20:54:37 +08:00
Dude so hot 0dfee513f4 Lock concurrency for Normal previews as well 2024-10-21 20:54:37 +08:00
Dude so hot 31f97cb840 Introduce NamedSemaphore 2024-10-21 20:54:37 +08:00
Dude so hot 93e2d36dbb Share semaphore 2024-10-21 20:54:37 +08:00
Dude so hot aed3d08167 Fetch from h1 server is NonCancellable 2024-10-21 20:54:37 +08:00
Dude so hot 9c5f11dd9d Fix step 2024-10-21 20:54:37 +08:00
Dude so hot 1af7aa8a16 Allow configure thumb concurrency temporarily 2024-10-21 20:54:37 +08:00
Dude so hot cf6f793478 Limit thumb fetcher concurrency 2024-10-21 20:54:37 +08:00
Dude so hot 145948f506 Add HttpClientConfig<T>.configureCommon 2024-10-21 20:54:37 +08:00
FooIbar b068492d10 Refine caching configuration in CI 2024-10-21 12:13:03 +08:00
renovate[bot] 8c94d39ae4 Lock file maintenance 2024-10-21 01:43:19 +00:00
Dude so hot c584d72f41 Rotate hue ourselves 2024-10-20 22:31:16 +08:00
Dude so hot cfd248f818 Support WallPaper DynamicColor on O_MR1 above 2024-10-20 22:31:16 +08:00
Dude so hot b9e59ee2f8 Harmonize with Kolor functions 2024-10-20 22:31:16 +08:00
Dude so hot 439899fbdc Use Expressive MotionScheme 2024-10-20 22:31:16 +08:00
Dude so hot 177c1889fa Import material-kolor 2024-10-20 22:31:16 +08:00
FooIbar ebce847724 Update OkHttp and configure async DNS 2024-10-20 19:32:26 +08:00
renovate[bot]andDude so hot c6b6d9a310 Fallback to Okhttp instead (#1771)
* Fallback to Okhttp instead

* Drop unused proguard

* Exclude okhttp3 public suffix

* Refine

* Refine

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-20 09:21:14 +00:00
Dude so hot 6601a45472 GP divide by 1k 2024-10-19 22:53:37 +08:00
Dude so hot d66fb9a783 RollingNumber support separator 2024-10-19 22:53:37 +08:00
Dude so hot c244369875 Apply RollingNumber to quota 2024-10-19 22:53:37 +08:00
Dude so hot f9c812864e Apply RollingNumber to gp & c 2024-10-19 22:53:37 +08:00
Dude so hot 87d0cdd9e5 Fix to measure RollingNumber with +inf maxHeight 2024-10-19 22:53:37 +08:00
Dude so hot d237a18aae Refine Naming 2024-10-19 21:36:40 +08:00
Dude so hot 0159cf30ae NamedMutex shares MutexPool 2024-10-19 21:36:40 +08:00
Dude so hot 6aba1670ec Refine NamedMutex 2024-10-19 21:36:40 +08:00
Dude so hot 447a31ea00 Restore default MaxConnPerRoute 2024-10-19 21:36:40 +08:00
FooIbar 9e410a61ef Pin Rust toolchain version to 1.81.0
1.82.0 has a severe binary size regression (+36%)
2024-10-19 21:27:34 +08:00
FooIbar 4588b7eaa5 Refine resources packing options 2024-10-19 15:23:24 +08:00
FooIbar aef55c9207 Use Cargo.lock for reproducible builds 2024-10-19 15:23:24 +08:00
renovate[bot] 9a587e9dd2 Update plugin composeCompilerReportGenerator to v1.4.1 2024-10-19 06:40:15 +00:00
renovate[bot] cbe834c71d Update Kotlin & KSP 2024-10-19 08:46:37 +08:00
FooIbar 020495cebf Fix comparison with leading zeros and clean up 2024-10-19 00:31:38 +08:00
FooIbar 54d3cccb67 Fix AnchoredDraggable fling behavior 2024-10-19 00:29:47 +08:00
renovate[bot] b4adf57f39 Update compose.destinations to v2.1.0-beta14 2024-10-18 13:34:21 +00:00
b796dbcde3 Partially Reland "Use Apache5 as fallback HTTP engine" (#1755)
* Use Apache5 as fallback HTTP engine

* Fix R8

* Depolymorphism CronetEngine context

* Conditionally insert conscrypt

* Limit IO thread cnt to 1

* Don't package compose destination as well

* Refine

* Add an option for debugging HTTP engines

---------

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-18 10:18:46 +00:00
FooIbar ba2c69690f Revert "Use Apache5 as fallback HTTP engine (#1752)"
This reverts commit c3b60f1eac.
2024-10-18 16:16:36 +08:00
FooIbarandDude so hot c3b60f1eac Use Apache5 as fallback HTTP engine (#1752)
* Use Apache5 as fallback HTTP engine

* Fix R8

* Depolymorphism CronetEngine context

* Drop Conscrypt, don't enforce anything

* Limit to 1 io thread count

* Fix H2 support below A10

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-18 15:57:42 +08:00
renovate[bot] 2bb387eeaf Update AGP to v8.8.0-alpha07 2024-10-17 22:50:11 +00:00
FooIbar fd67c733aa Always upload R8 reports 2024-10-18 00:17:44 +08:00
renovate[bot]andDude so hot eac7048360 Optimise non-fixed length RollingNumber (#1751)
* Try Optimise non-fixed length RollingNumber

* Apply RollingNumber to SliderPref

* Revert Debug

* RollingNumber handle negative value

* Fix typo

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-17 21:54:20 +08:00
renovate[bot]andDude so hot 34de943c9f Introduce RollingNumberPlaceholder (#1750)
* Introduce RollingNumberPlaceholder, sync page index double end style

* Cleanup

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-17 19:27:41 +08:00
renovate[bot] 302b43c9e8 Update dependency sh.calvin.reorderable:reorderable to v2.4.0 2024-10-17 09:02:20 +00:00
FooIbar 07b3917c4e Best effort for devices without DocumentsUI 2024-10-17 16:55:44 +08:00
FooIbar 1ace248121 Allow resetting to default download location 2024-10-17 16:55:44 +08:00
renovate[bot]andDude so hot 7a96feb049 Rolling number POC, convert page index (#1740)
* Rolling number POC, convert page index

* Refine

* Improve

* Comments

* Refine Comments

* Bigger number is from top

* Prepare to make content moveable

* Make content index reversed

* Use moveableContentOf

* Increase interval

* Fix calculation

* Fix calculation again

* Extract

* Refine

* Use zero-sized placeholder

* Fix width

* Allow RollingNumber have fixed width

* Improve comments

* Make moveableContent initialized lazily

* Reverse direction

* Avoid movableContentOf if width fixed

* Use Arrangement.spacedBy

* Improve animation

* Refine

* Fix comment

* Fix padding

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-17 06:07:49 +00:00
renovate[bot]andrenovate[bot] 5df1d099a1 Update dependency androidx.compose:compose-bom-alpha to v2024.10.00 (#1746)
* Update dependency androidx.compose:compose-bom-alpha to v2024.10.00

* Update Theme.k

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <99822064+revonateB0T@users.noreply.github.com>
2024-10-17 00:30:48 +00:00
renovate[bot] ed2db49cdb Update dependency androidx.core:core-ktx to v1.15.0-rc01 2024-10-17 00:23:24 +00:00
renovate[bot] 7b2c99357e Update androidx.lifecycle to v2.9.0-alpha05 2024-10-17 00:16:37 +00:00
renovate[bot] 4868c40795 Update androidx.room to v2.7.0-alpha10 2024-10-17 00:16:10 +00:00
renovate[bot]andrenovate[bot] 27ff17db50 Update benchmark to v1.4.0-alpha03 (#1743)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <99822064+revonateB0T@users.noreply.github.com>
2024-10-17 00:15:47 +00:00
renovate[bot] c15a52eb2a Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.0-rc01 2024-10-16 23:56:52 +00:00
FooIbar faa54dedb1 Migrate to BTF2
This reverts commit 8a29d46b
2024-10-16 11:14:54 +08:00
renovate[bot] 59bdb4b2f0 Update dependency io.github.pdvrieze.xmlutil:serialization to v0.90.2 2024-10-15 20:23:09 +00:00
renovate[bot] 4990d757ae Update dependency chrome to v130 2024-10-15 20:22:40 +00:00
FooIbar 5f71c3dcb4 Update Compose Material3 to 1.4 2024-10-15 23:58:56 +08:00
Dude so hot 1892a750b7 Address Code Review 2024-10-15 21:15:38 +08:00
Dude so hot 020f853bb1 MergeInterceptor use NamedMutex 2024-10-15 21:15:38 +08:00
Dude so hot 9ccddae60a Introduce Better NamedMutex 2024-10-15 21:15:38 +08:00
Dude so hot b7a2d93f03 Optimize rememberInVM allocation 2024-10-15 19:08:12 +08:00
Dude so hot 2fc8b421b0 Optimize collection access 2024-10-15 19:08:12 +08:00
Dude so hot 180466674f Optimize statesMap to use mutableIntObjectMapOf 2024-10-15 19:08:12 +08:00
Dude so hot ac23a4312d Address CR 2024-10-15 19:08:12 +08:00
Dude so hot aaea83b932 Minor cleanup GalleryDetailScreen 2024-10-15 19:08:12 +08:00
Dude so hot 8425ea0b2f Move SpiderDen file cache to use rwlock 2024-10-15 19:08:12 +08:00
Dude so hot 100a6569b7 Use rwlock 2024-10-15 13:40:02 +08:00
Dude so hot e8c4b9ab78 Ensure PageLoader cache sync 2024-10-15 13:03:18 +08:00
Dude so hot eff5467ccf Cleanup CrashHandler 2024-10-15 13:03:18 +08:00
Dude so hot 769308c9f0 Remove obsolete 2024-10-15 13:03:18 +08:00
Dude so hot e1de57c98c Ensure QrCodeInterceptor cache thread-safe 2024-10-15 13:03:18 +08:00
Dude so hot 9c6a5af87e Ensure FavouriteStatusRouter thread-safe 2024-10-15 13:03:18 +08:00
Dude so hot 9d88577108 Move to SieveCache 2024-10-15 13:03:18 +08:00
renovate[bot]andrenovate[bot] dbebc5956f Update AGP to v8.8.0-alpha06 (#1728)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <99822064+revonateB0T@users.noreply.github.com>
2024-10-15 02:30:35 +00:00
renovate[bot]andDude so hot 52ca5a7eb8 SpotlessAPply (#1729)
Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-10-15 02:19:56 +00:00
FooIbar 78b898dc5c Reduce build noise 2024-10-14 20:48:13 +08:00
Dude so hot 1e81157d5c Update VectorPainterCache to use MutableScatterMap 2024-10-14 13:27:49 +08:00
Dude so hot 5cff3db9b5 Drop unused local variable 2024-10-14 13:27:49 +08:00
Dude so hot 25fe80f252 Drop DismissDialogScope wrapper 2024-10-14 13:27:49 +08:00
Dude so hot 413bb2cd82 No longer need write permission as we copy magnet now 2024-10-14 13:27:49 +08:00
renovate[bot] b867bbc7a6 Lock file maintenance 2024-10-14 03:23:13 +00:00
Dude so hot 3bf6f763aa Optimise DialogState with value class 2024-10-13 20:48:24 +08:00
2c0d621b16 Add avatar (#1720)
* Load Avatar

* Click Avatar to show image limit

* Shared Limit among collection

* Cleanup and configure heartbeat

* Always refresh avatars

* Refine icons

* Avoid heartbeat

* Fix mistake

* No need to SharingStarted.WhileSubscribed

* Address code review

* Simply map error msg

* Convert limitFlow to stateFlow

* Fixes

* Refine retry logic

* Don't retry automatically

* Don't cache error result

* Animate quota changes

* Logout from guest mode via avatar

* No need to navigate explicitly as needSignIn is observed

* Make PostLogin not suspend

* More UX friendly

* Fix progressIndicator not disappear

* Use Either.catch.mapLeft

* Refine

* Add overload for awaitConfirmationOrCancel

* Log error with snackbar

* Refine

* Fix to use collectLatest

* Refine

* Refine

* Address cr

* Refine

* Animated loading

* Refine

* Fix

* Fix

* Refine image limits dialog

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-10-13 19:36:02 +08:00
renovate[bot] e930ebf42f Update dependency libarchive to v3.7.7 2024-10-13 10:41:48 +00:00
Dude so hot 4e3862b1c5 Move ImageSearchScreen 2024-10-12 21:21:14 +08:00
Dude so hot e3ffb9a473 Spilt ImageSearch from GalleryListScreen 2024-10-12 21:21:14 +08:00
FooIbar e485d79bb1 Drop torrent download in favor of magnet links 2024-10-12 15:26:37 +08:00
renovate[bot] 49edbb8185 Update compose.destinations to v2.1.0-beta13 2024-10-10 13:58:06 +00:00
renovate[bot] 32f7407752 Update dependency io.ktor:ktor-client-core to v3.0.0 2024-10-09 17:15:48 +00:00
renovate[bot]andFooIbar aef02d34a5 Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-rc01 (#1713)
* Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-rc01

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-10-08 11:24:43 +00:00
renovate[bot] bb2f9bb309 Lock file maintenance 2024-10-07 07:48:01 +00:00
FooIbar 140b6528a6 Fix ktlint snapshot 2024-10-07 15:40:23 +08:00
FooIbar fa3cf650ea Pin Compose Material3 version to 1.3 2024-10-05 16:11:48 +08:00
renovate[bot] cfe2cff74c Update AGP to v8.8.0-alpha05 2024-10-03 22:08:36 +00:00
renovate[bot] 1def52e41a Update dependency androidx.compose:compose-bom-alpha to v2024.09.03 2024-10-03 19:18:58 +00:00
renovate[bot] cc4eab3244 Update dependency io.ktor:ktor-client-core to v3.0.0-rc-2 2024-10-03 09:08:35 +00:00
renovate[bot] 4327e48c1f Update dependency androidx.profileinstaller:profileinstaller to v1.4.1 2024-10-03 00:25:35 +00:00
renovate[bot] c8c077c14b Update dependency androidx.work:work-runtime to v2.10.0-beta01 2024-10-03 00:25:19 +00:00
renovate[bot] f82221552b Update benchmark to v1.4.0-alpha02 2024-10-02 21:08:39 +00:00
renovate[bot] bfe0aad951 Update dependency androidx.core:core-ktx to v1.15.0-beta01 2024-10-02 21:08:05 +00:00
renovate[bot] 2f5d8edb03 Update androidx.lifecycle to v2.9.0-alpha04 2024-10-02 19:09:56 +00:00
renovate[bot] ac0acafff9 Update androidx.room to v2.7.0-alpha09 2024-10-02 19:09:27 +00:00
renovate[bot] 9333150c3b Update dependency liblzma to v5.6.3 2024-10-01 18:41:54 +00:00
renovate[bot] 0c71c6856b Update dependency chrome to v129 2024-10-01 05:59:07 +00:00
FooIbar 6a47a8a1e1 Use renovate to update Chrome version 2024-10-01 13:51:58 +08:00
renovate[bot]andDude so hot 04ee1a6362 Block AD in Large preview (#1694)
* Cleanup with `getOrPut`

* Refine `ArchivePageLoader` naming

* Move `PageLoader`

* Bump ktlint to fix when guards

* Extract `AdsPlaceholder`

* Detect QrCode for LargePreview

* Conditional detect

* Fixes

* Fixes

* Cleanup code

* Fix render

* Fixup hardware bitmap

* Original size to detect

* Log QrCode detect exception

* Don't mutate cached

* Refine ListUrlBuilder

* Move functions

* Enable preview adblocker for debug build only

---------

Co-authored-by: Dude so hot <djohn@fbi.gov>
2024-09-30 21:09:56 +08:00
renovate[bot] 9ead29643d Lock file maintenance 2024-09-30 00:22:01 +00:00
FooIbar d8e7bd4ccc Prepare for next iteration 2024-09-28 22:57:59 +08:00
159 changed files with 3870 additions and 3589 deletions

No files matched your search

+1
View File
@@ -2,3 +2,4 @@
ij_kotlin_imports_layout = *
ktlint_code_style = intellij_idea
ktlint_function_naming_ignore_when_annotated_with = Composable
ktlint_standard_class-signature = disabled
+7 -5
View File
@@ -21,7 +21,7 @@ jobs:
java-version: 21
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -61,7 +61,7 @@ jobs:
java-version: 21
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -79,7 +79,7 @@ jobs:
uses: actions/cache@v4
with:
path: app/.cxx
key: cmake-default-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
key: cmake-default-${{ hashFiles('settings.gradle.kts', 'app/src/main/cpp/CMakeLists.txt', 'app/src/main/rust/Cargo.lock') }}
restore-keys: cmake-default-
- name: Build
@@ -104,6 +104,7 @@ jobs:
path: app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk
- name: Upload mapping
if: always()
uses: actions/upload-artifact@v4
with:
name: default-mapping-${{ github.sha }}
@@ -128,7 +129,7 @@ jobs:
java-version: 21
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -146,7 +147,7 @@ jobs:
uses: actions/cache@v4
with:
path: app/.cxx
key: cmake-marshmallow-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
key: cmake-marshmallow-${{ hashFiles('settings.gradle.kts', 'app/src/main/cpp/CMakeLists.txt', 'app/src/main/rust/Cargo.lock') }}
restore-keys: cmake-marshmallow-
- name: Build
@@ -171,6 +172,7 @@ jobs:
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk
- name: Upload mapping
if: always()
uses: actions/upload-artifact@v4
with:
name: marshmallow-mapping-${{ github.sha }}
+12 -14
View File
@@ -9,6 +9,15 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Free disk space
run: rm -rf $AGENT_TOOLSDIRECTORY
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Checkout
uses: actions/checkout@v4
@@ -18,19 +27,8 @@ jobs:
distribution: temurin
java-version: 21
- name: Free disk space
run: |
shopt -s extglob
rm -rf ${ANDROID_SDK_ROOT}/ndk/!(27*)
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -38,10 +36,10 @@ jobs:
workspaces: "./app/src/main/rust"
- name: CMake Cache
uses: actions/cache@v4
uses: actions/cache/restore@v4
with:
path: app/.cxx
key: cmake-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
key: cmake-
restore-keys: cmake-
- name: Setup Gradle
@@ -1,29 +0,0 @@
name: 'Update Chrome Version'
on:
schedule:
- cron: '0 0 1 * *'
workflow_dispatch:
jobs:
action:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Update Chrome Version
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd chrome-for-testing
curl -OLf https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_STABLE
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config push.autoSetupRemote true
git switch -c chore/chrome
if git commit -am "Update Chrome version for default User-Agent"; then
git push
gh pr create -f
gh pr merge chore/chrome --auto -dr
fi
+27
View File
@@ -1,5 +1,32 @@
# 变更日志
## [1.13.0] - 2024-11-01
### 重大变更
* 不再捆绑 Cronet 库,在不支持 HttpEngine 的设备上回退到 OkHttp
* 适配 E 站缩略图和 WebP 等相关变更
### 改进
* 使用磁力链接代替种子下载
* 在搜索栏右侧显示论坛头像
* 支持重置下载路径
* 为 Android 10 以下且没有 DocumentsUI 的设备创建默认下载目录 [#1735](https://github.com/FooIbar/EhViewer/issues/1735)
* 重新添加了强制使用 e-hentai 缩略图服务器的选项
* 阅读器支持 PageUp/PageDown 和方向键上/下翻页 [#1801](https://github.com/FooIbar/EhViewer/issues/1801)
* 未找到搜索结果时显示可能存在的警告 [#1787](https://github.com/FooIbar/EhViewer/issues/1787)
* 更新了日本语翻译
* 杂项优化
### Bug 修复
* 阅读器屏幕方向与手机屏幕方向不同时闪退 [#1779](https://github.com/FooIbar/EhViewer/issues/1779)
* 下载文件哈希不匹配导致下载失败 [#1811](https://github.com/FooIbar/EhViewer/issues/1811)
* 无法同时下载和查看 [#1822](https://github.com/FooIbar/EhViewer/issues/1822)
* 杂项修复
## [1.12.1] - 2024-09-28
### 新功能
+23 -24
View File
@@ -21,10 +21,6 @@ plugins {
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
android {
compileSdk = 35
buildToolsVersion = "35.0.0"
ndkVersion = "27.1.12297006"
androidResources {
generateLocaleConfig = true
localeFilters += listOf(
@@ -78,15 +74,12 @@ android {
}.standardOutput.asText.get().trim().removePrefix("https://github.com/").removePrefix("git@github.com:")
.removeSuffix(".git")
val chromeVersion = rootProject.layout.projectDirectory.file("chrome-for-testing/LATEST_RELEASE_STABLE").asFile
.readText().substringBefore('.')
val chromeVersion = rootProject.layout.projectDirectory.file("chrome.version").asFile.readText().trim()
defaultConfig {
applicationId = "moe.tarsin.ehviewer"
minSdk = 26
targetSdk = 35
versionCode = 180060
versionName = "1.12.1"
versionCode = 180061
versionName = "1.13.0"
// versionNameSuffix = "-SNAPSHOT"
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
@@ -137,6 +130,19 @@ android {
dex {
useLegacyPackaging = false
}
resources {
// Required by Layout Inspector
pickFirsts += "/META-INF/androidx.compose.ui_ui.version"
excludes += listOf(
"/META-INF/**",
"/kotlin/**",
"**.txt",
"**.bin",
"**.{html,mmd}", // Compose Destination
"/okhttp3/**", // Okhttp public suffix
)
}
}
dependenciesInfo.includeInApk = false
@@ -172,17 +178,6 @@ composeCompiler {
featureFlags = setOf(ComposeFeatureFlag.OptimizeNonSkippingGroups)
}
androidComponents {
onVariants(selector().withBuildType("release")) {
it.packaging.resources.excludes.addAll(
"/META-INF/**",
"/kotlin/**",
"**.txt",
"**.bin",
)
}
}
baselineProfile {
mergeIntoMain = true
}
@@ -221,9 +216,14 @@ dependencies {
implementation(libs.androidx.work.runtime)
implementation(libs.material.motion.core)
implementation(libs.material.kolor)
implementation(libs.bundles.splitties)
// https://square.github.io/okhttp/changelogs/changelog/
implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp.android)
implementation(libs.okio.jvm)
implementation(libs.logcat)
@@ -246,7 +246,7 @@ dependencies {
implementation(libs.telephoto.zoomable)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.bundles.kotlinx.serialization)
@@ -256,8 +256,6 @@ dependencies {
coreLibraryDesugaring(libs.desugar)
implementation(libs.cronet.embedded)
implementation(libs.androidx.profileinstaller)
"baselineProfile"(project(":benchmark"))
@@ -279,6 +277,7 @@ kotlin {
"-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",
-2
View File
@@ -14,8 +14,6 @@
# Ktor logger
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn org.conscrypt.Conscrypt
# https://issuetracker.google.com/222232895
-dontwarn androidx.window.extensions.**
-dontwarn androidx.window.sidecar.Sidecar*
+8 -4
View File
@@ -23,7 +23,8 @@ set(XZ_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch)
FetchContent_Declare(
liblzma
GIT_REPOSITORY https://github.com/tukaani-project/xz.git
GIT_TAG v5.6.2
GIT_TAG v5.6.3
GIT_SHALLOW 1
PATCH_COMMAND git apply --check -R ${XZ_PATCH} || git apply ${XZ_PATCH}
)
@@ -46,9 +47,10 @@ ExternalProject_Add(
PREFIX nettle
GIT_REPOSITORY https://gitlab.com/gnutls/nettle.git
GIT_TAG nettle_3.10_release_20240616
GIT_SHALLOW 1
GIT_CONFIG core.autocrlf=input
CONFIGURE_COMMAND bash ${CURRENT_DIR}/configure_nettle.sh ${NDK_VERSION} ${CMAKE_C_COMPILER_TARGET}
BUILD_COMMAND bash -c "make libnettle.a CFLAGS='${CMAKE_C_FLAGS}'"
BUILD_COMMAND bash -c "make libnettle.a CFLAGS='${CMAKE_C_FLAGS} -Qunused-arguments'"
INSTALL_COMMAND ""
BUILD_IN_SOURCE 1
BUILD_BYPRODUCTS nettle/src/nettle/libnettle.a
@@ -90,7 +92,8 @@ 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.6
GIT_TAG v3.7.7
GIT_SHALLOW 1
PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH}
)
@@ -101,10 +104,11 @@ FetchContent_Declare(
Corrosion
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
GIT_TAG v0.5
GIT_SHALLOW 1
)
FetchContent_MakeAvailable(Corrosion)
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml)
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml LOCKED)
corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384")
if (ANDROID_NATIVE_API_LEVEL GREATER_EQUAL 26)
corrosion_set_features(ehviewer_rust FEATURES api-level-26)
+23 -53
View File
@@ -30,7 +30,6 @@
* negative chars in their default char type.
*/
#include <stddef.h> /* size_t */
#include <ctype.h>
#include "strnatcmp.h"
@@ -39,28 +38,21 @@
/* These are defined as macros to make it easier to adapt this code to
* different characters types or comparison functions. */
static inline int
nat_isdigit(nat_char a)
nat_isdigit(const char *a)
{
return isdigit((unsigned char) a);
return isdigit((unsigned char) *a);
}
static inline int
nat_isspace(nat_char a)
nat_isspace(const char *a)
{
return isspace((unsigned char) a);
}
static inline nat_char
nat_toupper(nat_char a)
{
return toupper((unsigned char) a);
return isspace((unsigned char) *a) || *a == '0' && nat_isdigit(a + 1);
}
static int
compare_right(nat_char const *a, nat_char const *b)
compare_right(const char *a, const char *b)
{
int bias = 0;
@@ -69,11 +61,11 @@ compare_right(nat_char const *a, nat_char const *b)
both numbers to know that they have the same magnitude, so we
remember it in BIAS. */
for (;; a++, b++) {
if (!nat_isdigit(*a) && !nat_isdigit(*b))
if (!nat_isdigit(a) && !nat_isdigit(b))
return bias;
if (!nat_isdigit(*a))
if (!nat_isdigit(a))
return -1;
if (!nat_isdigit(*b))
if (!nat_isdigit(b))
return +1;
if (*a < *b) {
if (!bias)
@@ -87,60 +79,38 @@ compare_right(nat_char const *a, nat_char const *b)
}
static int
strnatcmp0(nat_char const *a, nat_char const *b, int fold_case)
int
strnatcmp(const char *a, const char *b)
{
int ai, bi;
nat_char ca, cb;
int result;
int result;
ai = bi = 0;
while (1) {
ca = a[ai]; cb = b[bi];
while (1) {
/* skip over leading spaces or zeros */
while (nat_isspace(a))
a++;
/* skip over leading spaces or zeros */
while (nat_isspace(ca) || ca == '0')
ca = a[++ai];
while (nat_isspace(cb) || cb == '0')
cb = b[++bi];
while (nat_isspace(b))
b++;
/* process run of digits */
if (nat_isdigit(ca) && nat_isdigit(cb)) {
if ((result = compare_right(a+ai, b+bi)) != 0)
if (nat_isdigit(a) && nat_isdigit(b)) {
if ((result = compare_right(a, b)) != 0)
return result;
}
if (!ca && !cb) {
if (!*a && !*b) {
/* The strings compare the same. Perhaps the caller
will want to call strcmp to break the tie. */
return 0;
}
if (fold_case) {
ca = nat_toupper(ca);
cb = nat_toupper(cb);
}
if (ca < cb)
if (*a < *b)
return -1;
if (ca > cb)
if (*a > *b)
return +1;
++ai; ++bi;
a++; b++;
}
}
int
strnatcmp(nat_char const *a, nat_char const *b) {
return strnatcmp0(a, b, 0);
}
/* Compare, recognizing numeric string and ignoring case. */
int
strnatcasecmp(nat_char const *a, nat_char const *b) {
return strnatcmp0(a, b, 1);
}
+1 -8
View File
@@ -24,14 +24,7 @@
extern "C" {
#endif
/* CUSTOMIZATION SECTION
*
* You can change this typedef, but must then also change the inline
* functions in strnatcmp.c */
typedef char nat_char;
int strnatcmp(nat_char const *a, nat_char const *b);
int strnatcasecmp(nat_char const *a, nat_char const *b);
int strnatcmp(const char *a, const char *b);
#ifdef __cplusplus
}
@@ -24,6 +24,8 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.snapFlingBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
@@ -243,9 +245,8 @@ private class AnchoredHorizontalDraggableNode<T>(
private fun Offset.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
}
internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) =
((if (offset.isNaN()) 0f else offset) + delta)
.coerceIn(anchors.minPosition(), anchors.maxPosition())
internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) = ((if (offset.isNaN()) 0f else offset) + delta)
.coerceIn(anchors.minPosition(), anchors.maxPosition())
/**
* Compute the target anchor based on the [currentOffset], [velocity] and [positionalThreshold] and
@@ -256,31 +257,49 @@ internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) =
@ExperimentalFoundationApi
private fun <T> DraggableAnchors<T>.computeTarget(
currentOffset: Float,
currentValue: T,
velocity: Float,
positionalThreshold: (totalDistance: Float) -> Float,
velocityThreshold: () -> Float,
): T {
val currentAnchors = this
val currentAnchorPosition = currentAnchors.positionOf(currentValue)
val velocityThresholdPx = velocityThreshold()
return if (currentAnchorPosition == currentOffset || currentAnchorPosition.isNaN()) {
currentValue
require(!currentOffset.isNaN()) { "The offset provided to computeTarget must not be NaN." }
val velocitySign = sign(velocity)
val isMovingForward = velocitySign == 1.0f
val isMoving = isMovingForward || velocitySign == -1.0f
// When we're not moving, just pick the closest anchor and don't consider directionality
return if (!isMoving) {
currentAnchors.closestAnchor(currentOffset)!!
} else if (abs(velocity) >= abs(velocityThreshold())) {
currentAnchors.closestAnchor(currentOffset, searchUpwards = isMovingForward)!!
} else {
if (abs(velocity) >= abs(velocityThresholdPx)) {
currentAnchors.closestAnchor(currentOffset, sign(velocity) > 0)!!
} else {
val neighborAnchor =
currentAnchors.closestAnchor(
currentOffset,
currentOffset - currentAnchorPosition > 0,
)!!
val neighborAnchorPosition = currentAnchors.positionOf(neighborAnchor)
val distance = abs(currentAnchorPosition - neighborAnchorPosition)
val relativeThreshold = abs(positionalThreshold(distance))
val relativePosition = abs(currentAnchorPosition - currentOffset)
if (relativePosition <= relativeThreshold) currentValue else neighborAnchor
}
val left = currentAnchors.closestAnchor(currentOffset, false)!!
val leftAnchorPosition = currentAnchors.positionOf(left)
val right = currentAnchors.closestAnchor(currentOffset, true)!!
val rightAnchorPosition = currentAnchors.positionOf(right)
val distance = abs(leftAnchorPosition - rightAnchorPosition)
val relativeThreshold = abs(positionalThreshold(distance))
val closestAnchorFromStart =
if (isMovingForward) leftAnchorPosition else rightAnchorPosition
val relativePosition = abs(closestAnchorFromStart - currentOffset)
if (relativePosition <= relativeThreshold) left else right
}
}
// https://issuetracker.google.com/374241709
@Composable
fun <T> flingBehavior(
state: AnchoredDraggableState<T>,
positionalThreshold: (totalDistance: Float) -> Float = AnchoredDraggableDefaults.PositionalThreshold,
animationSpec: AnimationSpec<Float> = AnchoredDraggableDefaults.SnapAnimationSpec,
): TargetedFlingBehavior {
val density = LocalDensity.current
return remember(density, state, positionalThreshold, animationSpec) {
anchoredDraggableFlingBehavior(
state = state,
density = density,
positionalThreshold = positionalThreshold,
snapAnimationSpec = animationSpec,
)
}
}
@@ -299,17 +318,16 @@ internal fun <T> anchoredDraggableFlingBehavior(
density: Density,
positionalThreshold: (totalDistance: Float) -> Float,
snapAnimationSpec: AnimationSpec<Float>,
): TargetedFlingBehavior =
snapFlingBehavior(
decayAnimationSpec = NoOpDecayAnimationSpec,
snapAnimationSpec = snapAnimationSpec,
snapLayoutInfoProvider =
AnchoredDraggableLayoutInfoProvider(
state = state,
positionalThreshold = positionalThreshold,
velocityThreshold = { with(density) { 125.dp.toPx() } },
),
)
): TargetedFlingBehavior = snapFlingBehavior(
decayAnimationSpec = NoOpDecayAnimationSpec,
snapAnimationSpec = snapAnimationSpec,
snapLayoutInfoProvider =
AnchoredDraggableLayoutInfoProvider(
state = state,
positionalThreshold = positionalThreshold,
velocityThreshold = { with(density) { 125.dp.toPx() } },
),
)
@Suppress("FunctionName")
@OptIn(ExperimentalFoundationApi::class)
@@ -317,25 +335,23 @@ private fun <T> AnchoredDraggableLayoutInfoProvider(
state: AnchoredDraggableState<T>,
positionalThreshold: (totalDistance: Float) -> Float,
velocityThreshold: () -> Float,
): SnapLayoutInfoProvider =
object : SnapLayoutInfoProvider {
): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider {
// We never decay in AnchoredDraggable's fling
override fun calculateApproachOffset(velocity: Float, decayOffset: Float) = 0f
// We never decay in AnchoredDraggable's fling
override fun calculateApproachOffset(velocity: Float, decayOffset: Float) = 0f
override fun calculateSnapOffset(velocity: Float): Float {
val currentOffset = state.requireOffset()
val target =
state.anchors.computeTarget(
currentOffset = currentOffset,
currentValue = state.currentValue,
velocity = velocity,
positionalThreshold = positionalThreshold,
velocityThreshold = velocityThreshold,
)
return state.anchors.positionOf(target) - currentOffset
}
override fun calculateSnapOffset(velocity: Float): Float {
val currentOffset = state.requireOffset()
val target =
state.anchors.computeTarget(
currentOffset = currentOffset,
velocity = velocity,
positionalThreshold = positionalThreshold,
velocityThreshold = velocityThreshold,
)
return state.anchors.positionOf(target) - currentOffset
}
}
private val NoOpDecayAnimationSpec: DecayAnimationSpec<Float> =
object : FloatDecayAnimationSpec {
@@ -1,103 +0,0 @@
@file:Suppress("ktlint:standard:property-naming")
package androidx.compose.material3
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@Composable
fun SearchBarInputField(
state: TextFieldState,
onSearch: () -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
colors: TextFieldColors = SearchBarDefaults.inputFieldColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val focused by interactionSource.collectIsFocusedAsState()
val textColor = LocalTextStyle.current.color.takeOrElse {
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
}
BasicTextField(
state = state,
modifier = modifier.sizeIn(
minWidth = SearchBarMinWidth,
maxWidth = SearchBarMaxWidth,
minHeight = InputFieldHeight,
).focusRequester(focusRequester).onFocusChanged {
if (it.isFocused) onExpandedChange(true)
}.fillMaxWidth(),
enabled = enabled,
lineLimits = TextFieldLineLimits.SingleLine,
textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)),
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
onKeyboardAction = { onSearch() },
interactionSource = interactionSource,
decorator = @Composable { innerTextField ->
TextFieldDefaults.DecorationBox(
value = state.text.toString(),
innerTextField = innerTextField,
enabled = enabled,
singleLine = true,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
placeholder = placeholder,
leadingIcon = leadingIcon?.let { leading -> { Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() } } },
trailingIcon = trailingIcon?.let { trailing -> { Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } } },
shape = SearchBarDefaults.inputFieldShape,
colors = colors,
container = {},
)
},
)
val shouldClearFocus = !expanded && focused
LaunchedEffect(expanded) {
if (shouldClearFocus) {
// Not strictly needed according to the motion spec, but since the animation
// already has a delay, this works around b/261632544.
delay(AnimationDelayMillis.toLong())
focusManager.clearFocus()
}
}
}
val SearchBarIconOffsetX = 4.dp
val SearchBarMinWidth = 360.dp
val SearchBarMaxWidth = 720.dp
const val AnimationDelayMillis = 100
@@ -0,0 +1,191 @@
package androidx.compose.material3
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
import androidx.compose.material3.SearchBarDefaults.inputFieldColors
import androidx.compose.material3.SearchBarDefaults.inputFieldShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.ColorProducer
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
/**
* A text field to input a query in a search bar.
*
* @param state [TextFieldState] that holds the internal editing state of the input field.
* @param onSearch the callback to be invoked when the input service triggers the
* [ImeAction.Search] action. The current query in the [state] comes as a parameter of the
* callback.
* @param expanded whether the search bar is expanded and showing search results.
* @param onExpandedChange the callback to be invoked when the search bar's expanded state is
* changed.
* @param modifier the [Modifier] to be applied to this input field.
* @param enabled the enabled state of this input field. When `false`, this component will not
* respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param readOnly controls the editable state of the input field. When `true`, the field cannot
* be modified. However, a user can focus it and copy text from it.
* @param textStyle the style to be applied to the input text. Defaults to [LocalTextStyle].
* @param placeholder the placeholder to be displayed when the input text is empty.
* @param leadingIcon the leading icon to be displayed at the start of the input field.
* @param trailingIcon the trailing icon to be displayed at the end of the input field.
* @param prefix the optional prefix to be displayed before the input text.
* @param suffix the optional suffix to be displayed after the input text.
* @param inputTransformation optional [InputTransformation] that will be used to transform
* changes to the [TextFieldState] made by the user. The transformation will be applied to
* changes made by hardware and software keyboard events, pasting or dropping text,
* accessibility services, and tests. The transformation will _not_ be applied when changing
* the [state] programmatically, or when the transformation is changed. If the transformation
* is changed on an existing text field, it will be applied to the next user edit. The
* transformation will not immediately affect the current [state].
* @param outputTransformation optional [OutputTransformation] that transforms how the contents
* of the text field are presented.
* @param scrollState scroll state that manages the horizontal scroll of the input field.
* @param shape the shape of the input field.
* @param colors [TextFieldColors] that will be used to resolve the colors used for this input
* field in different states. See [SearchBarDefaults.inputFieldColors].
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this input field. You can use this to change the search bar's
* appearance or preview the search bar in different states. Note that if `null` is provided,
* interactions will still happen internally.
*/
@ExperimentalMaterial3Api
@Composable
fun SearchBarInputField(
state: TextFieldState,
onSearch: (String) -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
prefix: @Composable (() -> Unit)? = null,
suffix: @Composable (() -> Unit)? = null,
inputTransformation: InputTransformation? = null,
outputTransformation: OutputTransformation? = null,
scrollState: ScrollState = rememberScrollState(),
shape: Shape = inputFieldShape,
colors: TextFieldColors = inputFieldColors(),
interactionSource: MutableInteractionSource? = null,
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val textColor = textStyle.color.takeOrElse {
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
BasicTextField(
state = state,
modifier = modifier
.sizeIn(
minWidth = SearchBarMinWidth,
maxWidth = SearchBarMaxWidth,
minHeight = InputFieldHeight,
)
.focusRequester(focusRequester)
.onFocusChanged { if (it.isFocused) onExpandedChange(true) }
.fillMaxWidth(),
enabled = enabled,
readOnly = readOnly,
lineLimits = TextFieldLineLimits.SingleLine,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
onKeyboardAction = { onSearch(state.text.toString()) },
interactionSource = interactionSource,
inputTransformation = inputTransformation,
outputTransformation = outputTransformation,
scrollState = scrollState,
decorator = TextFieldDefaults.decorator(
state = state,
enabled = enabled,
lineLimits = TextFieldLineLimits.SingleLine,
outputTransformation = outputTransformation,
interactionSource = interactionSource,
placeholder = placeholder,
leadingIcon = leadingIcon?.let { leading ->
{ Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() } }
},
trailingIcon = trailingIcon?.let { trailing ->
{ Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } }
},
prefix = prefix,
suffix = suffix,
colors = colors,
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
container = {
val containerColor =
animateColorAsState(
targetValue = if (focused) colors.focusedContainerColor else colors.unfocusedContainerColor,
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
label = "ContainerColor",
)
Box(Modifier.textFieldBackground(containerColor::value, shape))
},
),
)
val shouldClearFocus = !expanded && focused
LaunchedEffect(expanded) {
if (shouldClearFocus) {
// Not strictly needed according to the motion spec, but since the animation
// already has a delay, this works around b/261632544.
delay(AnimationDelayMillis.toLong())
focusManager.clearFocus()
}
}
}
private fun Modifier.textFieldBackground(
color: ColorProducer,
shape: Shape,
) = drawWithCache {
val outline = shape.createOutline(size, layoutDirection, this)
onDrawBehind { drawOutline(outline, color = color()) }
}
private val SearchBarMinWidth = 360.dp
private val SearchBarMaxWidth = 720.dp
private val SearchBarIconOffsetX = 4.dp
@Suppress("ktlint:standard:property-naming")
private const val AnimationDelayMillis = 100
@@ -7,11 +7,11 @@ import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animate
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.flingBehavior
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -206,11 +206,10 @@ class DrawerState2(
/**
* The default [Saver] implementation for [DrawerState2].
*/
fun Saver(confirmStateChange: (DrawerValue) -> Boolean) =
Saver<DrawerState2, DrawerValue>(
save = { it.currentValue },
restore = { DrawerState2(it, confirmStateChange) },
)
fun Saver(confirmStateChange: (DrawerValue) -> Boolean) = Saver<DrawerState2, DrawerValue>(
save = { it.currentValue },
restore = { DrawerState2(it, confirmStateChange) },
)
}
}
@@ -258,7 +257,7 @@ fun ModalSideDrawer(
enableDragFromStartToEnd = drawerState.isOpen,
enableDragFromEndToStart = drawerState.isClosed,
enabled = gesturesEnabled,
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
flingBehavior = flingBehavior(
state = drawerState.anchoredDraggableState,
animationSpec = AnimationSpec,
),
@@ -18,13 +18,13 @@ package androidx.compose.material3.fork
import androidx.annotation.FloatRange
import androidx.compose.animation.core.SpringSpec
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.draggableAnchors
import androidx.compose.foundation.gestures.flingBehavior
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
@@ -208,7 +208,7 @@ fun SwipeToDismissBox(
enableDragFromStartToEnd = enableDismissFromStartToEnd,
enableDragFromEndToStart = enableDismissFromEndToStart,
enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled,
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
flingBehavior = flingBehavior(
state = state.anchoredDraggableState,
positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold,
animationSpec = SnapAnimationSpec,
@@ -98,16 +98,14 @@ fun rememberSystemUiController(
}
@Composable
private fun findWindow(): Window? =
(LocalView.current.parent as? DialogWindowProvider)?.window
?: LocalView.current.context.findWindow()
private fun findWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window
?: LocalView.current.context.findWindow()
private tailrec fun Context.findWindow(): Window? =
when (this) {
is Activity -> window
is ContextWrapper -> baseContext.findWindow()
else -> null
}
private tailrec fun Context.findWindow(): Window? = when (this) {
is Activity -> window
is ContextWrapper -> baseContext.findWindow()
else -> null
}
/**
* A helper class for setting the navigation and status bar colors for a [View], gracefully
@@ -635,8 +635,7 @@ data class WebViewError(
fun rememberWebViewState(
url: String,
additionalHttpHeaders: Map<String, String> = emptyMap(),
): WebViewState =
// Rather than using .apply {} here we will recreate the state, this prevents
): WebViewState = // Rather than using .apply {} here we will recreate the state, this prevents
// a recomposition loop when the webview updates the url itself.
remember {
WebViewState(
@@ -21,7 +21,6 @@ import android.content.Context
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.content.res.AppCompatResources
import androidx.collection.LruCache
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil3.EventListener
@@ -29,15 +28,14 @@ import coil3.SingletonImageLoader
import coil3.asImage
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.network.ConnectivityChecker
import coil3.network.NetworkFetcher
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.serviceLoaderEnabled
import coil3.util.DebugLogger
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.coil.CropBorderInterceptor
import com.hippo.ehviewer.coil.DetectBorderInterceptor
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
@@ -45,7 +43,7 @@ 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.coil.limitConcurrency
import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase
import com.hippo.ehviewer.download.DownloadManager
@@ -53,23 +51,28 @@ import com.hippo.ehviewer.download.DownloadsFilterMode
import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.ktbuilder.imageLoader
import com.hippo.ehviewer.ktor.Cronet
import com.hippo.ehviewer.legacy.cleanObsoleteCache
import com.hippo.ehviewer.ktor.configureClient
import com.hippo.ehviewer.ktor.configureCommon
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.lockObserver
import com.hippo.ehviewer.ui.screen.detailCache
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.CrashHandler
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.OSUtils
import com.hippo.ehviewer.util.isAtLeastO
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastS
import com.hippo.ehviewer.util.isAtLeastSExtension7
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.plugins.cookies.HttpCookies
import io.ktor.client.engine.okhttp.OkHttp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import logcat.AndroidLogcatLogger
@@ -101,22 +104,15 @@ class EhApplication :
}
}
lifecycle.addObserver(lockObserver)
val handler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { t, e ->
try {
if (Settings.saveCrashLog) {
Crash.saveCrashLog(e)
}
} catch (ignored: Throwable) {
}
handler?.uncaughtException(t, e)
}
CrashHandler.install()
super.onCreate()
System.loadLibrary("ehviewer")
lifecycleScope.launchIO {
launchUI { FavouriteStatusRouter.collect { (gid, slot) -> detailCache[gid]?.favoriteSlot = slot } }
launch { EhTagDatabase }
launch { EhDB }
dataStateFlow.value
launchIO { dataStateFlow.value }
launchIO { OSUtils.totalMemory }
launch {
if (DownloadManager.labelList.isNotEmpty() && Settings.downloadFilterMode.key !in Settings.prefs) {
Settings.downloadFilterMode.value = DownloadsFilterMode.CUSTOM.flag
@@ -127,16 +123,9 @@ class EhApplication :
FileUtils.cleanupDirectory(AppConfig.externalCrashDir)
FileUtils.cleanupDirectory(AppConfig.externalParseErrorDir)
}
launch {
cleanupDownload()
}
launch { cleanupDownload() }
if (Settings.requestNews) {
launch {
checkDawn()
}
}
launch {
cleanObsoleteCache()
launch { checkDawn() }
}
}
if (BuildConfig.DEBUG) {
@@ -172,7 +161,7 @@ class EhApplication :
interceptorCoroutineContext(Dispatchers.Default)
components {
serviceLoaderEnabled(false)
add(KtorNetworkFetcherFactory { ktorClient })
add(NetworkFetcher.Factory({ ktorClient.limitConcurrency() }) { ConnectivityChecker.ONLINE })
add(MergeInterceptor)
add(DownloadThumbInterceptor)
if (isAtLeastO) {
@@ -207,30 +196,22 @@ class EhApplication :
companion object {
val ktorClient by lazy {
HttpClient(Cronet) {
engine {
client = cronetHttpClient
if (isAtLeastSExtension7 && Settings.enableCronet) {
HttpClient(Cronet) {
engine { configureClient() }
configureCommon()
}
install(HttpCookies) {
storage = EhCookieStore
} else {
HttpClient(OkHttp) {
engine { configureClient() }
configureCommon()
}
}
}
val noRedirectKtorClient by lazy {
HttpClient(ktorClient.engine) {
followRedirects = false
install(HttpCookies) {
storage = EhCookieStore
}
}
}
val galleryDetailCache by lazy {
LruCache<Long, GalleryDetail>(25).also {
lifecycleScope.launch {
FavouriteStatusRouter.globalFlow.collect { (gid, slot) -> it[gid]?.favoriteSlot = slot }
}
configureCommon(redirect = false)
}
}
@@ -9,8 +9,6 @@ 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
import com.hippo.ehviewer.download.DownloadsFilterMode
@@ -108,6 +106,7 @@ object Settings : DataStorePreferences(null) {
var favCount by intArrayPref("fav_count", 10).emitTo(_favFlow)
var favCloudCount by intPref("fav_cloud", 0).emitTo(_favFlow)
val gallerySite = intPref("gallery_site_2", 0).observed { updateWhenGallerySiteChanges() }
val listMode = intPref("list_mode_2", 0)
val detailSize = intPref("detail_size_2", 0)
val thumbColumns = intPref("thumb_columns", 3)
@@ -115,7 +114,11 @@ object Settings : DataStorePreferences(null) {
val languageFilter = intPref("language_filter", -1)
val downloadSortMode = intPref("download_sort_mode", SortMode.Default.flag)
val downloadFilterMode = intPref("download_filter_mode", DownloadsFilterMode.Default.flag)
// Enabled by default as some ExHentai thumbnail servers do not support HTTP/2
val forceEhThumb = boolPref("force_eh_thumb", true)
val hasSignedIn = boolPref("has_signed_in", EhCookieStore.hasSignedIn())
val needSignIn = boolPref("need_sign_in", true)
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
val blackDarkTheme = boolPref("black_dark_theme", false)
val gridView = boolPref("grid_view", false)
@@ -124,6 +127,7 @@ object Settings : DataStorePreferences(null) {
val security = boolPref("require_unlock", false)
val animateItems = boolPref("animate_items", true)
val displayName = stringOrNullPref("display_name", null)
val avatar = stringOrNullPref("avatar", null)
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
var downloadScheme by stringOrNullPref("image_scheme", null)
@@ -131,14 +135,12 @@ object Settings : DataStorePreferences(null) {
var downloadPath by stringOrNullPref("image_path", null)
var downloadQuery by stringOrNullPref("image_query", null)
var downloadFragment by stringOrNullPref("image_fragment", null)
var archivePasswds by stringSetOrNullPref("archive_passwds")
var archivePasswds by stringSetPref("archive_passwds")
var downloadDelay by intPref("download_delay_3", 1000)
var gallerySite by intPref("gallery_site_2", 0).observed { updateWhenGallerySiteChanges() }
var multiThreadDownload by intPref("download_thread_2", 3)
var preloadImage by intPref("preload_image_2", 5)
var downloadTimeout by intPref("download_timeout", 60)
var theme by intPref("theme_2", -1).observed { updateWhenThemeChanges() }
var thumbResolution by intPref("thumb_resolution_2", 0)
var readCacheSize by intPref("read_cache_size_2", 640)
var launchPage by intPref("launch_page_2", 0)
var commentThreshold by intPref("comment_threshold", -101)
@@ -157,12 +159,12 @@ object Settings : DataStorePreferences(null) {
var saveParseErrorBody by boolPref("save_parse_error_body", true)
var saveCrashLog by boolPref("save_crash_log", true)
var removeImageFiles by boolPref("include_pic", true)
var needSignIn by boolPref("need_sign_in", true)
var harmonizeCategoryColor by boolPref("harmonize_category_color", true)
var preloadThumbAggressively by boolPref("preload_thumb_aggressively", false)
var downloadOriginImage by boolPref("download_origin_image", false)
var saveAsCbz by boolPref("save_as_cbz", false)
var archiveMetadata by boolPref("archive_metadata", true)
var enableCronet by boolPref("enable_cronet", true)
var desktopSite by boolPref("desktop_site", false)
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
var defaultFavSlot by intPref("default_favorite_slot", -2)
@@ -193,9 +195,6 @@ object Settings : DataStorePreferences(null) {
val keepScreenOn = boolPref("pref_keep_screen_on_key", true)
val readerLongTapAction = boolPref("reader_long_tap", true)
val pageTransitions = boolPref("pref_enable_transitions_key", true)
val readWithVolumeKeys = boolPref("reader_volume_keys", false)
val readWithVolumeKeysInterval = intPref("reader_volume_keys_interval", 0)
val readWithVolumeKeysInverted = boolPref("reader_volume_keys_inverted", false)
val grayScale = boolPref("pref_grayscale", false)
val invertedColors = boolPref("pref_inverted_colors", false)
val readerWebtoonNav = intPref("reader_navigation_mode_webtoon", 0)
@@ -255,3 +254,6 @@ object Settings : DataStorePreferences(null) {
}
}
}
private const val CHROME_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Safari/537.36"
private const val CHROME_MOBILE_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
@@ -15,35 +15,45 @@
*/
package com.hippo.ehviewer.client
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
// Normal Preview: https://*.hath.network/cm/[timed token]/[gid]-[index].jpg
// ExHentai Large Preview: https://s.exhentai.org/t/***
// E-Hentai Large Preview: https://ehgt.org/***
// Normal Preview (v2): https://*.hath.network/c(m|1|2)/[timed token]/[gid]-[index].(jpg|webp)
// ExHentai Large Preview (v1 Cover): https://s.exhentai.org/t/***
// E-Hentai Large Preview (v1 Cover): https://ehgt.org/***
// ExHentai v2 Cover: https://s.exhentai.org/**.webp
// E-Hentai v2 Cover: https://ehgt.org/**.webp
private const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/t/"
private const val NORMAL_PREVIEW_PREFIX = "$"
private val NormalPreviewKeyRegex = Regex("/(\\d+-\\d+)\\.jpg$")
const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/"
const val URL_SIGNATURE_THUMB_NORMAL = ".hath.network/c"
private const val URL_PREFIX_V1_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
private val V2PreviewKeyRegex = Regex("/c([m12]/)[^/]+/(\\d+-\\d+)")
fun getImageKey(gid: Long, index: Int): String = "image:$gid:$index"
fun getThumbKey(url: String): String = url.removePrefix(thumbPrefix)
fun getThumbKey(url: String): String = url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_V1_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
val String.isNormalPreviewKey
get() = startsWith(NORMAL_PREVIEW_PREFIX)
fun getV2PreviewKey(url: String) = "$".plus(
V2PreviewKeyRegex.find(url)?.let {
it.groupValues[1] + it.groupValues[2]
} ?: url,
)
val GalleryPreview.imageKey
get() = if (this is NormalGalleryPreview) {
NormalPreviewKeyRegex.find(url)?.run { NORMAL_PREVIEW_PREFIX + groupValues[1] }
} else {
getThumbKey(url)
}
val String.isV2PreviewKey
get() = startsWith('$')
val GalleryInfo.thumbUrl
get() = thumbPrefix + EhUtils.handleThumbUrlResolution(thumbKey!!)
get() = thumbKey!!.let {
if (it.endsWith("webp")) {
if (useExThumb) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
} else {
v1ThumbPrefix
} + it
}
private val thumbPrefix
get() = if (EhUtils.isExHentai) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
val v1ThumbPrefix
get() = if (useExThumb) URL_PREFIX_V1_THUMB_EX else URL_PREFIX_THUMB_E
private val useExThumb
get() = EhUtils.isExHentai && !Settings.forceEhThumb.value
@@ -33,6 +33,8 @@ object EhCookieStore : CookiesStorage {
)
}
fun getUserId() = getCookies(EhUrl.HOST_E)?.get(KEY_IPB_MEMBER_ID)
fun getIdentityCookies(): List<Pair<String, String?>> {
val eCookies = getCookies(EhUrl.HOST_E)
val exCookies = getCookies(EhUrl.HOST_EX)
@@ -48,8 +50,6 @@ object EhCookieStore : CookiesStorage {
fun flush() = manager.flush()
fun getCookieHeader(url: String): String? = manager.getCookie(url)
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
override suspend fun addCookie(requestUrl: Url, cookie: Cookie) {
if (cookie.name != KEY_UTMP_NAME) {
@@ -132,10 +132,10 @@ fun rethrowExactly(status: HttpStatusCode, body: Either<String, ByteBuffer>, e:
if (e is ParseException || e is SerializationException) {
body.onLeft { if ("<" !in it) throw EhException(it) }
when (val message = e.cause?.message) {
"No hits found!" -> throw NoHitsFoundException()
"No watched tags!" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
"Not logged in!" -> throw NotLoggedInException()
is String -> if (message.startsWith("Your IP address")) throw EhException(message)
"0" -> throw NoHitsFoundException()
"1" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
"2" -> throw NotLoggedInException()
is String if message.startsWith('3') -> throw EhException(message.drop(1))
}
if (Settings.saveParseErrorBody) body.saveParseError(e)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
@@ -180,6 +180,11 @@ object EhEngine {
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
}
suspend fun getTorrentKey() = EhCookieStore.getUserId()?.let { id ->
val key = ehRequest(EhUrl.URL_HOME).fetchUsingAsText(HomeParser::parseTorrentKey)
"${id}x$key"
}
suspend fun getArchiveList(url: String, gid: Long, token: String): ArchiveParser.Result {
val funds = if (EhUtils.isExHentai) getFunds() else null
return ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
@@ -273,7 +278,7 @@ object EhEngine {
ehRequest(location, url).fetchUsingAsText {
val document = Jsoup.parse(this)
val elements = document.select("#chd + p")
if (elements.size > 0) {
if (elements.isNotEmpty()) {
throw EhException(elements[0].text())
}
GalleryDetailParser.parseComments(document)
@@ -298,12 +303,11 @@ object EhEngine {
}.executeSafely { }
}
suspend fun getFavoriteNote(gid: Long, token: String) =
ehRequest(EhUrl.getAddFavorites(gid, token), EhUrl.getGalleryDetailUrl(gid, token))
.fetchUsingAsText(FavoritesParser::parseNote)
suspend fun getFavoriteNote(gid: Long, token: String) = ehRequest(EhUrl.getAddFavorites(gid, token), EhUrl.getGalleryDetailUrl(gid, token))
.fetchUsingAsText(FavoritesParser::parseNote)
suspend fun downloadArchive(gid: Long, token: String, or: String, res: String, isHAtH: Boolean): String? {
val url = EhUrl.getDownloadArchive(gid, token, or)
suspend fun downloadArchive(gid: Long, token: String, res: String, isHAtH: Boolean): String? {
val url = EhUrl.getDownloadArchive(gid, token)
val referer = EhUrl.getGalleryDetailUrl(gid, token)
val request = ehRequest(url, referer, EhUrl.origin) {
formBody {
@@ -379,54 +383,51 @@ object EhEngine {
}
}.fetchUsingAsText(String::parseAs)
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String? = null) =
galleryInfoList.chunked(MAX_REQUEST_SIZE).chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
if (index != 0) {
delay(REQUEST_INTERVAL)
}
chunk.parMap {
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
put("method", "gdata")
array("gidlist") {
it.forEach {
addJsonArray {
add(it.gid)
add(it.token)
}
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String? = null) = galleryInfoList.chunked(MAX_REQUEST_SIZE).chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
if (index != 0) {
delay(REQUEST_INTERVAL)
}
chunk.parMap {
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
put("method", "gdata")
array("gidlist") {
it.forEach {
addJsonArray {
add(it.gid)
add(it.token)
}
}
put("namespace", 1)
}
}.fetchUsingAsText { GalleryApiParser.parse(this, it) }
}
put("namespace", 1)
}
}.fetchUsingAsText { GalleryApiParser.parse(this, it) }
}
}
suspend fun voteComment(apiUid: Long, apiKey: String?, gid: Long, token: String, commentId: Long, commentVote: Int): VoteCommentResult =
ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "votecomment")
put("apiuid", apiUid)
put("apikey", requireNotNull(apiKey))
put("gid", gid)
put("token", token)
put("comment_id", commentId)
put("comment_vote", commentVote)
}
}.fetchUsingAsText(String::parseAs)
suspend fun voteComment(apiUid: Long, apiKey: String?, gid: Long, token: String, commentId: Long, commentVote: Int): VoteCommentResult = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "votecomment")
put("apiuid", apiUid)
put("apikey", requireNotNull(apiKey))
put("gid", gid)
put("token", token)
put("comment_id", commentId)
put("comment_vote", commentVote)
}
}.fetchUsingAsText(String::parseAs)
suspend fun voteTag(apiUid: Long, apiKey: String?, gid: Long, token: String, tags: String, vote: Int) =
ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "taggallery")
put("apiuid", apiUid)
put("apikey", requireNotNull(apiKey))
put("gid", gid)
put("token", token)
put("tags", tags)
put("vote", vote)
}
}.fetchUsingAsText(VoteTagParser::parse)
suspend fun voteTag(apiUid: Long, apiKey: String?, gid: Long, token: String, tags: String, vote: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "taggallery")
put("apiuid", apiUid)
put("apikey", requireNotNull(apiKey))
put("gid", gid)
put("token", token)
put("tags", tags)
put("vote", vote)
}
}.fetchUsingAsText(VoteTagParser::parse)
suspend fun getGalleryToken(gid: Long, gtoken: String, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
@@ -15,10 +15,8 @@
*/
package com.hippo.ehviewer.client
import com.hippo.ehviewer.BuildConfig
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.util.bodyAsUtf8Text
import com.hippo.ehviewer.util.ensureSuccess
import eu.kanade.tachiyomi.util.system.logcat
@@ -30,11 +28,9 @@ import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.HttpStatement
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.ParametersBuilder
import io.ktor.http.content.TextContent
import io.ktor.http.userAgent
import kotlinx.coroutines.cancel
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArrayBuilder
@@ -56,18 +52,9 @@ inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
val json = Json { ignoreUnknownKeys = true }
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"
fun HttpRequestBuilder.applyEhConfig(referer: String?, origin: String?) {
method = HttpMethod.Get
header("Referer", referer)
header("Origin", origin)
userAgent(Settings.userAgent)
header(HttpHeaders.Accept, CHROME_ACCEPT)
header(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
}
suspend inline fun ehRequest(
@@ -32,7 +32,6 @@ import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import java.io.File
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -104,7 +103,7 @@ object EhTagDatabase : CoroutineScope {
val prefix = keyword.dropLast(mKeyword.length)
val namespace = mKeyword.substringBefore(':')
val tagKeyword = mKeyword.drop(namespace.length + 1)
val namespacePrefix = TagNamespace(namespace).toPrefix() ?: namespace
val namespacePrefix = TagNamespace.from(namespace)?.prefix ?: namespace
val tags = tagGroups[namespacePrefix.takeIf { tagKeyword.isNotEmpty() && it != NAMESPACE_PREFIX }]
tags?.let {
internalSuggestFlow(it, tagKeyword, translate, exactly).collect { (hint, tag) ->
@@ -125,8 +124,7 @@ object EhTagDatabase : CoroutineScope {
private fun String.removeSpace(): String = replace(" ", "")
private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean =
removeSpace().contains(other.removeSpace(), ignoreCase)
private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean = removeSpace().contains(other.removeSpace(), ignoreCase)
private fun getMetadata(context: Context): Array<String>? = context.resources.getStringArray(R.array.tag_translation_metadata)
.takeIf { it.size == 4 }
@@ -134,7 +132,7 @@ object EhTagDatabase : CoroutineScope {
fun isTranslatable(context: Context): Boolean = context.resources.getBoolean(R.bool.tag_translatable)
private fun getFileContent(file: File): String? = runCatching {
file.source().buffer().use { it.readString(StandardCharsets.UTF_8) }
file.source().buffer().use { it.readUtf8() }
}.getOrNull()
private fun checkData(sha1: String?, data: File): Boolean = sha1 != null && sha1 == data.toOkioPath().sha1()
@@ -53,66 +53,65 @@ object EhUrl {
const val ORIGIN_E = REFERER_E
val domain: String
get() = when (Settings.gallerySite) {
get() = when (Settings.gallerySite.value) {
SITE_E -> DOMAIN_E
SITE_EX -> DOMAIN_EX
else -> DOMAIN_E
}
val apiUrl: String
get() = when (Settings.gallerySite) {
get() = when (Settings.gallerySite.value) {
SITE_E -> API_E
SITE_EX -> API_EX
else -> API_E
}
val referer: String
get() = when (Settings.gallerySite) {
get() = when (Settings.gallerySite.value) {
SITE_E -> REFERER_E
SITE_EX -> REFERER_EX
else -> REFERER_E
}
val origin: String
get() = when (Settings.gallerySite) {
get() = when (Settings.gallerySite.value) {
SITE_E -> ORIGIN_E
SITE_EX -> ORIGIN_EX
else -> ORIGIN_E
}
val uConfigUrl: String
get() = when (Settings.gallerySite) {
get() = when (Settings.gallerySite.value) {
SITE_E -> URL_UCONFIG_E
SITE_EX -> URL_UCONFIG_EX
else -> URL_UCONFIG_E
}
val myTagsUrl: String
get() = when (Settings.gallerySite) {
get() = when (Settings.gallerySite.value) {
SITE_E -> URL_MY_TAGS_E
SITE_EX -> URL_MY_TAGS_EX
else -> URL_MY_TAGS_E
}
val popularUrl: String
get() = when (Settings.gallerySite) {
get() = when (Settings.gallerySite.value) {
SITE_E -> URL_POPULAR_E
SITE_EX -> URL_POPULAR_EX
else -> URL_POPULAR_E
}
val imageSearchUrl: String
get() = when (Settings.gallerySite) {
get() = when (Settings.gallerySite.value) {
SITE_E -> URL_IMAGE_SEARCH_E
SITE_EX -> URL_IMAGE_SEARCH_EX
else -> URL_IMAGE_SEARCH_E
}
fun getGalleryDetailUrl(gid: Long, token: String, index: Int = 0, allComment: Boolean = false) =
ehUrl(listOf("g", "$gid", token)) {
if (index != 0) addQueryParameter("p", "$index")
if (allComment) addQueryParameter("hc", "1")
}.buildString()
fun getGalleryDetailUrl(gid: Long, token: String, index: Int = 0, allComment: Boolean = false) = ehUrl(listOf("g", "$gid", token)) {
if (index != 0) addQueryParameter("p", "$index")
if (allComment) addQueryParameter("hc", "1")
}.buildString()
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl(listOf("mpv", "$gid", token)).buildString()
@@ -126,13 +125,17 @@ object EhUrl {
addQueryParameter("act", "addfav")
}.buildString()
fun getDownloadArchive(gid: Long, token: String, or: String) = ehUrl("archiver.php") {
fun getDownloadArchive(gid: Long, token: String) = ehUrl("archiver.php") {
addQueryParameter("gid", "$gid")
addQueryParameter("token", token)
addQueryParameter("or", or)
}.buildString()
fun getTagDefinitionUrl(tag: String) = "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
fun getTrackerUrl(gid: Long, key: String?) = ehUrl(
path = listOfNotNull("$gid", key, "announce"),
host = "ehtracker.org",
).buildString()
}
inline fun ehUrl(path: String? = null, host: String = EhUrl.domain, builder: ParametersBuilder.() -> Unit) = ehUrl(
@@ -20,11 +20,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.graphics.ColorUtils
import arrow.core.memoize
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.GalleryInfo
import com.materialkolor.hct.Hct
import com.materialkolor.ktx.from
import com.materialkolor.ktx.toColor
import kotlin.math.abs
object EhUtils {
@@ -84,7 +85,7 @@ object EhUtils {
private val CATEGORY_STRINGS = CATEGORY_VALUES.entries.map { (k, v) -> v to k }
val isExHentai: Boolean
get() = Settings.gallerySite == EhUrl.SITE_EX
get() = Settings.gallerySite.value == EhUrl.SITE_EX
fun getCategory(type: String?): Int {
for (entry in CATEGORY_STRINGS) {
@@ -99,51 +100,54 @@ object EhUtils {
fun getCategory(type: Int): String = CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
private fun differenceDegrees(a: Float, b: Float): Float = 180.0f - abs(abs(a - b) - 180.0f)
fun invCategory(category: Int): Int = category.inv() and ALL_CATEGORY
private fun sanitizeDegreesDouble(degrees: Float): Float {
val deg = degrees % 360.0f
return if (deg < 0) deg + 360.0f else deg
private fun differenceDegrees(a: Double, b: Double): Double = 180.0 - abs(abs(a - b) - 180.0)
private fun sanitizeDegreesDouble(degrees: Double): Double {
val deg = degrees % 360.0
return if (deg < 0) deg + 360.0 else deg
}
private fun rotationDirection(from: Float, to: Float): Float {
private fun rotationDirection(from: Double, to: Double): Double {
val increasingDifference = sanitizeDegreesDouble(to - from)
return if (increasingDifference <= 180.0) 1.0f else -1.0f
return if (increasingDifference <= 180.0) 1.0 else -1.0
}
val harmonizeWithRole = { primaryContainer: Int, src: Int ->
val fromHct = FloatArray(3).apply { ColorUtils.colorToM3HCT(src, this) }
val toHct = FloatArray(3).apply { ColorUtils.colorToM3HCT(primaryContainer, this) }
val differenceDegrees = differenceDegrees(fromHct[0], toHct[0])
val rotationDegrees = minOf(differenceDegrees * 0.5f, 15.0f)
val outputHue = sanitizeDegreesDouble(fromHct[0] + rotationDegrees * rotationDirection(fromHct[0], toHct[0]))
ColorUtils.M3HCTToColor(outputHue, toHct[1], toHct[2])
val harmonizeWithRole = { primaryContainer: Color, src: Color ->
val fromHct = Hct.from(src)
val toHct = Hct.from(primaryContainer)
val differenceDegrees = differenceDegrees(toHct.hue, fromHct.hue)
val rotationDegrees = minOf(differenceDegrees * 0.5, 15.0)
val outputHue = sanitizeDegreesDouble(fromHct.hue + rotationDegrees * rotationDirection(fromHct.hue, toHct.hue))
Hct.from(outputHue, toHct.chroma, toHct.tone).toColor()
}.memoize()
@Stable
@ReadOnlyComposable
@Composable
fun getCategoryColor(category: Int): Color {
val primary = when (category) {
DOUJINSHI -> BG_COLOR_DOUJINSHI
MANGA -> BG_COLOR_MANGA
ARTIST_CG -> BG_COLOR_ARTIST_CG
GAME_CG -> BG_COLOR_GAME_CG
WESTERN -> BG_COLOR_WESTERN
NON_H -> BG_COLOR_NON_H
IMAGE_SET -> BG_COLOR_IMAGE_SET
COSPLAY -> BG_COLOR_COSPLAY
ASIAN_PORN -> BG_COLOR_ASIAN_PORN
MISC -> BG_COLOR_MISC
else -> BG_COLOR_UNKNOWN
}.toInt()
val color = if (Settings.harmonizeCategoryColor) {
val primaryContainer = MaterialTheme.colorScheme.primaryContainer.toArgb()
val primary = Color(
when (category) {
DOUJINSHI -> BG_COLOR_DOUJINSHI
MANGA -> BG_COLOR_MANGA
ARTIST_CG -> BG_COLOR_ARTIST_CG
GAME_CG -> BG_COLOR_GAME_CG
WESTERN -> BG_COLOR_WESTERN
NON_H -> BG_COLOR_NON_H
IMAGE_SET -> BG_COLOR_IMAGE_SET
COSPLAY -> BG_COLOR_COSPLAY
ASIAN_PORN -> BG_COLOR_ASIAN_PORN
MISC -> BG_COLOR_MISC
else -> BG_COLOR_UNKNOWN
}.toInt(),
)
return if (Settings.harmonizeCategoryColor) {
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
harmonizeWithRole(primaryContainer, primary)
} else {
primary
}
return Color(color)
}
val categoryTextColor = Color(0xffe6e0e9)
@@ -152,8 +156,8 @@ object EhUtils {
EhCookieStore.removeAllCookies()
Settings.displayName.value = null
Settings.hasSignedIn.value = false
Settings.gallerySite = EhUrl.SITE_E
Settings.needSignIn = true
Settings.gallerySite.value = EhUrl.SITE_E
Settings.needSignIn.value = true
}
fun getSuitableTitle(gi: GalleryInfo): String = if (Settings.showJpnTitle) {
@@ -171,20 +175,4 @@ object EhUtils {
// TODO But not sure every '|' means that
return title.substringBeforeLast('|').trim().ifEmpty { null }
}
fun handleThumbUrlResolution(url: String): String {
val resolution = when (Settings.thumbResolution) {
0 -> return url
1 -> "250"
2 -> "300"
else -> return url
}
val index1 = url.lastIndexOf('_')
val index2 = url.lastIndexOf('.')
return if (index1 >= 0 && index2 >= 0 && index1 < index2) {
url.substring(0, index1 + 1) + resolution + url.substring(index2)
} else {
url
}
}
}
@@ -33,10 +33,10 @@ import kotlinx.serialization.Serializable
data class BaseGalleryInfo(
@PrimaryKey
@ColumnInfo(name = "GID")
override var gid: Long,
override var gid: Long = 0,
@ColumnInfo(name = "TOKEN")
override var token: String,
override var token: String = "",
@ColumnInfo(name = "TITLE")
override var title: String? = null,
@@ -88,10 +88,7 @@ data class BaseGalleryInfo(
@Ignore
override var favoriteNote: String? = null,
) : GalleryInfo,
Parcelable {
constructor() : this(0, "")
}
) : GalleryInfo, Parcelable
class SimpleTagsConverter {
@TypeConverter
@@ -16,6 +16,7 @@
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import androidx.compose.ui.util.fastFlatMap
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANGS
import kotlinx.parcelize.Parcelize
@@ -51,15 +52,14 @@ data class GalleryDetail(
var size: String? = null,
var favoriteCount: Int = 0,
var ratingCount: Int = 0,
val tags: List<GalleryTagGroup>,
val tagGroups: List<GalleryTagGroup>,
var comments: GalleryCommentList,
val previewPages: Int,
val previewList: List<GalleryPreview>,
) : GalleryInfo by galleryInfo,
Parcelable {
) : GalleryInfo by galleryInfo, Parcelable {
fun fillInfo() {
val index = LANGUAGES.indexOf(language)
if (index != -1) simpleLanguage = S_LANGS[index]
simpleTags = tags.flatten()
simpleTags = tagGroups.fastFlatMap(GalleryTagGroup::tags)
}
}
@@ -141,6 +141,3 @@ fun GalleryInfo.findBaseInfo(): BaseGalleryInfo = when (this) {
}
fun GalleryInfo.asGalleryDetail(): GalleryDetail? = this as? GalleryDetail
val GalleryInfo.hasAds
get() = simpleTags?.any { "extraneous ads" in it } ?: false
@@ -16,22 +16,27 @@
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import com.hippo.ehviewer.client.v1ThumbPrefix
import kotlinx.parcelize.Parcelize
interface GalleryPreview : Parcelable {
sealed interface GalleryPreview : Parcelable {
val url: String
val imageKey: String
val position: Int
}
@Parcelize
data class LargeGalleryPreview(
override val url: String,
data class V1GalleryPreview(
override val imageKey: String,
override val position: Int,
) : GalleryPreview
) : GalleryPreview {
override val url get() = v1ThumbPrefix + imageKey
}
@Parcelize
data class NormalGalleryPreview(
data class V2GalleryPreview(
override val url: String,
override val imageKey: String,
override val position: Int,
val offsetX: Int,
val clipWidth: Int,
@@ -20,7 +20,6 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class GalleryTagGroup(
val groupName: String,
private val tagList: MutableList<String> = mutableListOf(),
) : Parcelable,
MutableList<String> by tagList
val nameSpace: TagNamespace,
val tags: List<String>,
) : Parcelable
@@ -16,7 +16,6 @@
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import androidx.annotation.IntDef
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
@@ -25,20 +24,18 @@ import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
import com.hippo.ehviewer.client.ehUrl
import com.hippo.ehviewer.dao.QuickSearch
import com.hippo.ehviewer.ui.main.AdvanceTable
import com.hippo.ehviewer.util.toIntOrDefault
import java.io.UnsupportedEncodingException
import java.net.URLDecoder
import io.ktor.http.Parameters
import kotlin.text.toIntOrNull
import kotlinx.parcelize.Parcelize
@Parcelize
data class ListUrlBuilder(
@get:Mode @Mode
var mode: Int = MODE_NORMAL,
private var mPrev: String? = null,
var mNext: String? = null,
private var prev: String? = null,
var next: String? = null,
// Reset to null after initial loading
var mJumpTo: String? = null,
private var mRange: Int = 0,
var jumpTo: String? = null,
private var range: Int = 0,
var category: Int = EhUtils.NONE,
private var mKeyword: String? = null,
var hash: String? = null,
@@ -47,24 +44,23 @@ data class ListUrlBuilder(
var minRating: Int = -1,
var pageFrom: Int = -1,
var pageTo: Int = -1,
var imagePath: String? = null,
) : Parcelable {
fun setIndex(index: String, isNext: Boolean = true) {
mNext = index.takeIf { isNext }
mPrev = index.takeUnless { isNext }
mRange = 0
next = index.takeIf { isNext }
prev = index.takeUnless { isNext }
range = 0
}
fun setJumpTo(jumpTo: Int) {
mJumpTo = jumpTo.takeUnless { it == 0 }?.toString()
fun setJumpTo(to: Int) {
jumpTo = to.takeUnless { it == 0 }?.toString()
}
fun setRange(range: Int) {
mRange = range
mPrev = null
mNext = null
mJumpTo = null
fun setRange(to: Int) {
range = to
prev = null
next = null
jumpTo = null
}
var keyword: String?
@@ -73,18 +69,18 @@ data class ListUrlBuilder(
mKeyword = keyword
}
constructor(q: QuickSearch) : this() {
mode = q.mode
this.category = q.category
mKeyword = q.keyword
advanceSearch = q.advanceSearch
minRating = q.minRating
pageFrom = q.pageFrom
pageTo = q.pageTo
mNext = q.name.substringAfterLast('@', "").ifEmpty { null }
}
constructor(q: QuickSearch) : this(
mode = q.mode,
category = q.category,
mKeyword = q.keyword,
advanceSearch = q.advanceSearch,
minRating = q.minRating,
pageFrom = q.pageFrom,
pageTo = q.pageTo,
next = q.name.substringAfterLast('@', "").ifEmpty { null },
)
fun toQuickSearch(name: String): QuickSearch = QuickSearch(
fun toQuickSearch(name: String) = QuickSearch(
name = name,
mode = mode,
category = category,
@@ -121,140 +117,34 @@ data class ListUrlBuilder(
}
}
/**
* @param query xxx=yyy&mmm=nnn
*/
constructor(query: String?) : this() {
// TODO page
if (query.isNullOrEmpty()) {
return
}
val queries = query.split('&')
var category = 0
var keyword: String? = null
var enableAdvanceSearch = false
var advanceSearch = 0
var enableMinRating = false
var minRating = -1
var enablePage = false
var pageFrom = -1
var pageTo = -1
for (str in queries) {
val index = str.indexOf('=')
if (index < 0) {
continue
constructor(params: Parameters) : this(
prev = params["prev"],
next = params["next"],
jumpTo = params["seek"],
range = params["range"]?.toIntOrNull() ?: 0,
category = params["f_cats"]?.toIntOrNull()?.let(EhUtils::invCategory) ?: EhUtils.NONE,
mKeyword = params["f_search"],
) {
if (params["advsearch"] == "1") {
advanceSearch = 0
if (params["f_sh"] == "on") {
advanceSearch = advanceSearch or AdvanceTable.SH
}
val key = str.substring(0, index)
val value = str.substring(index + 1)
when (key) {
"f_cats" -> {
val cats = value.toIntOrDefault(EhUtils.ALL_CATEGORY)
category = category or (cats.inv() and EhUtils.ALL_CATEGORY)
}
"f_doujinshi" -> if ("1" == value) {
category = category or EhUtils.DOUJINSHI
}
"f_manga" -> if ("1" == value) {
category = category or EhUtils.MANGA
}
"f_artistcg" -> if ("1" == value) {
category = category or EhUtils.ARTIST_CG
}
"f_gamecg" -> if ("1" == value) {
category = category or EhUtils.GAME_CG
}
"f_western" -> if ("1" == value) {
category = category or EhUtils.WESTERN
}
"f_non-h" -> if ("1" == value) {
category = category or EhUtils.NON_H
}
"f_imageset" -> if ("1" == value) {
category = category or EhUtils.IMAGE_SET
}
"f_cosplay" -> if ("1" == value) {
category = category or EhUtils.COSPLAY
}
"f_asianporn" -> if ("1" == value) {
category = category or EhUtils.ASIAN_PORN
}
"f_misc" -> if ("1" == value) {
category = category or EhUtils.MISC
}
"f_search" -> try {
keyword = URLDecoder.decode(value, "utf-8")
} catch (e: UnsupportedEncodingException) {
// Ignore
} catch (_: IllegalArgumentException) {
}
"advsearch" -> if ("1" == value) {
enableAdvanceSearch = true
}
"f_sh" -> if ("on" == value) {
advanceSearch = advanceSearch or AdvanceTable.SH
}
"f_sto" -> if ("on" == value) {
advanceSearch = advanceSearch or AdvanceTable.STO
}
"f_sfl" -> if ("on" == value) {
advanceSearch = advanceSearch or AdvanceTable.SFL
}
"f_sfu" -> if ("on" == value) {
advanceSearch = advanceSearch or AdvanceTable.SFU
}
"f_sft" -> if ("on" == value) {
advanceSearch = advanceSearch or AdvanceTable.SFT
}
"f_sr" -> if ("on" == value) {
enableMinRating = true
}
"f_srdd" -> minRating = value.toIntOrDefault(-1)
"f_sp" -> if ("on" == value) {
enablePage = true
}
"f_spf" -> pageFrom = value.toIntOrDefault(-1)
"f_spt" -> pageTo = value.toIntOrDefault(-1)
"f_shash" -> hash = value
if (params["f_sto"] == "on") {
advanceSearch = advanceSearch or AdvanceTable.STO
}
}
this.category = category
mKeyword = keyword
if (enableAdvanceSearch) {
this.advanceSearch = advanceSearch
if (enableMinRating) {
this.minRating = minRating
} else {
this.minRating = -1
if (params["f_sfl"] == "on") {
advanceSearch = advanceSearch or AdvanceTable.SFL
}
if (enablePage) {
this.pageFrom = pageFrom
this.pageTo = pageTo
} else {
this.pageFrom = -1
this.pageTo = -1
if (params["f_sfu"] == "on") {
advanceSearch = advanceSearch or AdvanceTable.SFU
}
} else {
this.advanceSearch = -1
if (params["f_sft"] == "on") {
advanceSearch = advanceSearch or AdvanceTable.SFT
}
minRating = params["f_srdd"]?.toIntOrNull() ?: -1
pageFrom = params["f_spf"]?.toIntOrNull() ?: -1
pageTo = params["f_spt"]?.toIntOrNull() ?: -1
}
}
@@ -262,7 +152,7 @@ data class ListUrlBuilder(
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
val category = if (!Settings.hasSignedIn.value && category <= 0) EhUtils.NON_H else category
if (category > 0) {
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
addQueryParameter("f_cats", "${EhUtils.invCategory(category)}")
}
val query = mKeyword?.let { keyword ->
if (language == -1 || "gid:" in keyword || "l:" in keyword || "language:" in keyword) {
@@ -277,10 +167,10 @@ data class ListUrlBuilder(
}
}
addQueryParameterIfNotBlank("f_search", query)
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
addQueryParameterIfNotBlank("prev", prev)
addQueryParameterIfNotBlank("next", next)
addQueryParameterIfNotBlank("seek", jumpTo)
addQueryParameterIfNotBlank("range", range.takeIf { it > 0 }?.toString())
// Advance search
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
addQueryParameter("advsearch", "1")
@@ -301,12 +191,10 @@ data class ListUrlBuilder(
}
// Set min star
if (minRating > 0) {
addQueryParameter("f_sr", "on")
addQueryParameter("f_srdd", "$minRating")
}
// Pages
if (pageFrom > 0 || pageTo > 0) {
addQueryParameter("f_sp", "on")
addQueryParameterIfNotBlank(
"f_spf",
pageFrom.takeIf { it > 0 }?.toString(),
@@ -322,10 +210,10 @@ data class ListUrlBuilder(
MODE_UPLOADER, MODE_TAG -> {
val path = if (mode == MODE_UPLOADER) "uploader" else "tag"
ehUrl(listOf(path, requireNotNull(mKeyword))) {
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
addQueryParameterIfNotBlank("prev", prev)
addQueryParameterIfNotBlank("next", next)
addQueryParameterIfNotBlank("seek", jumpTo)
addQueryParameterIfNotBlank("range", range.takeIf { it > 0 }?.toString())
}.buildString()
}
@@ -333,27 +221,14 @@ data class ListUrlBuilder(
MODE_IMAGE_SEARCH -> ehUrl {
addQueryParameter("f_shash", requireNotNull(hash))
}.buildString()
MODE_TOPLIST -> {
ehUrl("toplist.php", EhUrl.DOMAIN_E) {
addQueryParameter("tl", requireNotNull(mKeyword))
addQueryParameterIfNotBlank("p", mJumpTo)
}.buildString()
}
MODE_TOPLIST -> ehUrl("toplist.php", EhUrl.DOMAIN_E) {
addQueryParameter("tl", requireNotNull(mKeyword))
addQueryParameterIfNotBlank("p", jumpTo)
}.buildString()
else -> throw IllegalStateException("Unexpected value: $mode")
}
@IntDef(
MODE_NORMAL,
MODE_UPLOADER,
MODE_TAG,
MODE_WHATS_HOT,
MODE_IMAGE_SEARCH,
MODE_SUBSCRIPTION,
MODE_TOPLIST,
)
@Retention(AnnotationRetention.SOURCE)
private annotation class Mode
companion object {
const val MODE_NORMAL = 0x0
const val MODE_UPLOADER = 0x1
@@ -1,33 +1,38 @@
package com.hippo.ehviewer.client.data
@JvmInline
value class TagNamespace(val value: String) {
fun toPrefix(): String? = prefixMap[this]
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
sealed class TagNamespace(val value: String, val prefix: String?) : Parcelable {
object Artist : TagNamespace("artist", "a")
object Cosplayer : TagNamespace("cosplayer", "cos")
object Character : TagNamespace("character", "c")
object Female : TagNamespace("female", "f")
object Group : TagNamespace("group", "g")
object Language : TagNamespace("language", "l")
object Male : TagNamespace("male", "m")
object Mixed : TagNamespace("mixed", "x")
object Other : TagNamespace("other", "o")
object Parody : TagNamespace("parody", "p")
object Reclass : TagNamespace("reclass", "r")
object Temp : TagNamespace("temp", null)
companion object {
val Artist = TagNamespace("artist")
val Cosplayer = TagNamespace("cosplayer")
val Character = TagNamespace("character")
val Female = TagNamespace("female")
val Group = TagNamespace("group")
val Language = TagNamespace("language")
val Male = TagNamespace("male")
val Mixed = TagNamespace("mixed")
val Other = TagNamespace("other")
val Parody = TagNamespace("parody")
val Reclass = TagNamespace("reclass")
private val prefixMap = mapOf(
Artist to "a",
Cosplayer to "cos",
Character to "c",
Female to "f",
Group to "g",
Language to "l",
Male to "m",
Mixed to "x",
Other to "o",
Parody to "p",
Reclass to "r",
)
fun from(value: String) = when (value) {
Artist.value -> Artist
Cosplayer.value -> Cosplayer
Character.value -> Character
Female.value -> Female
Group.value -> Group
Language.value -> Language
Male.value -> Male
Mixed.value -> Mixed
Other.value -> Other
Parody.value -> Parody
Reclass.value -> Reclass
Temp.value -> Temp
else -> null
}
}
}
@@ -25,8 +25,6 @@ object ArchiveParser {
Regex("<strong>(.*)</strong>.*<a href=\"([^\"]*)\">Click Here To Start Downloading</a>")
private val PATTERN_CURRENT_FUNDS =
Regex("<p>([\\d,]+) GP \\[[^]]*] &nbsp; ([\\d,]+) Credits \\[[^]]*]</p>")
private val PATTERN_HATH_FORM =
Regex("<form id=\"hathdl_form\" action=\"[^\"]*?or=([^=\"]*?)\" method=\"post\">")
private val PATTERN_HATH_ARCHIVE =
Regex("<p><a href=\"[^\"]*\" onclick=\"return do_hathdl\\('([0-9]+|org)'\\)\">([^<]+)</a></p>\\s*<p>([\\w. ]+)</p>\\s*<p>([\\w. ]+)</p>")
private const val ERROR_NEED_HATH_CLIENT =
@@ -35,8 +33,6 @@ object ArchiveParser {
"You do not have enough funds to download this archive."
fun parse(body: String, maybeFunds: Funds?): Result {
val m = PATTERN_HATH_FORM.find(body)!!
val paramOr = m.groupValues[1]
val archiveList = ArrayList<Archive>()
Jsoup.parse(body).select("#db>div>div").forEach { element ->
if (element.childrenSize() > 0 && !element.attr("style").contains("color:#CCCCCC")) {
@@ -60,11 +56,11 @@ object ArchiveParser {
archiveList.add(item)
}
val funds = maybeFunds ?: PATTERN_CURRENT_FUNDS.find(body)!!.groupValues.run {
val fundsGP = ParserUtils.parseInt(get(1), 0)
val fundsGP = ParserUtils.parseInt(get(1), 0) / 1000
val fundsC = ParserUtils.parseInt(get(2), 0)
Funds("%,d".format(fundsGP), "%,d".format(fundsC))
Funds(fundsGP, fundsC)
}
return Result(paramOr, archiveList, funds)
return Result(archiveList, funds)
}
fun parseArchiveUrl(body: String): String? {
@@ -78,7 +74,7 @@ object ArchiveParser {
// TODO: Check more errors
}
data class Result(val paramOr: String, val archiveList: List<Archive>, val funds: Funds)
data class Result(val archiveList: List<Archive>, val funds: Funds)
}
class Archive(
@@ -15,19 +15,19 @@
*/
package com.hippo.ehviewer.client.parser
import arrow.core.Either
import arrow.core.getOrElse
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.util.ExceptionUtils
import org.jsoup.Jsoup
object ForumsParser {
fun parse(body: String): String = runCatching {
fun parse(body: String): String = Either.catch {
val d = Jsoup.parse(body, EhUrl.URL_FORUMS)
val userlinks = d.getElementById("userlinks")
val child = userlinks!!.child(0).child(0).child(0)
child.attr("href")
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw NotLoggedInException()
}
}
@@ -15,28 +15,29 @@
*/
package com.hippo.ehviewer.client.parser
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.unzip
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhFilter
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.EhUtils.getCategory
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.GalleryCommentList
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.GalleryTagGroup
import com.hippo.ehviewer.client.data.LargeGalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.client.data.V1GalleryPreview
import com.hippo.ehviewer.client.data.V2GalleryPreview
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.OffensiveException
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.exception.PiningException
import com.hippo.ehviewer.client.getThumbKey
import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.ehviewer.client.getV2PreviewKey
import com.hippo.ehviewer.util.toEpochMillis
import com.hippo.ehviewer.util.toFloatOrDefault
import com.hippo.ehviewer.util.toIntOrDefault
@@ -50,7 +51,6 @@ import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.select.Elements
import org.jsoup.select.NodeTraversor
import org.jsoup.select.NodeVisitor
@@ -69,14 +69,12 @@ object GalleryDetailParser {
Regex("<tr><td[^<>]*>Length:</td><td[^<>]*>([\\d,]+) pages</td></tr>")
private val PATTERN_PREVIEW_PAGES =
Regex("<td[^>]+><a[^>]+>([\\d,]+)</a></td><td[^>]+>(?:<a[^>]+>)?&gt;(?:</a>)?</td>")
private val PATTERN_NORMAL_PREVIEW =
Regex("<div class=\"gdtm\"[^<>]*><div[^<>]*width:(\\d+)[^<>]*height:(\\d+)[^<>]*\\((.+?)\\)[^<>]*-(\\d+)px[^<>]*><a[^<>]*href=\"(.+?)\"[^<>]*><img alt=\"([\\d,]+)\"")
private val PATTERN_LARGE_PREVIEW =
Regex("<div class=\"gdtl\".+?<a href=\"(.+?)\"><img alt=\"([\\d,]+)\".+?src=\"(.+?)\"")
private val PATTERN_NEWER_DATE = Regex(", added (.+?)<br />")
private val PATTERN_FAVORITE_SLOT =
Regex("/fav.png\\); background-position:0px -(\\d+)px")
private val EMPTY_GALLERY_COMMENT_LIST = GalleryCommentList(emptyList(), false)
private val PreviewRegex =
Regex("<a href=\"([^\"]+)\">(?:<div>)?<div title=\"Page (\\d+)(?:[^\"]+\"){2}\\D+(\\d+)\\D+(\\d+)[^(]+\\(([^)]+)\\)(?: -(\\d+))?")
// dd MMMM yyyy, HH:mm
private val WEB_COMMENT_DATE_FORMAT = LocalDateTime.Format {
@@ -106,7 +104,7 @@ object GalleryDetailParser {
PATTERN_ERROR.find(body)?.run { throw EhException(groupValues[1]) }
val document = Jsoup.parse(body)
val galleryDetail = GalleryDetail(
tags = parseTagGroups(document),
tagGroups = parseTagGroups(document),
comments = parseComments(document),
previewPages = parsePreviewPages(body),
previewList = parsePreviewList(body).first,
@@ -135,7 +133,7 @@ object GalleryDetailParser {
PATTERN_ARCHIVE.find(body)?.run {
gd.archiveUrl = groupValues[1].trim().unescapeXml()
}
try {
Either.catch {
val gm = d.getElementsByClass("gm")[0]
// Thumb url
gm.getElementById("gd1")?.child(0)?.attr("style")?.trim()?.let {
@@ -146,16 +144,15 @@ object GalleryDetailParser {
gd.titleJpn = gm.getElementById("gj")?.text()?.trim()
// Category
try {
gd.category = Either.catch {
val gdc = gm.getElementById("gdc")!!
var ce = gdc.getElementsByClass("cn").first()
if (ce == null) {
ce = gdc.getElementsByClass("cs").first()
}
gd.category = getCategory(ce!!.text())
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
gd.category = EhUtils.UNKNOWN
EhUtils.getCategory(ce!!.text())
}.getOrElse {
EhUtils.UNKNOWN
}
// Uploader
@@ -218,8 +215,7 @@ object GalleryDetailParser {
if (gd.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(gd.gid)) {
gd.favoriteSlot = LOCAL_FAVORITED
}
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
}.getOrElse {
throw ParseException("Can't parse gallery detail")
}
@@ -278,161 +274,141 @@ object GalleryDetailParser {
}
}
private fun parseTagGroup(element: Element): GalleryTagGroup? = try {
var nameSpace = element.child(0).text()
// Remove last ':'
nameSpace = nameSpace.substring(0, nameSpace.length - 1)
val group = GalleryTagGroup(nameSpace)
val tags = element.child(1).children()
tags.forEach {
var tag = it.text()
// Sometimes parody tag is followed with '|' and english translate, just remove them
val index = tag.indexOf('|')
if (index >= 0) {
tag = tag.substring(0, index).trim()
}
if (it.className() == "gtw") {
tag = "_$tag" // weak tag
}
group.add(tag)
private fun parseSingleTagGroup(element: Element): GalleryTagGroup? = Either.catch {
val nameSpace = with(element.child(0).text()) {
// Remove last ':'
TagNamespace.from(substring(0, length - 1))
}
if (group.size > 0) group else null
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
checkNotNull(nameSpace) { "$nameSpace is not a valid tag namespace!" }
val tags = element.child(1).children().map { e ->
val text = e.text()
// Sometimes parody tag is followed with '|' and english translate, just remove them
val index = text.indexOf('|')
val tag = if (index >= 0) text.substring(0, index).trim() else text
// weak tag
if (e.className() == "gtw") "_$tag" else tag
}
check(tags.isNotEmpty()) { "TagGroup is empty!" }
GalleryTagGroup(nameSpace, tags)
}.getOrElse {
logcat(it)
null
}
/**
* Parse tag groups with html parser
*/
private fun parseTagGroups(document: Document): List<GalleryTagGroup> = try {
private fun parseTagGroups(document: Document): List<GalleryTagGroup> = Either.catch {
val taglist = document.getElementById("taglist")!!
val tagGroups = taglist.child(0).child(0).children()
parseTagGroups(tagGroups)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
taglist.child(0).child(0).children().mapNotNull(::parseSingleTagGroup)
}.getOrElse {
logcat(it)
emptyList()
}
private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> = try {
trs.mapNotNull { parseTagGroup(it) }
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
emptyList()
}
private suspend fun parseComment(element: Element): GalleryComment? {
return try {
// Id
val a = element.previousElementSibling()
val name = a!!.attr("name")
val id = name trimAnd { substring(1).toInt().toLong() }
// Editable, vote up and vote down
val c4 = element.getElementsByClass("c4").first()
var voteUpAble = false
var voteUpEd = false
var voteDownAble = false
var voteDownEd = false
var editable = false
var uploader = false
if (null != c4) {
if ("Uploader Comment" == c4.text()) {
uploader = true
}
for (e in c4.children()) {
when (e.text()) {
"Vote+" -> {
voteUpAble = true
voteUpEd = e.attr("style").trim().isNotEmpty()
}
"Vote-" -> {
voteDownAble = true
voteDownEd = e.attr("style").trim().isNotEmpty()
}
"Edit" -> editable = true
private suspend fun parseComment(element: Element): GalleryComment? = Either.catch {
// Id
val a = element.previousElementSibling()
val name = a!!.attr("name")
val id = name trimAnd { substring(1).toInt().toLong() }
// Editable, vote up and vote down
val c4 = element.getElementsByClass("c4").first()
var voteUpAble = false
var voteUpEd = false
var voteDownAble = false
var voteDownEd = false
var editable = false
var uploader = false
if (null != c4) {
if ("Uploader Comment" == c4.text()) {
uploader = true
}
for (e in c4.children()) {
when (e.text()) {
"Vote+" -> {
voteUpAble = true
voteUpEd = e.attr("style").trim().isNotEmpty()
}
"Vote-" -> {
voteDownAble = true
voteDownEd = e.attr("style").trim().isNotEmpty()
}
"Edit" -> editable = true
}
}
// Vote state
val c7 = element.getElementsByClass("c7").first()
val voteState = c7?.text()?.trim()
// Score
val c5 = element.getElementsByClass("c5").first()
val score = c5?.children()?.first()?.text()?.trim()?.toIntOrDefault(0) ?: 0
// time
val c3 = element.getElementsByClass("c3").first()
val temp = c3!!.ownText()
val (timeStr, user) = if (temp.endsWith(':')) {
temp.substring("Posted on ".length, temp.length - " by:".length) to c3.child(0).text()
} else {
temp.substring("Posted on ".length) to null
}
val time = WEB_COMMENT_DATE_FORMAT.parse(timeStr).toEpochMillis()
// comment
val c6 = element.getElementsByClass("c6").first()
// fix underline support
for (e in c6!!.children()) {
if ("span" == e.tagName() && "text-decoration:underline;" == e.attr("style")) {
e.tagName("u")
}
}
val commentHtml = c6.html()
// filter comment
if (!uploader) {
val sEhFilter = EhFilter
if (score <= Settings.commentThreshold || sEhFilter.filterCommenter(user.orEmpty()) || sEhFilter.filterComment(commentHtml)) {
return null
}
}
// last edited
val c8 = element.getElementsByClass("c8").first()
val lastEdited = c8?.children()?.first()?.run { WEB_COMMENT_DATE_FORMAT.parse(text()).toEpochMillis() } ?: 0
GalleryComment(
id, score, editable, voteUpAble, voteUpEd, voteDownAble,
voteDownEd, uploader, voteState, time, user, commentHtml, lastEdited,
)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
null
}
// Vote state
val c7 = element.getElementsByClass("c7").first()
val voteState = c7?.text()?.trim()
// Score
val c5 = element.getElementsByClass("c5").first()
val score = c5?.children()?.first()?.text()?.trim()?.toIntOrDefault(0) ?: 0
// time
val c3 = element.getElementsByClass("c3").first()
val temp = c3!!.ownText()
val (timeStr, user) = if (temp.endsWith(':')) {
temp.substring("Posted on ".length, temp.length - " by:".length) to c3.child(0).text()
} else {
temp.substring("Posted on ".length) to null
}
val time = WEB_COMMENT_DATE_FORMAT.parse(timeStr).toEpochMillis()
// comment
val c6 = element.getElementsByClass("c6").first()
// fix underline support
for (e in c6!!.children()) {
if ("span" == e.tagName() && "text-decoration:underline;" == e.attr("style")) {
e.tagName("u")
}
}
val commentHtml = c6.html()
// filter comment
if (!uploader) {
val sEhFilter = EhFilter
if (score <= Settings.commentThreshold || sEhFilter.filterCommenter(user.orEmpty()) || sEhFilter.filterComment(commentHtml)) {
return null
}
}
// last edited
val c8 = element.getElementsByClass("c8").first()
val lastEdited = c8?.children()?.first()?.run { WEB_COMMENT_DATE_FORMAT.parse(text()).toEpochMillis() } ?: 0
GalleryComment(
id, score, editable, voteUpAble, voteUpEd, voteDownAble,
voteDownEd, uploader, voteState, time, user, commentHtml, lastEdited,
)
}.getOrElse {
logcat(it)
null
}
/**
* Parse comments with html parser
*/
suspend fun parseComments(document: Document): GalleryCommentList {
suspend fun parseComments(document: Document): GalleryCommentList = Either.catch {
// Disable pretty print to get comments in raw html
document.outputSettings().prettyPrint(false)
return try {
val cdiv = document.getElementById("cdiv")!!
val c1s = cdiv.getElementsByClass("c1")
val list = c1s.mapNotNull { parseComment(it) }
val chd = cdiv.getElementById("chd")
var hasMore = false
NodeTraversor.traverse(
object : NodeVisitor {
override fun head(node: Node, depth: Int) {
if (node is Element && node.text() == "click to show all") {
hasMore = true
}
val cdiv = document.getElementById("cdiv")!!
val c1s = cdiv.getElementsByClass("c1")
val list = c1s.mapNotNull { parseComment(it) }
val chd = cdiv.getElementById("chd")
var hasMore = false
NodeTraversor.traverse(
object : NodeVisitor {
override fun head(node: Node, depth: Int) {
if (node is Element && node.text() == "click to show all") {
hasMore = true
}
}
override fun tail(node: Node, depth: Int) {}
},
chd!!,
)
GalleryCommentList(list, hasMore)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
EMPTY_GALLERY_COMMENT_LIST
}
override fun tail(node: Node, depth: Int) {}
},
chd!!,
)
GalleryCommentList(list, hasMore)
}.getOrElse {
logcat(it)
EMPTY_GALLERY_COMMENT_LIST
}
/**
@@ -447,32 +423,23 @@ object GalleryDetailParser {
fun parsePages(body: String): Int = PATTERN_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
?: throw ParseException("Parse pages error")
fun parsePreviewList(body: String): Pair<List<GalleryPreview>, List<String>> = runCatching { parseNormalPreview(body) }.getOrElse { parseLargePreview(body) }
private fun parseLargePreview(body: String): Pair<List<GalleryPreview>, List<String>> {
check(PATTERN_LARGE_PREVIEW.containsMatchIn(body))
return PATTERN_LARGE_PREVIEW.findAll(body).unzip {
val position = it.groupValues[2].toInt() - 1
val url = it.groupValues[3].trim()
val pageUrl = it.groupValues[1].trim()
LargeGalleryPreview(url, position) to pageUrl
}.run {
first.toList() to second.toList()
}
}
private fun parseNormalPreview(body: String): Pair<List<GalleryPreview>, List<String>> {
check(PATTERN_NORMAL_PREVIEW.containsMatchIn(body))
return PATTERN_NORMAL_PREVIEW.findAll(body).unzip {
val position = it.groupValues[6].toInt() - 1
val url = it.groupValues[3].trim()
val xOffset = it.groupValues[4].toIntOrNull() ?: 0
val width = it.groupValues[1].toInt()
val height = it.groupValues[2].toInt()
val pageUrl = it.groupValues[5].trim()
NormalGalleryPreview(url, position, xOffset, width, height) to pageUrl
}.run {
first.toList() to second.toList()
}
fun parsePreviewList(body: String) = PreviewRegex.findAll(body).unzip {
val pageUrl = it.groupValues[1]
val position = it.groupValues[2].toInt() - 1
val url = it.groupValues[5]
val offset = it.groupValues[6]
if (offset.isEmpty()) {
val imageKey = getThumbKey(url)
V1GalleryPreview(imageKey, position)
} else {
val imageKey = getV2PreviewKey(url)
val width = it.groupValues[3].toInt()
val height = it.groupValues[4].toInt()
V2GalleryPreview(url, imageKey, position, offset.toInt(), width, height)
} to pageUrl
}.run {
first.toList().apply {
if (isEmpty()) throw ParseException("Parse preview list error")
} to second.toList()
}
}
@@ -15,124 +15,56 @@
*/
package com.hippo.ehviewer.client.parser
import android.net.Uri
import android.os.Build
import arrow.core.Either
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.ListUrlBuilder
import eu.kanade.tachiyomi.util.system.logcat
import java.io.UnsupportedEncodingException
import java.net.MalformedURLException
import java.net.URL
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import io.ktor.http.Url
import io.ktor.http.decodeURLQueryComponent
object GalleryListUrlParser {
private val VALID_HOSTS = arrayOf(EhUrl.DOMAIN_EX, EhUrl.DOMAIN_E)
private const val PATH_NORMAL = "/"
private const val PATH_UPLOADER = "/uploader/"
private const val PATH_TAG = "/tag/"
private const val PATH_TOPLIST = "/toplist.php"
fun parse(urlStr: String): ListUrlBuilder? {
val url = try {
URL(urlStr)
} catch (e: MalformedURLException) {
fun parse(urlStr: String) = Either.catch {
val url = Url(urlStr)
if (url.host != EhUrl.DOMAIN_E && url.host != EhUrl.DOMAIN_EX) {
return null
}
if (url.host !in VALID_HOSTS) {
return null
}
val path = url.path ?: return null
return if (PATH_NORMAL == path || path.isEmpty()) {
ListUrlBuilder(url.query)
} else if (path.startsWith(PATH_UPLOADER)) {
parseUploader(path)
} else if (path.startsWith(PATH_TAG)) {
parseTag(path)
} else if (path.startsWith(PATH_TOPLIST)) {
parseToplist(urlStr)
} else if (path.startsWith("/")) {
val category = try {
path.substring(1).toInt()
} catch (e: NumberFormatException) {
return null
val segments = url.segments
if (segments.isEmpty()) {
ListUrlBuilder(url.parameters).apply {
url.parameters["f_shash"]?.let {
mode = ListUrlBuilder.MODE_IMAGE_SEARCH
hash = it
}
}
ListUrlBuilder(url.query).also { it.category = category }
} else {
null
}
}
when (val head = segments[0]) {
"uploader", "tag" -> ListUrlBuilder(url.parameters).apply {
mode = if (head == "uploader") ListUrlBuilder.MODE_UPLOADER else ListUrlBuilder.MODE_TAG
keyword = segments[1].decodeURLQueryComponent(plusIsSpace = true)
}
// TODO get page
private fun parseUploader(path: String): ListUrlBuilder? {
var uploader: String?
val prefixLength = PATH_UPLOADER.length
val index = path.indexOf('/', prefixLength)
uploader = if (index < 0) {
path.substring(prefixLength)
} else {
path.substring(prefixLength, index)
}
uploader = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
URLDecoder.decode(uploader, StandardCharsets.UTF_8)
} else {
try {
URLDecoder.decode(uploader, StandardCharsets.UTF_8.displayName())
} catch (e: UnsupportedEncodingException) {
logcat(e)
return null
"toplist.php" -> {
val tl = url.parameters["tl"]
if (tl != null && tl in arrayOf("11", "12", "13", "15")) {
ListUrlBuilder(
mode = ListUrlBuilder.MODE_TOPLIST,
jumpTo = url.parameters["p"],
mKeyword = tl,
)
} else {
null
}
}
else -> {
val category = EhUtils.getCategory(head)
if (category != EhUtils.UNKNOWN) {
ListUrlBuilder(url.parameters).also { it.category = category }
} else {
null
}
}
}
}
if (uploader.isNullOrEmpty()) {
return null
}
val builder = ListUrlBuilder()
builder.mode = ListUrlBuilder.MODE_UPLOADER
builder.keyword = uploader
return builder
}
// TODO get page
private fun parseTag(path: String): ListUrlBuilder? {
var tag: String?
val prefixLength = PATH_TAG.length
val index = path.indexOf('/', prefixLength)
tag = if (index < 0) {
path.substring(prefixLength)
} else {
path.substring(prefixLength, index)
}
tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
URLDecoder.decode(tag, StandardCharsets.UTF_8)
} else {
try {
URLDecoder.decode(tag, StandardCharsets.UTF_8.displayName())
} catch (e: UnsupportedEncodingException) {
logcat(e)
return null
}
}
if (tag.isNullOrEmpty()) {
return null
}
val builder = ListUrlBuilder()
builder.mode = ListUrlBuilder.MODE_TAG
builder.keyword = tag
return builder
}
// TODO get page
private fun parseToplist(path: String): ListUrlBuilder? {
val uri = Uri.parse(path)
if (uri == null || uri.getQueryParameter("tl").isNullOrEmpty()) {
return null
}
val tl = uri.getQueryParameter("tl")!!.toInt()
if (tl > 15 || tl < 11) {
return null
}
val builder = ListUrlBuilder()
builder.mode = ListUrlBuilder.MODE_TOPLIST
builder.keyword = tl.toString()
return builder
}
}.getOrNull()
}
@@ -17,16 +17,16 @@
*/
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.util.ExceptionUtils
import arrow.core.Either
import arrow.core.getOrElse
import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup
object GalleryNotAvailableParser {
fun parse(body: String): String? = runCatching {
fun parse(body: String): String? = Either.catch {
val e = Jsoup.parse(body).getElementsByClass("d").first()
e!!.child(0).html().replace("<br>", "\n")
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
logcat(it)
null
}
@@ -15,9 +15,10 @@
*/
package com.hippo.ehviewer.client.parser
import arrow.core.Either
import arrow.core.getOrElse
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.parseAs
import com.hippo.ehviewer.util.ExceptionUtils
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -32,10 +33,9 @@ object GalleryTokenApiParser {
* ]
* }
*/
fun parse(body: String): String = runCatching {
fun parse(body: String): String = Either.catch {
body.parseAs<Result>().tokenList[0].token
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw EhException(body.parseAs<Error>().error)
}
@@ -4,11 +4,13 @@ import android.os.Parcelable
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlin.random.Random
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
object HomeParser {
private val PATTERN_FUNDS = Regex("Available: ([\\d,]+) Credits.*Available: ([\\d,]+) kGP", RegexOption.DOT_MATCHES_ALL)
private val TorrentKeyRegex = Regex("Your current key is: <[^>]*>([^<]*)<")
private val FundsRegex = Regex("Available: ([\\d,]+) Credits.*Available: ([\\d,]+) kGP", RegexOption.DOT_MATCHES_ALL)
private const val INSUFFICIENT_FUNDS = "Insufficient funds."
fun parse(body: ByteBuffer) = runCatching {
@@ -21,21 +23,24 @@ object HomeParser {
}
}
fun parseTorrentKey(body: String) = TorrentKeyRegex.find(body)?.run { groupValues[1] }
?: throw ParseException("Parse torrent key error")
fun parseFunds(body: String): Funds {
PATTERN_FUNDS.find(body)?.groupValues?.run {
FundsRegex.find(body)?.groupValues?.run {
val fundsC = ParserUtils.parseInt(get(1), 0)
val fundsGP = ParserUtils.parseInt(get(2), 0) * 1000
return Funds("%,d+".format(fundsGP), "%,d".format(fundsC))
val fundsGP = ParserUtils.parseInt(get(2), 0)
return Funds(fundsGP, fundsC)
}
throw ParseException("Parse funds error")
}
@Parcelize
data class Result(val limits: Limits, val funds: Funds) : Parcelable
data class Result(val limits: Limits, val funds: Funds, private val id: Int = Random.nextInt()) : Parcelable
}
@Parcelize
data class Funds(val gp: String, val credit: String) : Parcelable
data class Funds(val gp: Int, val credit: Int) : Parcelable
@Parcelize
@Serializable
@@ -15,18 +15,19 @@
*/
package com.hippo.ehviewer.client.parser
import arrow.core.Either
import arrow.core.getOrElse
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.util.ExceptionUtils
import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup
object ProfileParser {
fun parse(body: String): Result = runCatching {
fun parse(body: String): Result = Either.catch {
val d = Jsoup.parse(body)
val profilename = d.getElementById("profilename")
val displayName = profilename!!.child(0).text()
val avatar = runCatching {
val avatar = Either.catch {
val avatar =
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
if (avatar.isEmpty()) {
@@ -37,13 +38,11 @@ object ProfileParser {
avatar
}
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
logcat { "No avatar" }
null
}
Result(displayName, avatar)
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw ParseException("Parse forums error")
}
@@ -1,41 +1,49 @@
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.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 okio.Path
import okio.Path.Companion.toPath
private val emptyPath = "".toPath()
private val downloadLocationKey = Extras.Key(default = emptyPath)
fun ImageRequest.Builder.downloadLocation(path: Path) = apply {
extras[downloadLocationKey] = path
}
val ImageRequest.downloadLocation: Path
get() = getExtra(downloadLocationKey)
object DownloadThumbInterceptor : Interceptor {
private const val THUMB_FILE = "thumb.jpg"
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val magicOrUrl = chain.request.data as? String
if (magicOrUrl != null) {
val (url, location) = decodeMagicRequestOrUrl(magicOrUrl)
if (location != null) {
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 thumb = chain.request.downloadLocation
if (thumb != emptyPath) {
if (thumb.isFile) {
val new = chain.request.newBuilder().data(thumb.toUri()).build()
val result = chain.withRequest(new).proceed()
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
if (result is SuccessResult) return result
}
val new = chain.request.newBuilder().build()
val result = chain.withRequest(new).proceed()
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
}
return chain.proceed()
}
@@ -1,52 +1,36 @@
package com.hippo.ehviewer.coil
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import coil3.decode.BlackholeDecoder
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.size.Size
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.CHROME_ACCEPT
import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
import com.hippo.ehviewer.client.imageKey
import com.hippo.ehviewer.spider.DownloadInfoMagics.encodeMagicRequestOrUrl
import io.ktor.http.HttpHeaders
private val header = NetworkHeaders.Builder().apply {
add(HttpHeaders.UserAgent, Settings.userAgent)
add(HttpHeaders.Accept, CHROME_ACCEPT)
add(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
}.build()
import com.hippo.ehviewer.client.data.V2GalleryPreview
import com.hippo.ehviewer.client.thumbUrl
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.downloadLocation
fun ImageRequest.Builder.ehUrl(info: GalleryInfo) = apply {
val key = info.thumbKey!!
data(encodeMagicRequestOrUrl(info))
data(info.thumbUrl)
val format = info.thumbUrl.substringAfterLast('.', "")
check(format.isNotBlank())
if (info is DownloadInfo && !info.dirname.isNullOrBlank()) {
downloadLocation(downloadLocation / info.dirname / "thumb.$format")
}
memoryCacheKey(key)
diskCacheKey(key)
httpHeaders(header)
}
fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
data(preview.url)
memoryCacheKey(preview.imageKey)
diskCacheKey(preview.imageKey)
if (preview is NormalGalleryPreview) size(Size.ORIGINAL)
httpHeaders(header)
fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = with(preview) {
data(url)
if (preview is V2GalleryPreview) size(Size.ORIGINAL)
memoryCacheKey(imageKey)
diskCacheKey(imageKey)
}
private val stubImage = ColorDrawable(Color.BLACK).asImage(true)
private val stubResult = DecodeResult(stubImage, false)
private val stubFactory = Decoder { stubResult }
fun ImageRequest.Builder.justDownload() = apply {
memoryCachePolicy(CachePolicy.DISABLED)
decoderFactory { _, _, _ -> stubFactory }
decoderFactory(BlackholeDecoder.Factory())
}
@@ -0,0 +1,30 @@
package com.hippo.ehviewer.coil
import coil3.network.NetworkClient
import coil3.network.NetworkRequest
import coil3.network.NetworkResponse
import coil3.network.ktor3.asNetworkClient
import com.hippo.ehviewer.client.URL_PREFIX_THUMB_EX
import com.hippo.ehviewer.client.URL_SIGNATURE_THUMB_NORMAL
import io.ktor.client.HttpClient
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import moe.tarsin.coroutines.NamedSemaphore
import moe.tarsin.coroutines.withLock
class LimitConcurrencyNetworkClient(val impl: NetworkClient) : NetworkClient {
val semaphores = NamedSemaphore<String>(permits = 8)
override suspend fun <T> executeRequest(req: NetworkRequest, f: suspend (NetworkResponse) -> T): T {
val url = req.url
return when {
// Ex thumb server may not have h2 multiplexing support
URL_PREFIX_THUMB_EX in url -> semaphores.withLock(URL_PREFIX_THUMB_EX) { withContext(NonCancellable) { impl.executeRequest(req, f) } }
// H@H server may not have h2 multiplexing support
URL_SIGNATURE_THUMB_NORMAL in url -> semaphores.withLock(url.substringBefore(URL_SIGNATURE_THUMB_NORMAL)) { withContext(NonCancellable) { impl.executeRequest(req, f) } }
// H2 multiplexing enabled
else -> impl.executeRequest(req, f)
}
}
}
fun HttpClient.limitConcurrency() = LimitConcurrencyNetworkClient(asNetworkClient())
@@ -1,84 +1,28 @@
package com.hippo.ehviewer.coil
import coil3.decode.DataSource
import coil3.intercept.Interceptor
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.isNormalPreviewKey
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import com.hippo.ehviewer.client.isV2PreviewKey
import moe.tarsin.coroutines.NamedMutex
import moe.tarsin.coroutines.withLockNeedSuspend
object MergeInterceptor : Interceptor {
private val pendingContinuationMap: HashMap<String, MutableList<CancellableContinuation<Unit>>> = hashMapOf()
private val pendingContinuationMapLock = Any()
private val notifyScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val EMPTY_LIST = mutableListOf<CancellableContinuation<Unit>>()
private fun triggerSuccessor(key: String) {
notifyScope.launch {
synchronized(pendingContinuationMapLock) {
// Wake up a pending continuation to continue executing task
val successor = pendingContinuationMap[key]?.removeFirstOrNull()?.apply {
resume(Unit) { _, _, _ -> triggerSuccessor(key) }
}
// If no successor, delete this entry from hashmap
successor ?: pendingContinuationMap.remove(key)
}
}
}
private fun triggerFailure(key: String, e: Throwable) {
notifyScope.launch {
synchronized(pendingContinuationMapLock) {
pendingContinuationMap.remove(key)?.forEach { it.resumeWithException(e) }
}
}
}
private val mutex = NamedMutex<String>()
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val req = chain.request
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) { _, _, _ ->
triggerSuccessor(key)
}
} else {
if (existPendingContinuations === EMPTY_LIST) pendingContinuationMap[key] = mutableListOf()
pendingContinuationMap[key]!!.apply {
add(continuation)
continuation.invokeOnCancellation { remove(continuation) }
}
}
val key = req.memoryCacheKey?.takeIf { it.isV2PreviewKey || Settings.preloadThumbAggressively }
return if (key != null) {
val (result, suspended) = mutex.withLockNeedSuspend(key) { chain.proceed() }
when (result) {
is SuccessResult if (suspended) -> result.copy(dataSource = DataSource.MEMORY)
else -> result
}
}
try {
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) {
pendingContinuationMap.remove(key)?.forEach { it.resume(Unit) }
}
}
}
} catch (e: CancellationException) {
triggerSuccessor(key)
throw e
} catch (e: Throwable) {
// Wake all pending continuations since this request is to be failed
triggerFailure(key, e)
throw e
} else {
chain.proceed()
}
}
}
@@ -1,20 +0,0 @@
package com.hippo.ehviewer.cronet
import com.hippo.ehviewer.Settings
import java.io.File
import org.chromium.net.CronetEngine
import splitties.init.appCtx
val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
enableBrotli(true)
setUserAgent(Settings.userAgent)
// Cache Quic hint only since the real cache mechanism should on Ktor layer
val cache = File(appCtx.cacheDir, "http_cache").apply { mkdirs() }
setStoragePath(cache.path)
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096)
addQuicHint("e-hentai.org", 443, 443)
addQuicHint("forums.e-hentai.org", 443, 443)
addQuicHint("exhentai.org", 443, 443)
}.build()
@@ -33,7 +33,6 @@ import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.dao.DownloadArtist
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.DownloadLabel
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.spider.COMIC_INFO_FILE
import com.hippo.ehviewer.spider.SpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME
@@ -683,14 +682,6 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
}
override fun onGetImageSuccess(index: Int, image: Image?) {
// Ignore
}
override fun onGetImageFailure(index: Int, error: String?) {
// Ignore
}
interface DownloadListener {
/**
* Get 509 error
@@ -15,11 +15,12 @@
*/
package com.hippo.ehviewer.gallery
import android.os.ParcelFileDescriptor
import com.hippo.ehviewer.Settings
import arrow.fx.coroutines.autoCloseable
import arrow.fx.coroutines.closeable
import arrow.fx.coroutines.resourceScope
import com.hippo.ehviewer.Settings.archivePasswds
import com.hippo.ehviewer.image.ByteBufferSource
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.image.ImageSource
import com.hippo.ehviewer.image.byteBufferSource
import com.hippo.ehviewer.jni.closeArchive
import com.hippo.ehviewer.jni.extractToByteBuffer
import com.hippo.ehviewer.jni.extractToFd
@@ -28,140 +29,65 @@ import com.hippo.ehviewer.jni.needPassword
import com.hippo.ehviewer.jni.openArchive
import com.hippo.ehviewer.jni.providePassword
import com.hippo.ehviewer.jni.releaseByteBuffer
import com.hippo.ehviewer.ui.screen.implicit
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.displayPath
import com.hippo.ehviewer.util.displayName
import com.hippo.files.openFileDescriptor
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.system.logcat
import java.nio.ByteBuffer
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import moe.tarsin.coroutines.NamedMutex
import moe.tarsin.coroutines.withLock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import okio.Path
typealias PasswdInvalidator = (String) -> Boolean
typealias PasswdProvider = suspend (PasswdInvalidator) -> String
val emptyPasswdProvider: PasswdProvider = { error("Managed Archive have password???") }
class ArchivePageLoader(
private val file: Path,
suspend fun <T> useArchivePageLoader(
file: Path,
gid: Long = 0,
startPage: Int = 0,
private val hasAds: Boolean = false,
passwdProvider: PasswdProvider? = null,
) : PageLoader2(gid, startPage) {
private lateinit var pfd: ParcelFileDescriptor
private val hostJob = async(start = CoroutineStart.LAZY) {
logcat(DEBUG_TAG) { "Open archive ${file.toUri().displayPath}" }
pfd = file.openFileDescriptor("r")
val size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
if (size != 0 && passwdProvider != null && needPassword()) {
archivePasswds?.forEach {
it ?: return@forEach
if (providePassword(it)) return@async size
}
val toAdd = passwdProvider { providePassword(it) }
archivePasswds = archivePasswds?.toMutableSet()?.apply { add(toAdd) } ?: setOf(toAdd)
hasAds: Boolean = false,
passwdProvider: PasswdProvider = emptyPasswdProvider,
block: suspend (PageLoader) -> T,
) = coroutineScope {
resourceScope {
val pfd = closeable { file.openFileDescriptor("r") }
val size = install(
{ openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip")) },
{ _, _ -> closeArchive() },
)
check(size >= 0) { "Archive have no content!" }
if (needPassword() && archivePasswds.filterNotNull().none { providePassword(it) }) {
archivePasswds += passwdProvider { providePassword(it) }
}
size
}
val loader = autoCloseable {
object : PageLoader(gid, startPage, size, hasAds, implicit<CoroutineScope>()) {
override val title by lazy { FileUtils.getNameFromFilename(file.displayName)!! }
override var size = 0
private set
override fun getImageExtension(index: Int) = getExtension(index)
override fun start() {
super.start()
hostJob.start()
}
override fun stop() {
super.stop()
closeArchive()
pfd.close()
logcat(DEBUG_TAG) { "Close archive ${file.toUri().displayPath} successfully!" }
}
private val mJobMap = hashMapOf<Int, Job>()
private val mWorkerMutex = NamedMutex<Int>()
private val mSemaphore = Semaphore(4)
override fun onRequest(index: Int) {
synchronized(mJobMap) {
val current = mJobMap[index]
if (current?.isActive != true) {
mJobMap[index] = launch {
mWorkerMutex.withLock(index) {
mSemaphore.withPermit {
doRealWork(index)
}
override fun save(index: Int, file: Path) = runCatching {
file.openFileDescriptor("w").use {
extractToFd(index, it.fd)
}
}.getOrElse {
logcat(it)
false
}
override fun openSource(index: Int): ImageSource {
val buffer = extractToByteBuffer(index)
checkNotNull(buffer) { "Extract archive content $index failed!" }
check(buffer.isDirect)
return byteBufferSource(buffer) { releaseByteBuffer(buffer) }
}
override fun prefetchPages(pages: List<Int>, bounds: IntRange) = pages.forEach(::notifySourceReady)
override fun onRequest(index: Int, force: Boolean, orgImg: Boolean) = notifySourceReady(index)
}
}
loader.progressJob.join()
block(loader)
}
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)
val src = object : ByteBufferSource {
override val source: ByteBuffer = buffer
override fun close() {
releaseByteBuffer(buffer)
}
}
runCatching {
currentCoroutineContext().ensureActive()
}.onFailure {
src.close()
throw it
}
val image = Image.decode(src, hasAds && Settings.stripExtraneousAds.value && mayBeAd(index)) ?: return notifyPageFailed(index, null)
runCatching {
currentCoroutineContext().ensureActive()
}.onFailure {
image.recycle()
throw it
}
notifyPageSucceed(index, image)
}
override fun onForceRequest(index: Int, orgImg: Boolean) {
onRequest(index)
}
override suspend fun awaitReady(): Boolean {
size = hostJob.await()
return super.awaitReady() && isReady
}
override val isReady: Boolean
get() = size != 0
override val title by lazy {
FileUtils.getNameFromFilename(file.name)!!
}
override fun getImageExtension(index: Int): String = getExtension(index)
override fun save(index: Int, file: Path): Boolean = runCatching {
file.openFileDescriptor("w").use {
extractToFd(index, it.fd)
}
}.getOrElse {
logcat(it)
false
}
override fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>) = Unit
}
private const val DEBUG_TAG = "ArchivePageLoader"
@@ -1,107 +1,62 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.gallery
import arrow.fx.coroutines.autoCloseable
import arrow.fx.coroutines.resourceScope
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.spider.SpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.obtainSpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.releaseSpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
import com.hippo.ehviewer.ui.screen.implicit
import com.hippo.ehviewer.util.hasAds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import okio.Path
class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
PageLoader2(mGalleryInfo.gid, startPage),
OnSpiderListener {
private lateinit var mSpiderQueen: SpiderQueen
override fun start() {
super.start()
if (!::mSpiderQueen.isInitialized) {
mSpiderQueen = obtainSpiderQueen(mGalleryInfo, SpiderQueen.MODE_READ)
mSpiderQueen.addOnSpiderListener(this)
suspend fun <T> useEhPageLoader(
info: GalleryInfo,
startPage: Int,
block: suspend (PageLoader) -> T,
) = coroutineScope {
resourceScope {
val queen = install(
{ obtainSpiderQueen(info, SpiderQueen.MODE_READ) },
{ queen, _ -> releaseSpiderQueen(queen, SpiderQueen.MODE_READ) },
)
queen.awaitReady()
val loader = autoCloseable {
object : PageLoader(info.gid, startPage, queen.size, info.hasAds, implicit<CoroutineScope>()) {
override val title by lazy { EhUtils.getSuitableTitle(info) }
override fun getImageExtension(index: Int) = queen.getExtension(index)
override fun save(index: Int, file: Path) = queen.save(index, file)
override fun openSource(index: Int) = queen.spiderDen.getImageSource(index)
override fun prefetchPages(pages: List<Int>, bounds: IntRange) = queen.preloadPages(pages, bounds)
override fun onRequest(index: Int, force: Boolean, orgImg: Boolean) = queen.request(index, force, orgImg)
}
}.apply {
val listener = object : OnSpiderListener {
override fun onPageDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) {
if (contentLength > 0) {
notifyPagePercent(index, receivedSize.toFloat() / contentLength)
}
}
override fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int) = notifySourceReady(index)
override fun onPageFailure(index: Int, error: String?, finished: Int, downloaded: Int, total: Int) = notifyPageFailed(index, error)
}
install(
{ queen.addOnSpiderListener(listener) },
{ _, _ -> queen.removeOnSpiderListener(listener) },
)
}
}
override fun stop() {
super.stop()
mSpiderQueen.removeOnSpiderListener(this)
releaseSpiderQueen(mSpiderQueen, SpiderQueen.MODE_READ)
}
override val title by lazy {
EhUtils.getSuitableTitle(mGalleryInfo)
}
override fun getImageExtension(index: Int): String? = mSpiderQueen.getExtension(index)
override fun save(index: Int, file: Path): Boolean = mSpiderQueen.save(index, file)
override val size: Int
get() = mSpiderQueen.size
override fun onRequest(index: Int) {
mSpiderQueen.request(index)
}
override fun onForceRequest(index: Int, orgImg: Boolean) {
mSpiderQueen.forceRequest(index, orgImg)
}
override suspend fun awaitReady(): Boolean = super.awaitReady() && mSpiderQueen.awaitReady()
override val isReady: Boolean
get() = ::mSpiderQueen.isInitialized && mSpiderQueen.isReady
override fun onGetPages(pages: Int) {}
override fun onGet509(index: Int) {}
override fun onPageDownload(
index: Int,
contentLength: Long,
receivedSize: Long,
bytesRead: Int,
) {
if (contentLength > 0) {
notifyPagePercent(index, receivedSize.toFloat() / contentLength)
}
}
override fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int) {}
override fun onPageFailure(
index: Int,
error: String?,
finished: Int,
downloaded: Int,
total: Int,
) {
notifyPageFailed(index, error)
}
override fun onFinish(finished: Int, downloaded: Int, total: Int) {}
override fun onGetImageSuccess(index: Int, image: Image?) {
notifyPageSucceed(index, image!!)
}
override fun onGetImageFailure(index: Int, error: String?) {
notifyPageFailed(index, error)
}
override fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>) {
mSpiderQueen.preloadPages(pages, bounds)
loader.progressJob.join()
block(loader)
}
}
@@ -36,8 +36,12 @@ val PageStatus.progressObserved
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
@JvmInline value class Blocked(val ad: Image) : PageStatus
@JvmInline value class Ready(val image: Image) : PageStatus
@JvmInline value class Error(val message: String?) : PageStatus
@JvmInline value class Loading(val progress: MutableStateFlow<Float>) : PageStatus
}
@@ -0,0 +1,186 @@
package com.hippo.ehviewer.gallery
import androidx.collection.SieveCache
import arrow.atomic.value
import arrow.fx.coroutines.ExitCase
import arrow.fx.coroutines.bracketCase
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.image.ImageSource
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.OSUtils
import com.hippo.ehviewer.util.detectAds
import com.hippo.ehviewer.util.displayString
import com.hippo.ehviewer.util.isAtLeastO
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import moe.tarsin.coroutines.NamedMutex
import moe.tarsin.coroutines.withLock
import okio.Path
private val progressScope = CoroutineScope(Dispatchers.IO)
private const val MAX_CACHE_SIZE = 512 * 1024 * 1024
private const val MIN_CACHE_SIZE = 128 * 1024 * 1024
abstract class PageLoader(val gid: Long, var startPage: Int, val size: Int, val hasAds: Boolean = false, val scope: CoroutineScope) : AutoCloseable {
private val mutex = NamedMutex<Int>()
private val semaphore = Semaphore(4)
private val cache = SieveCache<Int, Image>(
maxSize = if (isAtLeastO) {
(OSUtils.totalMemory / 16).toInt().coerceIn(MIN_CACHE_SIZE, MAX_CACHE_SIZE)
} else {
(OSUtils.appMaxMemory / 3 * 2).toInt()
},
sizeOf = { _, v -> v.allocationSize.toInt() },
onEntryRemoved = { k, o, n, _ ->
if (o.isRecyclable) {
n ?: notifyPageWait(k)
o.recycle()
} else {
o.isRecyclable = true
}
},
)
fun decodePreloadRange(index: Int) = index - 3..index + 3
fun needDecode(index: Int) = index in decodePreloadRange(prevIndex) &&
lock.read { index !in cache } &&
pages[index].status !is PageStatus.Ready &&
pages[index].status !is PageStatus.Blocked
suspend fun atomicallyDecodeAndUpdate(index: Int) {
try {
bracketCase(
{ openSource(index) },
{ src -> notifyPageSucceed(index, Image.decode(src, hasAds && detectAds(index, size))) },
{ src, case -> if (case !is ExitCase.Completed) src.close() },
)
} catch (e: Throwable) {
notifyPageFailed(index, e.displayString())
}
}
private val lock = ReentrantReadWriteLock()
val pages = (0 until size).map { Page(it) }
private val prefetchPageCount = Settings.preloadImage
fun restart() {
lock.write { cache.evictAll() }
pages.forEach(Page::reset)
}
private var prevIndex by AtomicInteger(-1)::value
fun retryPage(index: Int, orgImg: Boolean = false) {
notifyPageWait(index)
lock.write { cache.remove(index) }
onRequest(index, true, orgImg)
}
protected abstract fun prefetchPages(pages: List<Int>, bounds: IntRange)
protected abstract fun onRequest(index: Int, force: Boolean = false, orgImg: Boolean = false)
fun notifyPageWait(index: Int) {
pages[index].reset()
}
fun notifyPagePercent(index: Int, percent: Float) {
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) lock.write { cache[index] = image }
pages[index].statusFlow.update { if (image.hasQrCode) PageStatus.Blocked(image) else PageStatus.Ready(image) }
}
fun notifyPageFailed(index: Int, error: String?) {
pages[index].statusFlow.update { PageStatus.Error(error) }
}
val progressJob = progressScope.launch {
if (startPage == -1) {
startPage = EhDB.getReadProgress(gid)
}
}
override fun close() {
lock.write { cache.evictAll() }
if (gid != 0L) {
progressScope.launch {
EhDB.putReadProgress(gid, startPage)
}
}
}
protected abstract val title: String
protected abstract fun getImageExtension(index: Int): String?
fun getImageFilename(index: Int): String? = getImageExtension(index)?.let {
FileUtils.sanitizeFilename("$title - ${index + 1}.${it.lowercase()}")
}
fun request(index: Int) {
val prefetchRange = if (index >= prevIndex) {
index + 1..(index + prefetchPageCount).coerceAtMost(size - 1)
} else {
index - 1 downTo (index - prefetchPageCount).coerceAtLeast(0)
}
prevIndex = index
val image = lock.read { cache[index] }
if (image != null) {
notifyPageSucceed(index, image, false)
} else {
notifyPageWait(index)
onRequest(index)
}
// Prefetch to disk
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..end + 5)
}
abstract fun save(index: Int, file: Path): Boolean
fun notifySourceReady(index: Int) {
if (needDecode(index)) {
scope.launch {
mutex.withLock(index) {
semaphore.withPermit {
// Double check
if (needDecode(index)) atomicallyDecodeAndUpdate(index)
}
}
}
}
}
abstract fun openSource(index: Int): ImageSource
}
@@ -1,70 +0,0 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.gallery
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.util.FileUtils
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import okio.Path
abstract class PageLoader2(private val gid: Long, var startPage: Int) :
PageLoader(),
CoroutineScope {
override val coroutineContext = Dispatchers.IO + Job()
private val progressJob = async(start = CoroutineStart.LAZY) {
if (startPage == -1) {
startPage = EhDB.getReadProgress(gid)
}
}
override fun start() {
progressJob.start()
}
override suspend fun awaitReady(): Boolean {
progressJob.await()
return super.awaitReady() && startPage != -1
}
override fun stop() {
super.stop()
if (gid != 0L) {
launch {
EhDB.putReadProgress(gid, startPage)
this@PageLoader2.cancel()
}
} else {
cancel()
}
}
protected abstract val title: String
protected abstract fun getImageExtension(index: Int): String?
fun getImageFilename(index: Int): String? = getImageExtension(index)?.let {
FileUtils.sanitizeFilename("$title - ${index + 1}.${it.lowercase()}")
}
abstract fun save(index: Int, file: Path): Boolean
}
@@ -0,0 +1,42 @@
package com.hippo.ehviewer.icons.filled
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Filled.Magnet by unsafeLazy {
materialIcon(name = "Magnet") {
materialPath {
moveTo(3.0F, 7.0F)
verticalLineTo(13.0F)
arcTo(9.0F, 9.0F, 0.0F, false, false, 12.0F, 22.0F)
arcTo(9.0F, 9.0F, 0.0F, false, false, 21.0F, 13.0F)
verticalLineTo(7.0F)
horizontalLineTo(17.0F)
verticalLineTo(13.0F)
arcTo(5.0F, 5.0F, 0.0F, false, true, 12.0F, 18.0F)
arcTo(5.0F, 5.0F, 0.0F, false, true, 7.0F, 13.0F)
verticalLineTo(7.0F)
moveTo(17.0F, 5.0F)
horizontalLineTo(21.0F)
verticalLineTo(2.0F)
horizontalLineTo(17.0F)
moveTo(3.0F, 5.0F)
horizontalLineTo(7.0F)
verticalLineTo(2.0F)
horizontalLineTo(3.0F)
close()
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconMagnetPreview() {
Image(imageVector = EhIcons.Default.Magnet, contentDescription = null)
}
@@ -23,6 +23,8 @@ import androidx.compose.ui.unit.IntSize
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import arrow.fx.coroutines.ExitCase
import arrow.fx.coroutines.bracketCase
import coil3.BitmapImage
import coil3.DrawableImage
import coil3.Image as CoilImage
@@ -47,7 +49,6 @@ import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastU
import com.hippo.files.openFileDescriptor
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.system.logcat
import java.nio.ByteBuffer
import okio.Path
import splitties.init.appCtx
@@ -97,43 +98,34 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
}
}
suspend fun decode(src: ImageSource, checkExtraneousAds: Boolean = false): Image? {
return runCatching {
val image = when (src) {
is PathSource -> {
if (isAtLeastP && !isAtLeastU) {
src.source.openFileDescriptor("rw").use {
val fd = it.fd
if (isGif(fd)) {
val buffer = mmap(fd)!!
val source = object : ByteBufferSource {
override val source = buffer
override fun close() {
munmap(buffer)
src.close()
}
}
return decode(source, checkExtraneousAds)
}
suspend fun decode(src: ImageSource, checkExtraneousAds: Boolean = false): Image {
val image = when (src) {
is PathSource -> {
if (isAtLeastP && !isAtLeastU) {
src.source.openFileDescriptor("rw").use {
val fd = it.fd
if (isGif(fd)) {
return bracketCase(
{ mmap(fd)!! },
{ buffer -> decode(byteBufferSource(buffer) { munmap(buffer).also { src.close() } }, checkExtraneousAds) },
{ buffer, case -> if (case !is ExitCase.Completed) munmap(buffer) },
)
}
}
src.right().decodeCoil(checkExtraneousAds)
}
src.right().decodeCoil(checkExtraneousAds)
}
is ByteBufferSource -> {
if (isAtLeastP && !isAtLeastU) {
rewriteGifSource(src.source)
}
src.left().decodeCoil(checkExtraneousAds)
is ByteBufferSource -> {
if (isAtLeastP && !isAtLeastU) {
rewriteGifSource(src.source)
}
src.left().decodeCoil(checkExtraneousAds)
}
Image(image, src).apply {
if (innerImage is BitmapImage) src.close()
}
}.onFailure {
src.close()
logcat(it)
}.getOrNull()
}
return Image(image, src).apply {
if (innerImage is BitmapImage) src.close()
}
}
}
}
@@ -149,6 +141,11 @@ interface ByteBufferSource : ImageSource {
val source: ByteBuffer
}
inline fun byteBufferSource(buffer: ByteBuffer, crossinline release: () -> Unit) = object : ByteBufferSource {
override val source = buffer
override fun close() = release()
}
external fun detectBorder(bitmap: Bitmap): IntArray
external fun hasQrCode(bitmap: Bitmap): Boolean
external fun copyBitmapToAHB(src: Bitmap, dst: HardwareBuffer, x: Int, y: Int)
@@ -0,0 +1,36 @@
package com.hippo.ehviewer.ktor
import com.hippo.ehviewer.Settings.userAgent
import com.hippo.ehviewer.client.EhCookieStore
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.SaveBodyPlugin
import io.ktor.client.plugins.UserAgent
import io.ktor.client.plugins.cookies.HttpCookies
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
private const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
private const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
fun <T : HttpClientEngineConfig> HttpClientConfig<T>.configureCommon(redirect: Boolean = true) = apply {
install(HttpCookies) {
storage = EhCookieStore
}
install(HttpTimeout) {
requestTimeoutMillis = 10_000
}
install(UserAgent) {
agent = userAgent
}
defaultRequest {
header(HttpHeaders.Accept, CHROME_ACCEPT)
header(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
}
followRedirects = redirect
install(SaveBodyPlugin) {
disabled = true
}
}
@@ -1,9 +1,12 @@
@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
package com.hippo.ehviewer.ktor
import io.ktor.client.engine.HttpClientEngine
import android.os.Build
import androidx.annotation.RequiresExtension
import io.ktor.client.engine.HttpClientEngineFactory
import splitties.init.appCtx
object Cronet : HttpClientEngineFactory<CronetConfig> {
override fun create(block: CronetConfig.() -> Unit): HttpClientEngine =
CronetEngine(CronetConfig().apply(block))
override fun create(block: CronetConfig.() -> Unit) = CronetEngine(CronetConfig(appCtx).apply(block))
}
@@ -1,13 +1,13 @@
@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
package com.hippo.ehviewer.ktor
import android.content.Context
import android.net.http.HttpEngine
import android.os.Build
import androidx.annotation.RequiresExtension
import io.ktor.client.engine.HttpClientEngineConfig
import org.chromium.net.CronetEngine
class CronetConfig : HttpClientEngineConfig() {
var client: CronetEngine? = null
fun config(context: Context, block: CronetEngine.Builder.() -> Unit) {
client = CronetEngine.Builder(context).apply(block).build()
}
class CronetConfig(val context: Context) : HttpClientEngineConfig() {
var config: HttpEngine.Builder.() -> Unit = {}
}
@@ -1,7 +1,19 @@
@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
package com.hippo.ehviewer.ktor
import android.net.http.HttpEngine
import android.net.http.HttpException
import android.net.http.UploadDataProvider
import android.net.http.UploadDataSink
import android.net.http.UrlRequest
import android.net.http.UrlResponseInfo
import android.os.Build
import androidx.annotation.RequiresExtension
import com.hippo.ehviewer.Settings
import io.ktor.client.engine.HttpClientEngineBase
import io.ktor.client.engine.callContext
import io.ktor.client.plugins.HttpTimeoutCapability
import io.ktor.client.request.HttpRequestData
import io.ktor.client.request.HttpResponseData
import io.ktor.http.HttpHeaders
@@ -27,17 +39,18 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.job
import kotlinx.coroutines.suspendCancellableCoroutine
import org.chromium.net.CronetException
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
import org.chromium.net.apihelpers.UploadDataProviders
class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cronet") {
// Limit thread to 1 since we are async & non-blocking
override val dispatcher = Dispatchers.Default.limitedParallelism(1)
override val supportedCapabilities = setOf(HttpTimeoutCapability)
private val executor = dispatcher.asExecutor()
private val pool = DirectByteBufferPool(32)
private val client = config.client ?: error("Cronet client is not configured")
private val client by lazy {
with(config) { HttpEngine.Builder(context).apply(config).build() }
}
@InternalAPI
override suspend fun execute(data: HttpRequestData) = executeHttpRequest(callContext(), data)
@@ -48,7 +61,7 @@ class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cr
) = suspendCancellableCoroutine { continuation ->
val requestTime = GMTDate()
val callback = object : UrlRequest.Callback() {
val callback = object : UrlRequest.Callback {
val chunkChan = Channel<ByteBuffer>()
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) {
continuation.resume(
@@ -87,16 +100,23 @@ class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cr
chunkChan.close()
}
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: HttpException) {
// Cronet may crash on some devices, fuck xiaomi
// https://github.com/FooIbar/EhViewer/issues/1826
if (error.message?.contains("ERR_FILE_NOT_FOUND") == true) {
Settings.enableCronet = false
}
if (continuation.isActive) {
continuation.resumeWithException(error)
} else {
chunkChan.close(error)
}
}
override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) = Unit
}
client.newUrlRequestBuilder(data.url.toString(), callback, executor).apply {
client.newUrlRequestBuilder(data.url.toString(), executor, callback).apply {
setHttpMethod(data.method.value)
data.headers.flattenForEach { key, value -> addHeader(key, value) }
data.body.contentType?.let { addHeader(HttpHeaders.ContentType, it.toString()) }
@@ -117,7 +137,7 @@ private fun UrlResponseInfo.toHttpResponseData(
statusCode = HttpStatusCode.fromValue(httpStatusCode),
requestTime = requestTime,
headers = headers {
allHeaders.forEach { (key, value) ->
headers.asMap.forEach { (key, value) ->
appendAll(key, value)
}
},
@@ -133,6 +153,17 @@ private fun UrlResponseInfo.toHttpResponseData(
private fun OutgoingContent.toUploadDataProvider() = when (this) {
is OutgoingContent.NoContent -> null
is OutgoingContent.ByteArrayContent -> UploadDataProviders.create(bytes())
is OutgoingContent.ByteArrayContent -> object : UploadDataProvider() {
val buffer = ByteBuffer.wrap(bytes()).slice()
override fun getLength() = buffer.limit().toLong()
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
byteBuffer.put(buffer)
uploadDataSink.onReadSucceeded(false)
}
override fun rewind(uploadDataSink: UploadDataSink) {
buffer.position(0)
uploadDataSink.onRewindSucceeded()
}
}
else -> error("UnsupportedContentType $this")
}
@@ -0,0 +1,23 @@
@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
package com.hippo.ehviewer.ktor
import android.net.http.HttpEngine
import android.os.Build
import androidx.annotation.RequiresExtension
import java.io.File
fun CronetConfig.configureClient() {
config = {
setEnableBrotli(true)
// Cache Quic hint only since the real cache mechanism should on Ktor layer
val cache = File(context.cacheDir, "http_cache").apply { mkdirs() }
setStoragePath(cache.path)
setEnableHttpCache(HttpEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096)
addQuicHint("e-hentai.org", 443, 443)
addQuicHint("forums.e-hentai.org", 443, 443)
addQuicHint("exhentai.org", 443, 443)
}
}
@@ -0,0 +1,16 @@
package com.hippo.ehviewer.ktor
import com.hippo.ehviewer.util.isAtLeastQ
import io.ktor.client.engine.okhttp.OkHttpConfig
import okhttp3.AsyncDns
import okhttp3.ExperimentalOkHttpApi
import okhttp3.android.AndroidAsyncDns
@OptIn(ExperimentalOkHttpApi::class)
fun OkHttpConfig.configureClient() {
config {
if (isAtLeastQ) {
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
}
}
}
@@ -1,22 +0,0 @@
package com.hippo.ehviewer.legacy
import java.io.File
import splitties.init.appCtx
private val OBSOLETE_CACHE_DIRS = arrayOf(
"image",
"thumb",
"gallery_image",
"spider_info",
)
fun cleanObsoleteCache() {
appCtx.deleteDatabase("hosts.db")
val dir = appCtx.cacheDir
for (subdir in OBSOLETE_CACHE_DIRS) {
val file = File(dir, subdir)
if (file.exists()) {
file.deleteRecursively()
}
}
}
@@ -5,6 +5,15 @@ import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.SimpleTagsConverter
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.client.data.TagNamespace.Artist
import com.hippo.ehviewer.client.data.TagNamespace.Character
import com.hippo.ehviewer.client.data.TagNamespace.Cosplayer
import com.hippo.ehviewer.client.data.TagNamespace.Female
import com.hippo.ehviewer.client.data.TagNamespace.Group
import com.hippo.ehviewer.client.data.TagNamespace.Male
import com.hippo.ehviewer.client.data.TagNamespace.Mixed
import com.hippo.ehviewer.client.data.TagNamespace.Other
import com.hippo.ehviewer.client.data.TagNamespace.Parody
import com.hippo.files.openInputStream
import com.hippo.files.openOutputStream
import kotlinx.serialization.KSerializer
@@ -42,18 +51,17 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
val otherTags = mutableListOf<String>()
with(TagNamespace) {
when (this@getComicInfo) {
is GalleryDetail -> tags.forEach { tagList ->
val list = tagList.filterNot { it == TAG_ORIGINAL || it.startsWith('_') }
when (val ns = TagNamespace(tagList.groupName)) {
is GalleryDetail -> tagGroups.forEach { group ->
val list = group.tags.filterNot { it == TAG_ORIGINAL || it.startsWith('_') }
when (val ns = group.nameSpace) {
Artist, Cosplayer -> artists.addAll(list)
Group -> groups.addAll(list)
Character -> characters.addAll(list)
Parody -> parodies.addAll(list)
Other -> otherTags.addAll(list)
Female, Male, Mixed -> ns.toPrefix()?.let { prefix ->
Female, Male, Mixed -> ns.prefix.let { prefix ->
list.forEach { tag -> otherTags.add("$prefix:$tag") }
}
else -> Unit
}
}
@@ -61,13 +69,13 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
else -> simpleTags?.forEach { tagString ->
val (namespace, tag) = tagString.split(':', limit = 2)
.takeIf { it.size == 2 } ?: return@forEach // Ignore temp tags that don't have namespace
when (val ns = TagNamespace(namespace)) {
when (val ns = from(namespace)) {
Artist, Cosplayer -> artists.add(tag)
Group -> groups.add(tag)
Character -> characters.add(tag)
Parody -> if (tag != TAG_ORIGINAL) parodies.add(tag)
Other -> otherTags.add(tag)
Female, Male, Mixed -> ns.toPrefix()?.let { otherTags.add("$it:$tag") }
Female, Male, Mixed -> ns.prefix.let { otherTags.add("$it:$tag") }
else -> Unit
}
}
@@ -1,27 +0,0 @@
package com.hippo.ehviewer.spider
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.thumbUrl
import com.hippo.ehviewer.dao.DownloadInfo
object DownloadInfoMagics {
private const val DOWNLOAD_INFO_DIRNAME_URL_MAGIC = "$"
private const val DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR = "|"
fun encodeMagicRequestOrUrl(info: GalleryInfo): String {
val url = info.thumbUrl
val location = (info as? DownloadInfo)?.dirname
return if (location.isNullOrBlank()) {
url
} else {
DOWNLOAD_INFO_DIRNAME_URL_MAGIC + url + DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR + location
}
}
fun decodeMagicRequestOrUrl(encoded: String): Pair<String, String?> = if (encoded.startsWith(DOWNLOAD_INFO_DIRNAME_URL_MAGIC)) {
val (a, b) = encoded.removePrefix(DOWNLOAD_INFO_DIRNAME_URL_MAGIC).split(DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR)
a to b
} else {
encoded to null
}
}
@@ -52,12 +52,15 @@ import com.hippo.files.openFileDescriptor
import com.hippo.files.openOutputStream
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.plugins.onDownload
import io.ktor.client.plugins.timeout
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.request
import io.ktor.http.isSuccess
import io.ktor.utils.io.copyTo
import kotlin.io.path.readText
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
import kotlinx.coroutines.CancellationException
import okio.Path
@@ -69,11 +72,13 @@ class SpiderDen(val info: GalleryInfo) {
private var tempDownloadDir: Path? = null
private val saveAsCbz = Settings.saveAsCbz
private val archiveName = "$gid.cbz"
private val downloadTimeout = Settings.downloadTimeout * 1000L
private val lock = ReentrantReadWriteLock()
// Search in both directories to maintain compatibility
private val fileCache by lazy {
(tempDownloadDir?.list().orEmpty() + downloadDir?.list().orEmpty())
.associateBy { it.name } as MutableMap
listOfNotNull(tempDownloadDir, downloadDir).map(Path::list).flatten().associateBy { it.name } as MutableMap
}
private val imageDir
@@ -102,14 +107,12 @@ class SpiderDen(val info: GalleryInfo) {
private fun containInCache(index: Int): Boolean {
val key = getImageKey(gid, index)
return sCache.read(key) { true } ?: false
return sCache.read(key) {} != null
}
private fun findImageFile(index: Int, temp: Boolean = false) = synchronized(fileCache) {
private fun findImageFile(index: Int, temp: Boolean = false) = lock.read {
val head = perFilename(index)
fileCache.entries.firstOrNull { (name) ->
name.startsWith(head) && temp == name.endsWith(TEMP_SUFFIX)
}?.value
fileCache.entries.firstOrNull { (name) -> name.startsWith(head) && temp == name.endsWith(TEMP_SUFFIX) }?.value
}
private fun containInDownloadDir(index: Int): Boolean = findImageFile(index) != null
@@ -122,8 +125,7 @@ class SpiderDen(val info: GalleryInfo) {
val extension = metadata.toFile().readText()
val file = dir.findDownloadFileForIndex(index, extension)
data sendTo file
true
} ?: false
} != null
}.onFailure {
logcat(it)
}.getOrDefault(false)
@@ -148,11 +150,9 @@ class SpiderDen(val info: GalleryInfo) {
return sCache.remove(key)
}
private fun removeTempFile(index: Int) = findImageFile(index, temp = true)?.run {
delete()
synchronized(fileCache) {
fileCache.remove(name)
}
private fun removeTempFile(index: Int) = findImageFile(index, temp = true)?.let { file ->
file.delete()
lock.write { fileCache.remove(file.name) }
}
fun removeIntermediateFiles(index: Int) {
@@ -160,11 +160,9 @@ class SpiderDen(val info: GalleryInfo) {
removeTempFile(index)
}
private fun Path.findDownloadFileForIndex(index: Int, extension: String): Path {
private fun Path.findDownloadFileForIndex(index: Int, extension: String) = with(lock) {
val name = perFilename(index, extension)
return synchronized(fileCache) {
fileCache.getOrPut(name) { resolve(name) }
}
read { fileCache[name] } ?: resolve(name).also { write { fileCache[name] = it } }
}
suspend fun makeHttpCallAndSaveImage(
@@ -178,6 +176,9 @@ class SpiderDen(val info: GalleryInfo) {
notifyProgress(total!!, done, (done - prev).toInt())
prev = done
}
timeout {
requestTimeoutMillis = downloadTimeout
}
}.executeSafely {
if (it.status.isSuccess()) {
saveFromHttpResponse(index, it)
@@ -196,8 +197,9 @@ class SpiderDen(val info: GalleryInfo) {
fops(tempFile)
val file = resolve(tempFile.name.removeSuffix(TEMP_SUFFIX))
file.delete()
lock.write { fileCache.remove(file.name) }
tempFile.moveTo(file)
synchronized(fileCache) {
lock.write {
fileCache.remove(tempFile.name)
fileCache[file.name] = file
}
@@ -222,9 +224,11 @@ class SpiderDen(val info: GalleryInfo) {
outFile.openOutputStream().use {
response.bodyAsChannel().copyTo(it.channel)
}
val expected = FileHashRegex.findAll(url).last().groupValues[1]
val actual = outFile.sha1()
check(expected == actual) { "File hash mismatch: expected $expected, but got $actual\nURL: $url" }
FileHashRegex.find(url)?.let {
val expected = it.groupValues[1]
val actual = outFile.sha1()
check(expected == actual) { "File hash mismatch: expected $expected, but got $actual\nURL: $url" }
}
}
}
@@ -256,11 +260,11 @@ class SpiderDen(val info: GalleryInfo) {
fun getExtension(index: Int): String? {
val key = getImageKey(gid, index)
return sCache.read(key) { metadata.toNioPath().readText() }
return sCache.read(key) { metadata.toFile().readText() }
?: findImageFile(index)?.name.let { FileUtils.getExtensionFromFilename(it) }
}
fun getImageSource(index: Int): PathSource? {
fun getImageSource(index: Int): PathSource {
if (mode == SpiderQueen.MODE_READ) {
val key = getImageKey(gid, index)
val snapshot = sCache.openSnapshot(key)
@@ -268,12 +272,12 @@ class SpiderDen(val info: GalleryInfo) {
return object : PathSource, AutoCloseable by snapshot {
override val source = snapshot.data
override val type by lazy {
snapshot.metadata.toNioPath().readText()
snapshot.metadata.toFile().readText()
}
}
}
}
val source = findImageFile(index) ?: return null
val source = requireNotNull(findImageFile(index)) { "Source $index not found!" }
return object : PathSource {
override val source = source
override val type by lazy {
@@ -315,8 +319,7 @@ class SpiderDen(val info: GalleryInfo) {
return archived
}
suspend fun exportAsCbz(file: Path) =
downloadDir!!.find(archiveName)?.sendTo(file) ?: archiveTo(file)
suspend fun exportAsCbz(file: Path) = downloadDir!!.find(archiveName)?.sendTo(file) ?: archiveTo(file)
private suspend fun archiveTo(file: Path) = resourceScope {
val comicInfo = closeable {
@@ -330,7 +333,7 @@ class SpiderDen(val info: GalleryInfo) {
}
val pages = info.pages
val (fdBatch, names) = (0 until pages).parMap { idx ->
val f = autoCloseable { getImageSource(idx) ?: throw CancellationException("Image #$idx not found") }
val f = autoCloseable { getImageSource(idx) }
closeable { f.source.openFileDescriptor("r") }.fd to perFilename(idx, f.type)
}.run { plus(comicInfo.fd to COMIC_INFO_FILE) }.unzip()
val arcFd = closeable { file.openFileDescriptor("rw") }
@@ -370,7 +373,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+")
private val FileHashRegex = Regex("/h/([0-9a-f]{40})")
fun perFilename(index: Int, extension: String = ""): String = "%08d.%s".format(index + 1, extension)
@@ -17,8 +17,6 @@
package com.hippo.ehviewer.spider
import androidx.annotation.IntDef
import androidx.collection.LongSparseArray
import androidx.collection.set
import arrow.core.partially1
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
@@ -28,7 +26,6 @@ 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
@@ -37,31 +34,26 @@ import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePreviewList
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePreviewPages
import com.hippo.ehviewer.client.parser.GalleryMultiPageViewerPTokenParser
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.util.displayString
import com.hippo.files.find
import eu.kanade.tachiyomi.util.system.logcat
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeout
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path
import splitties.init.appCtx
@@ -70,10 +62,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
override val coroutineContext = Dispatchers.IO + Job()
@Volatile
lateinit var mPageStateArray: IntArray
lateinit var mSpiderInfo: SpiderInfo
lateinit var pageStates: IntArray
lateinit var spiderInfo: SpiderInfo
val mSpiderDen: SpiderDen = SpiderDen(galleryInfo)
val spiderDen: SpiderDen = SpiderDen(galleryInfo)
private val mPageStateLock = Any()
private val mDownloadedPages = AtomicInteger(0)
private val mFinishedPages = AtomicInteger(0)
@@ -122,7 +114,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
index,
mFinishedPages.get(),
mDownloadedPages.get(),
mPageStateArray.size,
pageStates.size,
)
}
}
@@ -136,7 +128,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
error,
mFinishedPages.get(),
mDownloadedPages.get(),
mPageStateArray.size,
pageStates.size,
)
}
}
@@ -148,50 +140,34 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
it.onFinish(
mFinishedPages.get(),
mDownloadedPages.get(),
mPageStateArray.size,
pageStates.size,
)
}
}
}
fun notifyGetImageSuccess(index: Int, image: Image) {
synchronized(mSpiderListeners) {
mSpiderListeners.forEach {
it.onGetImageSuccess(index, image)
}
}
}
fun notifyGetImageFailure(index: Int, error: String) {
synchronized(mSpiderListeners) {
mSpiderListeners.forEach {
it.onGetImageFailure(index, error)
}
}
}
private var downloadMode = false
val isReady
get() = this::mSpiderInfo.isInitialized && this::mPageStateArray.isInitialized
get() = this::spiderInfo.isInitialized && this::pageStates.isInitialized
private val updateLock = Mutex()
private suspend fun updateMode() {
if (!awaitReady()) return
private suspend fun updateMode() = runSuspendCatching {
awaitReady()
updateLock.withLock {
val mode: Int = if (mDownloadReference > 0) {
MODE_DOWNLOAD
} else {
MODE_READ
}
mSpiderDen.setMode(mode)
spiderDen.setMode(mode)
// Update download page
val intoDownloadMode = mode == MODE_DOWNLOAD
if (intoDownloadMode && !downloadMode) {
// Clear download state
synchronized(mPageStateLock) {
val temp: IntArray = mPageStateArray
val temp: IntArray = pageStates
var i = 0
val n = temp.size
while (i < n) {
@@ -226,20 +202,19 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
check(!(mReadReference < 0 || mDownloadReference < 0)) { "Mode reference < 0" }
}
private val prepareJob = async { doPrepare() }
private val archiveJob = launch(start = CoroutineStart.LAZY) { mSpiderDen.archive() }
private val prepareScope = CoroutineScope(coroutineContext + SupervisorJob())
private val prepareJob = prepareScope.async { doPrepare() }
private val archiveJob = launch(start = CoroutineStart.LAZY) { spiderDen.archive() }
private suspend fun doPrepare() {
mSpiderDen.initDownloadDirIfExist()
mSpiderInfo = readSpiderInfoFromLocal() ?: readSpiderInfoFromInternet() ?: return
mPageStateArray = IntArray(mSpiderInfo.pages)
notifyGetPages(mSpiderInfo.pages)
spiderDen.initDownloadDirIfExist()
spiderInfo = readSpiderInfoFromLocal() ?: readSpiderInfoFromInternet()
pageStates = IntArray(spiderInfo.pages)
check(spiderInfo.pages > 0)
notifyGetPages(spiderInfo.pages)
}
suspend fun awaitReady(): Boolean {
prepareJob.await()
return isReady
}
suspend fun awaitReady() = prepareJob.await()
private fun stop() {
val queenScope = this
@@ -247,9 +222,9 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
if (archiveJob.isActive) {
archiveJob.join()
}
if (!mSpiderDen.postArchive()) {
if (!spiderDen.postArchive()) {
if (mWorkerScope.isDownloadMode) {
mSpiderDen.writeComicInfo()
spiderDen.writeComicInfo()
}
runCatching {
writeSpiderInfoToLocal()
@@ -262,34 +237,26 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
val size
get() = mPageStateArray.size
get() = pageStates.size
val error: String?
get() = null
fun forceRequest(index: Int, orgImg: Boolean) {
request(index, true, orgImg)
}
fun request(index: Int) {
request(index, false)
}
private fun getPageState(index: Int): Int {
synchronized(mPageStateLock) {
return if (index >= 0 && index < mPageStateArray.size) {
mPageStateArray[index]
return if (index >= 0 && index < pageStates.size) {
pageStates[index]
} else {
STATE_NONE
}
}
}
fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {
fun preloadPages(pages: List<Int>, pair: IntRange) {
mWorkerScope.updateRAList(pages, pair)
}
private fun request(index: Int, force: Boolean, orgImg: Boolean = false) {
fun request(index: Int, force: Boolean, orgImg: Boolean = false) {
// Get page state
val state = getPageState(index)
@@ -306,7 +273,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
return if (STATE_FINISHED != state) {
false
} else {
mSpiderDen.saveToPath(index, file)
spiderDen.saveToPath(index, file)
}
}
@@ -315,11 +282,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
return if (STATE_FINISHED != state) {
null
} else {
mSpiderDen.getExtension(index)
spiderDen.getExtension(index)
}
}
private fun readSpiderInfoFromLocal(): SpiderInfo? = mSpiderDen.downloadDir?.run {
private fun readSpiderInfoFromLocal(): SpiderInfo? = spiderDen.downloadDir?.run {
find(SPIDER_INFO_FILENAME)?.let { file ->
readCompatFromPath(file)?.takeIf {
it.gid == galleryInfo.gid && it.token == galleryInfo.token
@@ -346,22 +313,18 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}
private suspend fun readSpiderInfoFromInternet(): SpiderInfo? = runSuspendCatching {
ehRequest(
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
referer,
).fetchUsingAsText {
val pages = parsePages(this)
val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, pages)
readPreviews(this, 0, spiderInfo)
spiderInfo
}
}.onFailure {
logcat(it)
}.getOrNull()
private suspend fun readSpiderInfoFromInternet() = ehRequest(
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
referer,
).fetchUsingAsText {
val pages = parsePages(this)
val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, pages)
readPreviews(this, 0, spiderInfo)
spiderInfo
}
suspend fun getPTokenFromMultiPageViewer(index: Int): String? {
val spiderInfo = mSpiderInfo
val spiderInfo = spiderInfo
val url = getGalleryMultiPageViewerUrl(
galleryInfo.gid,
galleryInfo.token,
@@ -380,7 +343,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
suspend fun getPTokenFromInternet(index: Int): String? {
val spiderInfo = mSpiderInfo
val spiderInfo = spiderInfo
// Check previewIndex
var previewIndex: Int
@@ -412,8 +375,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
@Synchronized
private fun writeSpiderInfoToLocal() {
if (!isReady) return
mSpiderDen.downloadDir?.run { mSpiderInfo.write(resolve(SPIDER_INFO_FILENAME)) }
mSpiderInfo.saveToCache()
spiderDen.downloadDir?.run { spiderInfo.write(resolve(SPIDER_INFO_FILENAME)) }
spiderInfo.saveToCache()
}
private fun isStateDone(state: Int): Boolean = state == STATE_FINISHED || state == STATE_FAILED
@@ -421,8 +384,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
fun updatePageState(index: Int, @State state: Int, error: String? = null) {
var oldState: Int
synchronized<Unit>(mPageStateLock) {
oldState = mPageStateArray[index]
mPageStateArray[index] = state
oldState = pageStates[index]
pageStates[index] = state
if (!isStateDone(oldState) && isStateDone(state)) {
mDownloadedPages.incrementAndGet()
} else if (isStateDone(oldState) && !isStateDone(state)) {
@@ -455,14 +418,12 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
@Retention(AnnotationRetention.SOURCE)
annotation class State
interface OnSpiderListener {
fun onGetPages(pages: Int)
fun onGet509(index: Int)
fun onPageDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int)
fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int)
fun onPageFailure(index: Int, error: String?, finished: Int, downloaded: Int, total: Int)
fun onFinish(finished: Int, downloaded: Int, total: Int)
fun onGetImageSuccess(index: Int, image: Image?)
fun onGetImageFailure(index: Int, error: String?)
fun onGetPages(pages: Int) {}
fun onGet509(index: Int) {}
fun onPageDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) {}
fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int) {}
fun onPageFailure(index: Int, error: String?, finished: Int, downloaded: Int, total: Int) {}
fun onFinish(finished: Int, downloaded: Int, total: Int) {}
}
companion object {
@@ -473,11 +434,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
const val STATE_FINISHED = 2
const val STATE_FAILED = 3
const val SPIDER_INFO_FILENAME = ".ehviewer"
private val sQueenMap = LongSparseArray<SpiderQueen>()
private val sQueenMap = mutableMapOf<Long, SpiderQueen>()
fun obtainSpiderQueen(galleryInfo: GalleryInfo, @Mode mode: Int): SpiderQueen {
val gid = galleryInfo.gid
return (sQueenMap[gid] ?: SpiderQueen(galleryInfo).also { sQueenMap[gid] = it }).apply {
return (sQueenMap.getOrPut(gid) { SpiderQueen(galleryInfo) }).apply {
setMode(mode)
launch { updateMode() }
}
@@ -497,13 +458,12 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
private val mWorkerScope = object {
private val mFetcherJobMap = hashMapOf<Int, Job>()
private val mSemaphore = Semaphore(Settings.multiThreadDownload)
private val jobs = hashMapOf<Int, Job>()
private val semaphore = Semaphore(Settings.multiThreadDownload)
private val pTokenLock = Mutex()
private var showKey: String? = null
private val showKeyLock = Mutex()
private val mDownloadDelay = Settings.downloadDelay.milliseconds
private val downloadTimeout = Settings.downloadTimeout.seconds
private val downloadDelay = Settings.downloadDelay.milliseconds
private var lastRequestTime = TimeSource.Monotonic.markNow()
var isDownloadMode = false
private set
@@ -515,16 +475,21 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
isDownloadMode = true
}
fun updateRAList(list: List<Int>, cancelBounds: Pair<Int, Int> = 0 to Int.MAX_VALUE) {
fun updateRAList(list: List<Int>, aliveBound: IntRange = 0..Int.MAX_VALUE) {
val absent = list.filterNot { index ->
(pageStates[index] == STATE_FINISHED).also { finished ->
if (finished) notifyPageSuccess(index)
}
}
if (isDownloadMode) return
synchronized(mFetcherJobMap) {
mFetcherJobMap.forEach { (i, job) ->
if (i < cancelBounds.first || i > cancelBounds.second) {
synchronized(jobs) {
jobs.forEach { (i, job) ->
if (i !in aliveBound) {
job.cancel()
}
}
list.forEach {
if (mFetcherJobMap[it]?.isActive != true) {
absent.forEach {
if (jobs[it]?.isActive != true) {
doLaunchDownloadJob(it, false)
}
}
@@ -532,15 +497,13 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
private fun doLaunchDownloadJob(index: Int, force: Boolean, orgImg: Boolean = false) {
val state = mPageStateArray[index]
if (!force && state == STATE_FINISHED) return
val currentJob = mFetcherJobMap[index]
val currentJob = jobs[index]
val skipHath = force && !orgImg && currentJob?.isActive == true
if (force) currentJob?.cancel(CancellationException(FORCE_RETRY))
if (currentJob?.isActive != true) {
mFetcherJobMap[index] = launch {
jobs[index] = launch {
runCatching {
mSemaphore.withPermit {
semaphore.withPermit {
doInJob(index, force, orgImg, skipHath)
}
}.onFailure {
@@ -561,17 +524,17 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
fun launch(index: Int, force: Boolean = false, orgImg: Boolean) {
check(index in 0 until size)
val state = pageStates[index]
if (!force && state == STATE_FINISHED) return notifyPageSuccess(index)
if (!isDownloadMode) {
synchronized(mFetcherJobMap) { doLaunchDownloadJob(index, force, orgImg) }
synchronized(jobs) { doLaunchDownloadJob(index, force, orgImg) }
}
if (force) decoder.cancel(index)
decoder.launch(index)
}
private suspend fun doInJob(index: Int, force: Boolean, orgImg: Boolean, skipHath: Boolean) {
suspend fun getPToken(index: Int): String? {
if (index !in 0 until size) return null
return mSpiderInfo.pTokenMap[index].takeIf { it != TOKEN_FAILED }
return spiderInfo.pTokenMap[index].takeIf { it != TOKEN_FAILED }
?: getPTokenFromInternet(index)
?: getPTokenFromInternet(index)
?: getPTokenFromMultiPageViewer(index)
@@ -580,20 +543,20 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
val pToken: String
pTokenLock.withLock {
if (force) {
if (mSpiderInfo.pTokenMap[index] == TOKEN_FAILED) {
mSpiderInfo.pTokenMap.remove(index)
if (spiderInfo.pTokenMap[index] == TOKEN_FAILED) {
spiderInfo.pTokenMap.remove(index)
}
} else if (index in mSpiderDen) {
} else if (index in spiderDen) {
return updatePageState(index, STATE_FINISHED)
}
pToken = getPToken(index)
?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE).also {
mSpiderInfo.pTokenMap[index] = TOKEN_FAILED
spiderInfo.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())
delay(downloadDelay - lastRequestTime.elapsedNow())
lastRequestTime = TimeSource.Monotonic.markNow()
}
updatePageState(index, STATE_DOWNLOADING)
@@ -628,7 +591,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
if (imageUrl == null) {
runSuspendCatching {
EhEngine.getGalleryPageApi(
mSpiderInfo.gid,
spiderInfo.gid,
index,
pToken,
localShowKey,
@@ -650,48 +613,38 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
return@repeat
}
val targetImageUrl: String?
val referer: String?
if (original && originImageUrl != null) {
val (targetImageUrl, referer) = if (original && originImageUrl != null) {
if (retries == 1 && skipHathKey != null) {
originImageUrl += "?nl=$skipHathKey"
}
val pageUrl = EhUrl.getPageUrl(mSpiderInfo.gid, index, pToken)
targetImageUrl = EhEngine.getOriginalImageUrl(originImageUrl!!, pageUrl)
referer = EhUrl.referer
val pageUrl = EhUrl.getPageUrl(spiderInfo.gid, index, pToken)
EhEngine.getOriginalImageUrl(originImageUrl!!, pageUrl) to EhUrl.referer
} else {
// Original image url won't change, so only set forceHtml in this case
forceHtml = true
targetImageUrl = imageUrl
referer = null
imageUrl to null
}
checkNotNull(targetImageUrl)
repeat(2) { times ->
runCatching {
logcat(WORKER_DEBUG_TAG) { "Start download image $index attempt #$times" }
val success = withTimeout(downloadTimeout) {
mSpiderDen.makeHttpCallAndSaveImage(
index,
targetImageUrl,
referer,
this@SpiderQueen::notifyPageDownload.partially1(index),
)
}
val success = spiderDen.makeHttpCallAndSaveImage(
index,
targetImageUrl,
referer,
this@SpiderQueen::notifyPageDownload.partially1(index),
)
check(success)
logcat(WORKER_DEBUG_TAG) { "Download image $index succeed" }
updatePageState(index, STATE_FINISHED)
return
}.onFailure {
mSpiderDen.removeIntermediateFiles(index)
spiderDen.removeIntermediateFiles(index)
logcat(WORKER_DEBUG_TAG) { "Download image $index attempt #$times failed" }
error = when (it) {
is TimeoutCancellationException -> ERROR_TIMEOUT
is CancellationException -> throw it
else -> it.displayString()
}
if (it is CancellationException) throw it
error = it.displayString()
}
}
}
@@ -704,65 +657,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
updatePageState(index, STATE_FAILED, error)
}
private val decoder = object {
private val mSemaphore = Semaphore(4)
private val mDecodeJobMap = hashMapOf<Int, Job>()
fun cancel(index: Int) {
synchronized(mDecodeJobMap) {
mDecodeJobMap.remove(index)?.cancel()
}
}
fun launch(index: Int) {
synchronized(mDecodeJobMap) {
val currentJob = mDecodeJobMap[index]
if (currentJob?.isActive != true) {
mDecodeJobMap[index] = launch {
runCatching {
mSemaphore.withPermit {
mFetcherJobMap[index]?.join()
if (mPageStateArray[index] == STATE_FINISHED) {
doInJob(index)
}
}
}.onFailure {
if (mPageStateArray[index] == STATE_FINISHED) {
notifyGetImageFailure(index, DECODE_ERROR)
}
if (it is CancellationException) {
throw it
}
}
}
}
}
}
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, hasAds && Settings.stripExtraneousAds.value && mayBeAd(index))
checkNotNull(image)
runCatching {
currentCoroutineContext().ensureActive()
}.onFailure {
image.recycle()
throw it
}
notifyGetImageSuccess(index, image)
}
}
}
}
private val PTOKEN_FAILED_MESSAGE = appCtx.getString(R.string.error_get_ptoken_error)
private val ERROR_TIMEOUT = appCtx.getString(R.string.error_timeout)
private val DECODE_ERROR = appCtx.getString(R.string.error_decoding_failed)
private val URL_509_PATTERN = Regex("\\.org/.+/509s?\\.gif")
private const val FORCE_RETRY = "Force retry"
private const val WORKER_DEBUG_TAG = "SpiderQueenWorker"
@@ -106,8 +106,7 @@ suspend fun keepNoMediaFileStatus(downloadDir: Path = downloadLocation) {
}
}
fun getFavoriteIcon(favorited: Boolean) =
if (favorited) Icons.Default.Favorite else Icons.Default.FavoriteBorder
fun getFavoriteIcon(favorited: Boolean) = if (favorited) Icons.Default.Favorite else Icons.Default.FavoriteBorder
suspend fun DialogState.startDownload(
context: Context,
@@ -281,14 +280,11 @@ suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorite
localFavorited = EhDB.containLocalFavorites(galleryInfo.gid),
)
fun DestinationsNavigator.navToReader(info: BaseGalleryInfo, page: Int = -1) =
navToReader(ReaderScreenArgs.Gallery(info, page))
fun DestinationsNavigator.navToReader(info: BaseGalleryInfo, page: Int = -1) = navToReader(ReaderScreenArgs.Gallery(info, page))
fun DestinationsNavigator.navToReader(uri: Uri) =
navToReader(ReaderScreenArgs.Archive(uri))
fun DestinationsNavigator.navToReader(uri: Uri) = navToReader(ReaderScreenArgs.Archive(uri))
private fun DestinationsNavigator.navToReader(args: ReaderScreenArgs) =
navigate(ReaderScreenDestination(args)) { launchSingleTop = true }
private fun DestinationsNavigator.navToReader(args: ReaderScreenArgs) = navigate(ReaderScreenDestination(args)) { launchSingleTop = true }
context(DialogState, Context, DestinationsNavigator)
suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
@@ -376,6 +376,7 @@ class MainActivity : EhActivity() {
val drawerEnabled = drawerHandle.isNotEmpty()
val density = LocalDensity.current
val adaptiveInfo = currentWindowAdaptiveInfo()
val needSignIn by Settings.needSignIn.collectAsState()
CompositionLocalProvider(
LocalNavDrawerState provides navDrawerState,
LocalSideSheetState provides sideSheetState,
@@ -473,7 +474,7 @@ class MainActivity : EhActivity() {
// SharedTransitionLayout {
CompositionLocalProvider(LocalSharedTransitionScope provides NoopSharedTransitionScope) {
val start = when {
Settings.needSignIn -> SignInScreenDestination
needSignIn -> SignInScreenDestination
hasNetwork -> StartDestination
else -> DownloadsScreenDestination
}
@@ -94,8 +94,7 @@ class SecurityActivity : AppCompatActivity() {
result: BiometricPrompt.AuthenticationResult,
) = onAuthenticationSucceeded(activity, result)
override fun onAuthenticationFailed(activity: FragmentActivity?) =
onAuthenticationFailed(activity)
override fun onAuthenticationFailed(activity: FragmentActivity?) = onAuthenticationFailed(activity)
}
private fun onSuccess() {
@@ -22,10 +22,8 @@ import soup.compose.material.motion.animation.rememberSlideDistance
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
EhTheme(useDarkTheme = isSystemInDarkTheme()) {
ProvideVectorPainterCache {
val dialogState = remember { DialogState() }
dialogState.Intercept()
CompositionLocalProvider(
LocalDialogState provides dialogState,
LocalDialogState provides remember { DialogState() }.apply { Intercept() },
) {
content()
}
@@ -36,11 +36,7 @@ fun Context.openBrowser(url: String) {
}
}
context(Context, DestinationsNavigator)
fun Context.jumpToReaderByPage(
url: String,
detail: GalleryDetail,
): Boolean {
fun DestinationsNavigator.jumpToReaderByPage(url: String, detail: GalleryDetail): Boolean {
GalleryPageUrlParser.parse(url)?.let {
if (it.gid == detail.gid) {
navToReader(detail.galleryInfo, it.page)
@@ -1,33 +0,0 @@
package com.hippo.ehviewer.ui.legacy
import android.graphics.drawable.Animatable
import android.graphics.drawable.DrawableWrapper
import android.text.Html
import android.widget.TextView
import coil3.asDrawable
import coil3.imageLoader
import coil3.request.crossfade
import coil3.size.Size
import com.hippo.ehviewer.ktbuilder.imageRequest
class CoilImageGetter(
private val textView: TextView,
) : Html.ImageGetter {
override fun getDrawable(source: String) = object : DrawableWrapper(null) {}.apply {
with(textView.context) {
imageLoader.enqueue(
imageRequest {
data(source)
crossfade(false)
size(Size.ORIGINAL)
target { drawable ->
setDrawable(drawable.asDrawable(resources))
if (drawable is Animatable) drawable.start()
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
textView.text = textView.text
}
},
)
}
}
}
@@ -1,58 +0,0 @@
/*
* Copyright 2015 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.ui.legacy
import android.content.Context
import android.text.Spanned
import android.text.style.ClickableSpan
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.appcompat.widget.AppCompatTextView
class LinkifyTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
private val onCurrentSpanUpdate: (ClickableSpan?) -> Unit,
) : AppCompatTextView(context, attrs) {
override fun onTouchEvent(event: MotionEvent): Boolean {
// Let the parent or grandparent of TextView to handles click aciton.
// Otherwise click effect like ripple will not work, and if touch area
// do not contain a url, the TextView will still get MotionEvent.
// onTouchEven must be called with MotionEvent.ACTION_DOWN for each touch
// action on it, so we analyze touched url here.
if (event.action == MotionEvent.ACTION_DOWN) {
onCurrentSpanUpdate(null)
if (text is Spanned) {
var x = event.x.toInt()
var y = event.y.toInt()
x -= totalPaddingLeft
y -= totalPaddingTop
x += scrollX
y += scrollY
val layout = layout
if (null != layout) {
val line = layout.getLineForVertical(y)
val off = layout.getOffsetForHorizontal(line, x.toFloat())
val spans = (text as Spanned).getSpans(off, off, ClickableSpan::class.java)
if (spans.isNotEmpty()) {
onCurrentSpanUpdate(spans[0])
}
}
}
}
return super.onTouchEvent(event)
}
}
@@ -5,19 +5,26 @@ import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUrl
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
suspend fun postLogin() = coroutineScope {
launch {
runCatching {
EhEngine.getProfile().run {
Settings.displayName.value = displayName
}
}.onFailure {
logcat(it)
}
suspend fun CoroutineScope.refreshAccountInfo() = runSuspendCatching {
with(EhEngine.getProfile()) {
Settings.displayName.value = displayName
Settings.avatar.value = avatar
}
}.onFailure {
logcat(it)
}
@OptIn(DelicateCoroutinesApi::class)
fun postLogin() = GlobalScope.async(Dispatchers.IO) {
launch { refreshAccountInfo() }
runCatching {
// For the `star` cookie
EhEngine.getNews(false)
@@ -33,13 +40,13 @@ suspend fun postLogin() = coroutineScope {
}
// Sad panda check
Settings.gallerySite = EhUrl.SITE_EX
// Explicitly use ex url since https://github.com/Ehviewer-Overhauled/Ehviewer/issues/1239#issuecomment-1632584525
EhEngine.getUConfig(EhUrl.URL_UCONFIG_EX)
EhCookieStore.flush()
Settings.gallerySite.value = EhUrl.SITE_EX
}.onFailure {
Settings.gallerySite = EhUrl.SITE_E
Settings.gallerySite.value = EhUrl.SITE_E
}
Settings.hasSignedIn.value = true
Settings.needSignIn = false
Settings.needSignIn.value = false
}
@@ -14,8 +14,11 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextObfuscationMode
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
@@ -27,6 +30,7 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedSecureTextField
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -49,8 +53,6 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
@@ -60,10 +62,8 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.destinations.WebViewSignInScreenDestination
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.autofill
@@ -72,7 +72,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.withNonCancellableContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.Job
@@ -85,8 +84,8 @@ fun SignInScreen(navigator: DestinationsNavigator) {
var isProgressIndicatorVisible by rememberSaveable { mutableStateOf(false) }
var showUsernameError by rememberSaveable { mutableStateOf(false) }
var showPasswordError by rememberSaveable { mutableStateOf(false) }
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
val username = rememberTextFieldState()
val password = rememberTextFieldState()
var passwordHidden by rememberSaveable { mutableStateOf(true) }
val context = LocalContext.current
var signInJob by remember { mutableStateOf<Job?>(null) }
@@ -94,13 +93,13 @@ fun SignInScreen(navigator: DestinationsNavigator) {
fun signIn() {
if (signInJob?.isActive == true) return
if (username.isEmpty()) {
if (username.text.isEmpty()) {
showUsernameError = true
return
} else {
showUsernameError = false
}
if (password.isEmpty()) {
if (password.text.isEmpty()) {
showPasswordError = true
return
} else {
@@ -112,10 +111,11 @@ fun SignInScreen(navigator: DestinationsNavigator) {
EhUtils.signOut()
signInJob = coroutineScope.launchIO {
runCatching {
EhEngine.signIn(username, password)
EhEngine.signIn(username.text.toString(), password.text.toString())
}.onFailure {
withUIContext {
focusManager.clearFocus()
isProgressIndicatorVisible = false
dialogState.awaitConfirmationOrCancel(
confirmText = R.string.get_it,
title = R.string.sign_in_failed,
@@ -129,11 +129,9 @@ fun SignInScreen(navigator: DestinationsNavigator) {
)
},
)
isProgressIndicatorVisible = false
}
}.onSuccess {
withNonCancellableContext { postLogin() }
withUIContext { navigator.popNavigate(StartDestination) }
postLogin()
}
}
}
@@ -141,31 +139,28 @@ fun SignInScreen(navigator: DestinationsNavigator) {
@Composable
fun UsernameAndPasswordTextField() {
OutlinedTextField(
value = username,
onValueChange = { username = it },
state = username,
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)).autofill(
autofillTypes = listOf(AutofillType.Username),
onFill = { username = it },
onFill = { username.setTextAndPlaceCursorAtEnd(it) },
),
label = { Text(stringResource(R.string.username)) },
supportingText = { if (showUsernameError) Text(stringResource(R.string.error_username_cannot_empty)) },
trailingIcon = { if (showUsernameError) Icon(imageVector = Icons.Filled.Info, contentDescription = null) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
singleLine = true,
lineLimits = TextFieldLineLimits.SingleLine,
isError = showUsernameError,
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
OutlinedSecureTextField(
state = password,
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)).autofill(
autofillTypes = listOf(AutofillType.Password),
onFill = { password = it },
onFill = { password.setTextAndPlaceCursorAtEnd(it) },
),
label = { Text(stringResource(R.string.password)) },
visualTransformation = if (passwordHidden) PasswordVisualTransformation() else VisualTransformation.None,
supportingText = { if (showPasswordError) Text(stringResource(R.string.error_password_cannot_empty)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
keyboardActions = KeyboardActions(onDone = { signIn() }),
onKeyboardAction = { signIn() },
trailingIcon = {
if (showPasswordError) {
Icon(imageVector = Icons.Filled.Info, contentDescription = null)
@@ -176,8 +171,8 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}
}
},
singleLine = true,
isError = showPasswordError,
textObfuscationMode = if (passwordHidden) TextObfuscationMode.RevealLastTyped else TextObfuscationMode.Visible,
)
}
@@ -236,9 +231,8 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}
TextButton(
onClick = {
Settings.needSignIn = false
Settings.gallerySite = EhUrl.SITE_E
navigator.popNavigate(StartDestination)
Settings.gallerySite.value = EhUrl.SITE_E
Settings.needSignIn.value = false
},
modifier = Modifier.weight(1f),
) {
@@ -319,9 +313,8 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}
TextButton(
onClick = {
Settings.needSignIn = false
Settings.gallerySite = EhUrl.SITE_E
navigator.popNavigate(StartDestination)
Settings.gallerySite.value = EhUrl.SITE_E
Settings.needSignIn.value = false
},
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
) {
@@ -1,11 +1,11 @@
package com.hippo.ehviewer.ui.login
import android.webkit.WebView
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import com.google.accompanist.web.AccompanistWebViewClient
import com.google.accompanist.web.WebView
@@ -13,35 +13,26 @@ import com.google.accompanist.web.rememberWebViewState
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.util.bgWork
import com.hippo.ehviewer.util.setDefaultSettings
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
@Destination<RootGraph>
@Composable
fun WebViewSignInScreen(navigator: DestinationsNavigator) {
val coroutineScope = rememberCoroutineScope()
fun AnimatedVisibilityScope.WebViewSignInScreen(navigator: DestinationsNavigator) = composing(navigator) {
val state = rememberWebViewState(url = EhUrl.URL_SIGN_IN)
val client = remember {
object : AccompanistWebViewClient() {
private var present = false
override fun onPageFinished(view: WebView, url: String?) {
if (present) {
view.destroy()
return
}
if (EhCookieStore.hasSignedIn()) {
present = true
coroutineScope.launchIO {
withNonCancellableContext { postLogin() }
withUIContext { navigator.popNavigate(StartDestination) }
}
postLogin()
view.destroy()
launch { bgWork { awaitCancellation() } }
}
}
}
@@ -52,9 +43,7 @@ fun WebViewSignInScreen(navigator: DestinationsNavigator) {
WebView(
state = state,
modifier = Modifier.fillMaxSize(),
onCreated = {
it.setDefaultSettings()
},
onCreated = { it.setDefaultSettings() },
client = client,
)
}
@@ -0,0 +1,204 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NoAccounts
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import arrow.core.Either
import arrow.core.Either.Companion.catch
import arrow.core.Option
import arrow.core.none
import arrow.core.some
import coil3.compose.AsyncImage
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.parser.HomeParser
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.util.displayString
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSwallowingWithUI
private val limitScope = CoroutineScope(Dispatchers.IO)
private val refreshEvent = MutableSharedFlow<Unit>()
private val invalidateEvent = MutableSharedFlow<Unit>()
typealias Result = Option<Either<String, HomeParser.Result>>
private val limitFlow: StateFlow<Result> = refreshEvent.conflate()
.onEach {
// If cached value is error, drop it when refresh
val isError = limitFlow.value.isSome { it.isLeft() }
if (isError) invalidateEvent.emit(Unit)
}
.map { catch { EhEngine.getImageLimits() }.mapLeft { e -> e.displayString() }.some() }
.let { src -> merge(src, invalidateEvent.map { none() }) }
.stateIn(limitScope, SharingStarted.Eagerly, none())
context(CoroutineScope, DialogState, SnackbarHostState, DestinationsNavigator)
@Composable
fun AvatarIcon() {
val hasSignedIn by Settings.hasSignedIn.collectAsState()
if (hasSignedIn) {
val placeholder = stringResource(id = R.string.please_wait)
val result by limitFlow.collectAsState()
IconButton(
onClick = {
launch {
refreshEvent.emit(Unit)
awaitConfirmationOrCancel(
title = R.string.image_limits,
showConfirmButton = false,
showCancelButton = false,
) {
val animatedAlpha by animateFloatAsState(if (remember { result } == result) 0.5f else 1f)
result.onNone {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.keyline_margin)))
Text(text = placeholder)
}
}
result.onSome { current ->
Box(modifier = Modifier.graphicsLayer { alpha = animatedAlpha }) {
when (current) {
is Either.Left -> Text(text = current.value)
is Either.Right -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
val (limits, funds) = current.value
if (limits.maximum > 0) {
val value by animateFloatAsState(limits.current.toFloat() / limits.maximum)
LinearProgressIndicator(
progress = { value },
modifier = Modifier.height(12.dp).fillMaxWidth(),
)
}
when (limits.maximum) {
-1 -> Text(text = stringResource(id = R.string.image_limits_restricted))
0 -> Text(text = stringResource(id = R.string.image_limits_normal))
else -> Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = stringResource(id = R.string.image_limits_summary))
RollingNumber(number = limits.current)
Text(text = " / ")
RollingNumberPlaceholder(number = limits.maximum)
}
}
Text(text = stringResource(id = R.string.current_funds))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround,
) {
FundsItem(
type = "GP",
amount = funds.gp,
)
FundsItem(
type = "C",
amount = funds.credit,
)
}
if (limits.resetCost > 0) {
Button(
onClick = {
launchIO {
runSwallowingWithUI {
invalidateEvent.emit(Unit)
EhEngine.resetImageLimits()
refreshEvent.emit(Unit)
}
}
},
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Icon(
imageVector = Icons.Default.RestartAlt,
contentDescription = stringResource(id = R.string.reset),
)
Text(text = stringResource(id = R.string.reset_cost, limits.resetCost))
}
}
}
}
}
}
}
}
},
) {
val avatar by Settings.avatar.collectAsState()
AnimatedContent(targetState = avatar == null) { noAvatar ->
if (noAvatar) {
Icon(imageVector = Icons.Default.Person, contentDescription = null)
} else {
AsyncImage(
model = avatar,
contentDescription = null,
modifier = Modifier.clip(CircleShape),
contentScale = ContentScale.Crop,
)
}
}
}
} else {
IconButton(
onClick = {
launch {
awaitConfirmationOrCancel(
confirmText = R.string.sign_in,
showCancelButton = false,
text = { Text(text = stringResource(id = R.string.settings_eh_identity_cookies_guest)) },
)
EhUtils.signOut()
}
},
) {
Icon(imageVector = Icons.Default.NoAccounts, contentDescription = null)
}
}
}
@@ -15,7 +15,7 @@ import androidx.compose.ui.unit.dp
@Composable
fun FundsItem(
type: String,
amount: String,
amount: Int,
modifier: Modifier = Modifier,
textStyle: TextStyle = LocalTextStyle.current,
) {
@@ -27,6 +27,9 @@ fun FundsItem(
Badge(containerColor = MaterialTheme.colorScheme.primaryContainer) {
Text(text = type)
}
Text(text = amount, style = textStyle)
RollingNumber(number = amount, style = textStyle, separator = true)
if (type == "GP") {
Text(text = "k")
}
}
}
@@ -1,32 +1,36 @@
package com.hippo.ehviewer.ui.main
import android.text.style.ClickableSpan
import android.text.style.URLSpan
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.compose.ConstraintLayout
import arrow.core.Either
import coil3.compose.AsyncImage
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.ui.legacy.LinkifyTextView
import com.hippo.ehviewer.ui.screen.breakToTextAndUrl
import com.hippo.ehviewer.util.ReadableTime
typealias TextOrUrl = Either<String, AnnotatedString>
typealias TextOrUrlList = List<TextOrUrl>
@Composable
fun GalleryCommentCard(
modifier: Modifier = Modifier,
@@ -34,22 +38,14 @@ fun GalleryCommentCard(
onCardClick: () -> Unit,
onUserClick: () -> Unit,
onUrlClick: (String) -> Unit,
update: LinkifyTextView.() -> Unit,
) = comment.run {
var currentSpan by remember(comment) { mutableStateOf<ClickableSpan?>(null) }
Card(
onClick = {
val span = currentSpan.apply { currentSpan = null }
if (span is URLSpan) {
onUrlClick(span.url)
} else {
onCardClick()
}
},
modifier = modifier,
) {
val keylineMargin = dimensionResource(id = R.dimen.keyline_margin)
ConstraintLayout(modifier = Modifier.padding(horizontal = keylineMargin, vertical = 8.dp).fillMaxWidth()) {
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
showImage: Boolean = false,
processComment: @Composable (GalleryComment, TextLinkStyles, LinkInteractionListener) -> AnnotatedString = { c, s, l -> AnnotatedString.fromHtml(c.comment, s, l) },
) = with(comment) {
Card(onClick = onCardClick, modifier = modifier) {
val margin = dimensionResource(id = R.dimen.keyline_margin)
ConstraintLayout(modifier = Modifier.padding(horizontal = margin, vertical = 8.dp).fillMaxWidth()) {
val (userRef, timeRef, commentRef) = createRefs()
ProvideTextStyle(value = MaterialTheme.typography.titleSmall) {
val userText = if (uploader) stringResource(id = R.string.comment_user_uploader, user.orEmpty()) else user.orEmpty()
@@ -68,21 +64,41 @@ fun GalleryCommentCard(
},
)
}
val textColor = LocalContentColor.current.toArgb()
val linkTextColor = MaterialTheme.colorScheme.primary.toArgb()
AndroidView(
factory = { context ->
LinkifyTextView(context) { currentSpan = it }.apply {
setTextColor(textColor)
setLinkTextColor(linkTextColor)
}
},
Column(
modifier = Modifier.constrainAs(commentRef) {
start.linkTo(parent.start)
top.linkTo(userRef.bottom, margin = 8.dp)
},
update = update,
)
) {
ProvideTextStyle(value = MaterialTheme.typography.bodyMedium) {
val linkColor = MaterialTheme.colorScheme.primary
val processed = processComment(
comment,
TextLinkStyles(style = SpanStyle(color = linkColor)),
) { link ->
check(link is LinkAnnotation.Url)
onUrlClick(link.url)
}
if (showImage) {
val list = breakToTextAndUrl(comment.comment, processed)
list.forEach {
when (it) {
is Either.Left -> AsyncImage(
model = it.value,
contentDescription = null,
)
is Either.Right -> Text(text = it.value)
}
}
} else {
Text(
text = processed,
maxLines = maxLines,
overflow = overflow,
)
}
}
}
}
}
}
@@ -21,10 +21,10 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import coil3.BitmapImage
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.AsyncImagePainter.State
import coil3.request.ImageRequest
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
import com.hippo.ehviewer.client.data.V2GalleryPreview
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.shouldCrop
@@ -47,8 +47,8 @@ fun EhAsyncPreview(
contentDescription = null,
modifier = modifier,
transform = {
model.run {
if (it is AsyncImagePainter.State.Success && this is NormalGalleryPreview) {
if (it is State.Success && model is V2GalleryPreview) {
with(model) {
it.copy(
painter = BitmapPainter(
(it.result.image as BitmapImage).bitmap.asImageBitmap(),
@@ -56,22 +56,20 @@ fun EhAsyncPreview(
IntSize(clipWidth - 1, clipHeight - 1),
),
)
} else {
it
}
} else {
it
}
},
onState = {
if (it is AsyncImagePainter.State.Success) {
model.run {
if (this is NormalGalleryPreview) {
if (shouldCrop) {
contentScale = ContentScale.Crop
}
} else {
if (it.result.image.shouldCrop) {
contentScale = ContentScale.Crop
}
if (it is State.Success) {
if (model is V2GalleryPreview) {
if (model.shouldCrop) {
contentScale = ContentScale.Crop
}
} else {
if (it.result.image.shouldCrop) {
contentScale = ContentScale.Crop
}
}
}
@@ -33,22 +33,22 @@ fun GalleryTags(
val context = LocalContext.current
val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(context) && EhTagDatabase.initialized
val ehTags = EhTagDatabase.takeIf { canTranslate }
fun String.translate() = ehTags?.getTranslation(tag = this) ?: this
fun String.translate(prefix: String?) = ehTags?.getTranslation(prefix = prefix, tag = this) ?: this
fun TagNamespace.translate() = ehTags?.getTranslation(tag = value) ?: value
fun String.translate(ns: TagNamespace) = ehTags?.getTranslation(prefix = ns.prefix, tag = this) ?: this
Column(modifier) {
tagGroups.forEach { tagGroup ->
Row {
val ns = tagGroup.nameSpace
BaseRoundText(
text = tagGroup.groupName.translate(),
text = ns.translate(),
isGroup = true,
)
val prefix = TagNamespace(tagGroup.groupName).toPrefix()
FlowRow {
tagGroup.forEach {
tagGroup.tags.forEach {
val weak = it.startsWith('_')
val real = it.removePrefix("_")
val translated = real.translate(prefix)
val tag = tagGroup.groupName + ":" + real
val translated = real.translate(ns)
val tag = tagGroup.nameSpace.value + ":" + real
val hapticFeedback = LocalHapticFeedback.current
BaseRoundText(
text = translated,
@@ -0,0 +1,201 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.animation.core.animateOffset
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.toSize
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.sin
@Composable
fun rememberTextStyleNumberMaxSize(textStyle: TextStyle): DpSize {
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(textStyle, textMeasurer, density) {
with(density) {
"0123456789".maxOfWith({ (aw, _), (bw, _) -> aw - bw }) { char ->
textMeasurer.measure("$char", textStyle).size
}.toSize().toDpSize()
}
}
}
@Composable
fun RollingNumberPlaceholder(number: Int, style: TextStyle = LocalTextStyle.current) {
val styleNoSpacing = style.copy(letterSpacing = TextUnit.Unspecified)
val size = rememberTextStyleNumberMaxSize(style)
Row(horizontalArrangement = Arrangement.Center) {
"$number".forEach { char ->
Text(
text = "$char",
style = styleNoSpacing,
textAlign = TextAlign.Center,
modifier = Modifier.size(size),
)
}
}
}
// We have three space to make animation looks continuous
// 1. Concept space, where we have elements 1 2 3 4 5 6 7 8 9 0 null
// 0, 1, null is connected and the have same norm(distance) to each other
// 2. Intermediate, which is 2d euclidean space
// 0 ~ 9 in Concept space is mapped to a circle rigidly
// null is mapped to a special point(0, - 1.528f * radius)
// keeping null - 0, null - 1, 0 - 1 have same distance
// 3. Number Offset Space
// value is from -1 ~ 10, where -1 and 10 shows nothing
// radian of point in Intermediate space is mapped to here discontinuously
// This controls animation speed
private const val RADIUS = 100f
// Gaps between number in radian
private const val GAP = (2 * PI / 10).toFloat()
private const val ZERO_RADIAN = -(PI / 2 - GAP / 2).toFloat()
// null is mapped to (0, - 1.528f * radius)
private const val NULL_NODE_DISTANCE = 1.528f
// Input: Concept space: 1 2 3 4 5 6 7 8 9 0 null
// Output: Position in 2d euclidean space partial circle
private fun conceptSpaceToIntermediate(value: Int?): Offset {
if (value != null) {
val theta = ZERO_RADIAN - value * GAP
return Offset(cos(theta), sin(theta)) * RADIUS
} else {
return Offset(0f, -NULL_NODE_DISTANCE) * RADIUS
}
}
// Output: -1 ~ 10, where -1 and 10 shows nothing
private fun intermediateToNumberOffset(circleOffset: Offset): Float {
val degree = atan2(circleOffset.y, circleOffset.x)
val normalized = normalize(ZERO_RADIAN - degree)
return if (normalized > 9 * GAP) {
val reNormalized = normalized - 9 * GAP - GAP / 2
if (reNormalized > 0) {
// 0 side
reNormalized / GAP * 2 - 1
} else {
// 9 side
10 + reNormalized / GAP * 2
}
} else {
normalized / GAP
}
}
// Normalize radian to [0, 2 * PI]
private fun normalize(degree: Float) = (degree - 2 * PI * floor(degree / (2 * PI))).toFloat()
// Convert 2d euclidean space partial circle to UI Offset
private fun numberOffsetToUIOffset(heightPx: Int, number: Float) = IntOffset(0, -(heightPx * 2 * number).toInt())
@Composable
fun RollingNumber(
number: Int,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
style: TextStyle = LocalTextStyle.current,
separator: Boolean = false,
length: Int? = null,
) {
val isNegative = number < 0
val textColor = color.takeOrElse { style.color.takeOrElse { LocalContentColor.current } }
val string = remember(number) { "${abs(number)}" }
val transition = updateTransition(string)
val styleNoSpacing = style.merge(color = textColor).copy(letterSpacing = TextUnit.Unspecified)
val size = rememberTextStyleNumberMaxSize(style)
LazyRow(
modifier = modifier.clipToBounds().layout { measurable, constraints ->
val placeable = measurable.measure(constraints.copy(maxHeight = Int.MAX_VALUE))
layout(width = placeable.width, height = size.height.roundToPx()) {
placeable.place(0, 0)
}
},
reverseLayout = true,
horizontalArrangement = Arrangement.Center,
) {
val max = length ?: with(transition) { max(currentState.length, targetState.length) }
items(max, key = { it }) { reversed ->
val rotate by transition.animateOffset { str ->
val len = str.length
val absent = max - len
val where = max - reversed - 1
val v = if (where < absent) null else str[where - absent].digitToInt()
conceptSpaceToIntermediate(v)
}
if (reversed != 0 && reversed % 3 == 0 && separator) {
Text(
text = ",",
modifier = Modifier.size(size),
style = styleNoSpacing,
textAlign = TextAlign.Center,
)
}
Column(
modifier = Modifier.animateItem().offset {
val number = intermediateToNumberOffset(rotate)
numberOffsetToUIOffset(size.height.roundToPx(), number)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
repeat(10) { i ->
Text(
text = "$i",
modifier = Modifier.size(size),
style = styleNoSpacing,
textAlign = TextAlign.Center,
)
Text(
text = "",
modifier = Modifier.size(size),
style = styleNoSpacing,
textAlign = TextAlign.Center,
)
}
}
}
if (isNegative) {
item(key = -1) {
Text(
text = "-",
modifier = Modifier.size(size).animateItem(),
style = styleNoSpacing,
textAlign = TextAlign.Center,
)
}
}
}
}
@@ -21,7 +21,7 @@ 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 com.hippo.ehviewer.gallery.PageLoader
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
@@ -37,7 +37,7 @@ fun GalleryPager(
type: ReadingModeType,
pagerState: PagerState,
lazyListState: LazyListState,
pageLoader: PageLoader2,
pageLoader: PageLoader,
showNavigationOverlay: Boolean,
onNavigationModeChange: () -> Unit,
onSelectPage: (Page) -> Unit,
@@ -0,0 +1,25 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
fun Modifier.keyEventHandler(
enabled: () -> Boolean,
movePrevious: () -> Unit,
moveNext: () -> Unit,
) = onPreviewKeyEvent {
if (enabled() && it.type == KeyEventType.KeyDown) {
when (it.key) {
Key.DirectionUp, Key.PageUp, Key.VolumeUp -> movePrevious()
Key.DirectionDown, Key.PageDown, Key.VolumeDown -> moveNext()
else -> return@onPreviewKeyEvent false
}
true
} else {
false
}
}
@@ -17,7 +17,7 @@ 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.gallery.PageLoader
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.awaitActivityResult
@@ -33,7 +33,7 @@ import moe.tarsin.coroutines.runSuspendCatching
import okio.Path.Companion.toOkioPath
import splitties.systemservices.clipboardManager
context(PageLoader2)
context(PageLoader)
private fun Context.provideImage(index: Int): Uri? {
val dir = AppConfig.externalTempDir ?: return null
val name = getImageFilename(index) ?: return null
@@ -41,7 +41,7 @@ private fun Context.provideImage(index: Int): Uri? {
return FileProvider.getUriForFile(this, "$APPLICATION_ID.fileprovider", file)
}
context(SnackbarHostState, Context, PageLoader2)
context(SnackbarHostState, Context, PageLoader)
suspend fun shareImage(page: Page, info: GalleryInfo? = null) {
val error = getString(R.string.error_cant_save_image)
val share = getString(R.string.share_image)
@@ -66,7 +66,7 @@ suspend fun shareImage(page: Page, info: GalleryInfo? = null) {
}
}
context(SnackbarHostState, Context, PageLoader2)
context(SnackbarHostState, Context, PageLoader)
suspend fun copy(page: Page) {
val error = getString(R.string.error_cant_save_image)
val copied = getString(R.string.copied_to_clipboard)
@@ -82,7 +82,7 @@ suspend fun copy(page: Page) {
}
}
context(SnackbarHostState, Context, PageLoader2)
context(SnackbarHostState, Context, PageLoader)
suspend fun save(page: Page) {
val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
val cannotSave = getString(R.string.error_cant_save_image)
@@ -136,7 +136,7 @@ suspend fun save(page: Page) {
}
}
context(SnackbarHostState, Context, PageLoader2)
context(SnackbarHostState, Context, PageLoader)
suspend fun saveTo(page: Page) {
val filename = getImageFilename(page.index)
if (filename == null) {
@@ -35,12 +35,12 @@ 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.PageLoader
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 com.hippo.ehviewer.ui.settings.AdsPlaceholderFile
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import com.hippo.ehviewer.util.AdsPlaceholderFile
import eu.kanade.tachiyomi.ui.reader.viewer.CombinedCircularProgressIndicator
import kotlinx.coroutines.flow.drop
import moe.tarsin.kt.unreachable
@@ -55,6 +55,7 @@ fun PagerItem(
) {
LaunchedEffect(Unit) {
pageLoader.request(page.index)
// In case pageloader restart
page.statusFlow.drop(1).collect {
if (page.statusFlow.value == PageStatus.Queued) {
pageLoader.request(page.index)
@@ -101,26 +102,10 @@ fun PagerItem(
)
}
is PageStatus.Blocked -> {
SubcomposeAsyncImage(
model = AdsPlaceholderFile,
contentDescription = null,
AdsPlaceholder(
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)) {
@@ -168,3 +153,28 @@ private val grayScaleAndInvertMatrix = ColorMatrix().also { mtx ->
private val grayScaleFilter = ColorFilter.colorMatrix(grayScaleMatrix)
private val invertFilter = ColorFilter.colorMatrix(invertMatrix)
private val grayScaleAndInvertFilter = ColorFilter.colorMatrix(grayScaleAndInvertMatrix)
@Composable
fun AdsPlaceholder(
modifier: Modifier = Modifier,
contentScale: ContentScale,
) = SubcomposeAsyncImage(
model = AdsPlaceholderFile,
contentDescription = null,
modifier = modifier,
contentScale = 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))
}
}
}
}
@@ -8,7 +8,6 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -32,7 +31,7 @@ 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 com.hippo.ehviewer.gallery.PageLoader
import com.hippo.ehviewer.gallery.PageStatus
import com.hippo.ehviewer.gallery.statusObserved
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
@@ -54,7 +53,7 @@ fun PagerViewer(
pagerState: PagerState,
isRtl: Boolean,
isVertical: Boolean,
pageLoader: PageLoader2,
pageLoader: PageLoader,
navigator: () -> NavigationRegions,
onSelectPage: (Page) -> Unit,
onMenuRegionClick: () -> Unit,
@@ -123,7 +122,7 @@ fun PagerViewer(
@Composable
private fun PageContainer(
page: Page,
pageLoader: PageLoader2,
pageLoader: PageLoader,
isRtl: Boolean,
scaleType: Int,
landscapeZoom: Boolean,
@@ -238,21 +237,19 @@ private fun PageContainer(
}
}
private suspend fun ZoomableState?.panLeft(distance: Float, bounds: Rect): Boolean =
if (canPan { it.right - bounds.right }) {
panBy(Offset(-distance, 0f))
true
} else {
false
}
private suspend fun ZoomableState?.panLeft(distance: Float, bounds: Rect): Boolean = if (canPan { it.right - bounds.right }) {
panBy(Offset(-distance, 0f))
true
} else {
false
}
private suspend fun ZoomableState?.panRight(distance: Float, bounds: Rect): Boolean =
if (canPan { bounds.left - it.left }) {
panBy(Offset(distance, 0f))
true
} else {
false
}
private suspend fun ZoomableState?.panRight(distance: Float, bounds: Rect): Boolean = if (canPan { bounds.left - it.left }) {
panBy(Offset(distance, 0f))
true
} else {
false
}
private inline fun ZoomableState?.canPan(getRemaining: (Rect) -> Float): Boolean {
// TODO: Remove when K2 mode in IDE is stable
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -64,23 +63,4 @@ fun ReaderGeneralSetting() = Column(modifier = Modifier.verticalScroll(rememberS
title = stringResource(id = R.string.pref_page_transitions),
field = Settings.pageTransitions.asMutableState(),
)
val volume = Settings.readWithVolumeKeys.asMutableState()
SwitchChoice(
title = stringResource(id = R.string.settings_read_volume_page),
field = volume,
)
AnimatedVisibility(visible = volume.value) {
Column {
SliderChoice(
startSlot = { Text(text = stringResource(id = R.string.settings_read_volume_page_fast)) },
endSlot = { Text(text = stringResource(id = R.string.settings_read_volume_page_slow)) },
range = 0..9,
field = Settings.readWithVolumeKeysInterval.asMutableState(),
)
SwitchChoice(
title = stringResource(id = R.string.settings_read_reverse_volume),
field = Settings.readWithVolumeKeysInverted.asMutableState(),
)
}
}
}
@@ -6,6 +6,7 @@ import android.os.Parcelable
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.isSystemInDarkTheme
@@ -42,35 +43,39 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.BlendMode
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.core.view.WindowInsetsControllerCompat
import arrow.core.Either
import arrow.core.Either.Companion.catch
import arrow.core.raise.ensure
import arrow.core.right
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.PageLoader
import com.hippo.ehviewer.gallery.PageStatus
import com.hippo.ehviewer.gallery.status
import com.hippo.ehviewer.gallery.unblock
import com.hippo.ehviewer.gallery.useArchivePageLoader
import com.hippo.ehviewer.gallery.useEhPageLoader
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.ui.tools.asyncInVM
import com.hippo.ehviewer.util.displayString
import com.hippo.ehviewer.util.hasAds
import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
@@ -81,12 +86,13 @@ import eu.kanade.tachiyomi.ui.reader.ReaderContentOverlay
import eu.kanade.tachiyomi.ui.reader.ReaderPageSheetMeta
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.awaitCancellation
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.parcelize.Parcelize
import moe.tarsin.kt.unreachable
@@ -109,51 +115,54 @@ private fun Background(
@Destination<RootGraph>
@Composable
fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
Await({ preparePageLoader(args) }) { pageLoader ->
DisposableEffect(pageLoader) {
pageLoader.start()
onDispose {
pageLoader.stop()
}
val bgColor by collectBackgroundColorAsState()
val uiController = rememberSystemUiController()
DisposableEffect(uiController) {
val lightStatusBar = uiController.statusBarDarkContentEnabled
onDispose {
uiController.statusBarDarkContentEnabled = lightStatusBar
}
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
}
LaunchedEffect(uiController) {
snapshotFlow { bgColor }.collect {
uiController.statusBarDarkContentEnabled = it == Color.White
}
}
Await(
{
Either.catch {
readingFailed.takeUnless { pageLoader.awaitReady() }
}.fold({ it.displayString() }, { it })
},
placeholder = {
Background(bgColor) {
CircularProgressIndicator()
}
Await(
block = asyncInVM(args) { alive ->
suspendCancellableCoroutine { cont ->
alive.launchIO {
catch {
usePageLoader(args) { loader ->
cont.resume(loader.right())
awaitCancellation()
}
}.let { left -> cont.resume(left) }
}
},
) { error ->
if (error == null) {
}
}.value.run {
{ await() }
},
placeholder = {
Background(bgColor) {
CircularProgressIndicator()
}
},
) { result ->
when (result) {
is Either.Left -> Background(bgColor) {
Text(
text = result.value.displayString(),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleLarge,
)
}
is Either.Right -> {
val loader = result.value
val info = (args as? ReaderScreenArgs.Gallery)?.info
key(pageLoader) {
ReaderScreen(pageLoader, info, navigator)
}
} else {
Background(bgColor) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleLarge,
)
key(loader) {
ReaderScreen(loader, info, navigator)
}
}
}
@@ -161,7 +170,7 @@ fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: Dest
}
@Composable
fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGalleryInfo?, navigator: DestinationsNavigator) = composing(navigator) {
fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader, info: BaseGalleryInfo?, navigator: DestinationsNavigator) = composing(navigator) {
ConfigureKeepScreenOn()
LaunchedEffect(Unit) {
val orientation = requestedOrientation
@@ -178,7 +187,6 @@ fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGall
}
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
val readingMode by Settings.readingMode.collectAsState { ReadingModeType.fromPreference(it) }
val volumeKeysEnabled by Settings.readWithVolumeKeys.collectAsState()
val fullscreen by Settings.fullscreen.collectAsState()
val cutoutShort by Settings.cutoutShort.collectAsState()
val uiController = rememberSystemUiController()
@@ -192,11 +200,32 @@ fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGall
val lazyListState = rememberLazyListState(pageLoader.startPage)
val pagerState = rememberPagerState(pageLoader.startPage) { pageLoader.size }
val syncState = rememberSliderPagerDoubleSyncState(lazyListState, pagerState, pageLoader)
Box {
var appbarVisible by remember { mutableStateOf(false) }
var appbarVisible by remember { mutableStateOf(false) }
val isWebtoon by rememberUpdatedState(ReadingModeType.isWebtoon(readingMode))
val focusRequester = remember { FocusRequester() }
Box(
Modifier.keyEventHandler(
enabled = { !appbarVisible },
movePrevious = {
launch {
if (isWebtoon) lazyListState.scrollUp() else pagerState.moveToPrevious()
}
},
moveNext = {
launch {
if (isWebtoon) lazyListState.scrollDown() else pagerState.moveToNext()
}
},
).focusRequester(focusRequester).focusable(),
) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
val bgColor by collectBackgroundColorAsState()
val isWebtoon by rememberUpdatedState(ReadingModeType.isWebtoon(readingMode))
syncState.Sync(isWebtoon) { appbarVisible = false }
LaunchedEffect(isWebtoon) {
appbarVisible = false
}
if (fullscreen) {
LaunchedEffect(Unit) {
snapshotFlow { appbarVisible }.collect {
@@ -204,15 +233,6 @@ fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGall
}
}
}
VolumeKeysHandler(
enabled = { volumeKeysEnabled && !appbarVisible },
movePrevious = {
if (isWebtoon) lazyListState.scrollUp() else pagerState.moveToPrevious()
},
moveNext = {
if (isWebtoon) lazyListState.scrollDown() else pagerState.moveToNext()
},
)
var showNavigationOverlay by remember {
val showOnStart = Settings.showNavigationOverlayNewUser.value || Settings.showNavigationOverlayOnStart.value
Settings.showNavigationOverlayNewUser.value = false
@@ -348,19 +368,20 @@ fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGall
}
context(Context, DialogState, DestinationsNavigator)
private suspend fun preparePageLoader(args: ReaderScreenArgs) = when (args) {
suspend fun <T> usePageLoader(args: ReaderScreenArgs, block: suspend (PageLoader) -> T) = when (args) {
is ReaderScreenArgs.Gallery -> {
val info = args.info
val page = args.page
val archive = withIOContext { DownloadManager.getDownloadInfo(info.gid)?.archiveFile }
val archive = DownloadManager.getDownloadInfo(info.gid)?.archiveFile
if (archive != null) {
ArchivePageLoader(archive, info.gid, page, info.hasAds)
useArchivePageLoader(archive, info.gid, page, info.hasAds, block = block)
} else {
EhPageLoader(info, page)
useEhPageLoader(info, page, block)
}
}
is ReaderScreenArgs.Archive -> {
ArchivePageLoader(args.uri.toOkioPath()) { invalidator ->
is ReaderScreenArgs.Archive -> useArchivePageLoader(
args.uri.toOkioPath(),
passwdProvider = { invalidator ->
awaitInputText(
title = getString(R.string.archive_need_passwd),
hint = getString(R.string.archive_passwd),
@@ -369,8 +390,9 @@ private suspend fun preparePageLoader(args: ReaderScreenArgs) = when (args) {
ensure(text.isNotBlank()) { getString(R.string.passwd_cannot_be_empty) }
ensure(invalidator(text)) { getString(R.string.passwd_wrong) }
}
}
}
},
block = block,
)
}
@Composable
@@ -11,7 +11,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import com.hippo.ehviewer.gallery.PageLoader2
import com.hippo.ehviewer.gallery.PageLoader
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
@@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.filterNotNull
class SliderPagerDoubleSync(
private val lazyListState: LazyListState,
private val pagerState: PagerState,
private val pageLoader: PageLoader2,
private val pageLoader: PageLoader,
) {
private var sliderFollowPager by mutableStateOf(true)
var sliderValue by mutableIntStateOf(pageLoader.startPage + 1)
@@ -34,23 +34,28 @@ class SliderPagerDoubleSync(
sliderFollowPager = true
}
fun currentPageFlow(webtoon: Boolean) = if (webtoon) {
snapshotFlow {
with(lazyListState.layoutInfo) {
visibleItemsInfo.lastOrNull {
it.offset <= maxOf(viewportStartOffset, viewportEndOffset - it.size)
}?.index
}
}.filterNotNull()
} else {
snapshotFlow { pagerState.currentPage }
}
@Composable
fun Sync(webtoon: Boolean, onPageSelected: () -> Unit) {
val currentIndexFlow = remember(webtoon) {
val initialIndex = sliderValue - 1
if (webtoon) {
sliderFollowPager = lazyListState.firstVisibleItemIndex == initialIndex
snapshotFlow {
with(lazyListState.layoutInfo) {
visibleItemsInfo.lastOrNull {
it.offset <= maxOf(viewportStartOffset, viewportEndOffset - it.size)
}?.index
}
}.filterNotNull()
sliderFollowPager = if (webtoon) {
lazyListState.firstVisibleItemIndex == initialIndex
} else {
sliderFollowPager = pagerState.currentPage == initialIndex
snapshotFlow { pagerState.currentPage }
pagerState.currentPage == initialIndex
}
currentPageFlow(webtoon)
}
if (sliderFollowPager) {
LaunchedEffect(currentIndexFlow) {
@@ -80,7 +85,7 @@ class SliderPagerDoubleSync(
fun rememberSliderPagerDoubleSyncState(
lazyListState: LazyListState,
pagerState: PagerState,
pageLoader: PageLoader2,
pageLoader: PageLoader,
): SliderPagerDoubleSync = remember {
SliderPagerDoubleSync(lazyListState, pagerState, pageLoader)
}
@@ -1,49 +0,0 @@
package com.hippo.ehviewer.ui.reader
import android.view.KeyEvent.ACTION_DOWN
import android.view.KeyEvent.KEYCODE_VOLUME_DOWN
import android.view.KeyEvent.KEYCODE_VOLUME_UP
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalView
import androidx.core.view.ViewCompat
import com.hippo.ehviewer.Settings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
context(CoroutineScope)
@Composable
fun VolumeKeysHandler(
enabled: () -> Boolean,
movePrevious: suspend () -> Unit,
moveNext: suspend () -> Unit,
) {
val view = LocalView.current
DisposableEffect(view) {
val listener = ViewCompat.OnUnhandledKeyEventListenerCompat { _, event ->
if (!enabled()) return@OnUnhandledKeyEventListenerCompat false
when (val keyCode = event.keyCode) {
KEYCODE_VOLUME_UP, KEYCODE_VOLUME_DOWN -> {
val interval = Settings.readWithVolumeKeysInterval.value + 1
val inverted = Settings.readWithVolumeKeysInverted.value
if (event.action == ACTION_DOWN && event.repeatCount % interval == 0) {
launch {
if (inverted.xor(keyCode == KEYCODE_VOLUME_UP)) {
movePrevious()
} else {
moveNext()
}
}
}
true
}
else -> false
}
}
ViewCompat.addOnUnhandledKeyEventListener(view, listener)
onDispose {
ViewCompat.removeOnUnhandledKeyEventListener(view, listener)
}
}
}
@@ -19,7 +19,7 @@ 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.gallery.PageLoader
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
@@ -32,7 +32,7 @@ import me.saket.telephoto.zoomable.zoomable
fun WebtoonViewer(
lazyListState: LazyListState,
withGaps: Boolean,
pageLoader: PageLoader2,
pageLoader: PageLoader,
navigator: () -> NavigationRegions,
onSelectPage: (Page) -> Unit,
onMenuRegionClick: () -> Unit,
@@ -14,11 +14,10 @@ data class DownloadsFilterState(
val keyword: String = "",
) : Parcelable
fun DownloadsFilterState.take(info: DownloadInfo) =
mode.take(info, label) &&
(state == -1 || info.state == state) &&
with(info) {
title.containsIgnoreCase(keyword) ||
titleJpn.containsIgnoreCase(keyword) ||
simpleTags?.any { it.containsIgnoreCase(keyword) } ?: false
}
fun DownloadsFilterState.take(info: DownloadInfo) = mode.take(info, label) &&
(state == -1 || info.state == state) &&
with(info) {
title.containsIgnoreCase(keyword) ||
titleJpn.containsIgnoreCase(keyword) ||
simpleTags?.any { it.containsIgnoreCase(keyword) } ?: false
}
@@ -152,8 +152,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) =
val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(implicit<Context>()) && EhTagDatabase.initialized
val ehTags = EhTagDatabase.takeIf { canTranslate }
fun getTranslation(tag: String) = ehTags?.run {
getTranslation(TagNamespace.Artist.toPrefix(), tag)
?: getTranslation(TagNamespace.Cosplayer.toPrefix(), tag)
getTranslation(TagNamespace.Artist.prefix, tag) ?: getTranslation(TagNamespace.Cosplayer.prefix, tag)
} ?: tag
val allName = stringResource(R.string.download_all)
val defaultName = stringResource(R.string.default_download_label_name)
@@ -68,6 +68,7 @@ import com.hippo.ehviewer.ui.DrawerHandle
import com.hippo.ehviewer.ui.LocalSideSheetState
import com.hippo.ehviewer.ui.awaitSelectDate
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.main.AvatarIcon
import com.hippo.ehviewer.ui.main.FAB_ANIMATE_TIME
import com.hippo.ehviewer.ui.main.FabLayout
import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
@@ -246,6 +247,7 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator) =
IconButton(onClick = { launch { sheetState.open() } }) {
Icon(imageVector = Icons.Outlined.FolderSpecial, contentDescription = null)
}
AvatarIcon()
},
) { contentPadding ->
val listMode by Settings.listMode.collectAsState()
@@ -1,12 +1,5 @@
package com.hippo.ehviewer.ui.screen
import android.content.Context
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.StyleSpan
import android.widget.TextView
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
@@ -67,15 +60,23 @@ import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.max
import androidx.compose.ui.util.lerp
import androidx.core.text.inSpans
import androidx.core.text.parseAsHtml
import com.hippo.ehviewer.EhApplication
import arrow.core.left
import arrow.core.right
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhEngine
@@ -88,8 +89,9 @@ import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.main.TextOrUrl
import com.hippo.ehviewer.ui.main.TextOrUrlList
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
import com.hippo.ehviewer.ui.tools.normalizeSpan
@@ -99,7 +101,6 @@ import com.hippo.ehviewer.ui.tools.thenIf
import com.hippo.ehviewer.ui.tools.toBBCode
import com.hippo.ehviewer.ui.tools.updateSpan
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.TextUrl
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.displayString
import com.ramcosta.composedestinations.annotation.Destination
@@ -109,41 +110,68 @@ import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlin.math.roundToInt
import kotlin.sequences.forEach
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import rikka.core.res.resolveColor
private fun Context.generateComment(
textView: TextView,
private const val IMAGE_OBJ = ''
private val IMAGE_PATTERN = Regex("<img src=\"([^\"]+)\"")
private val URL_PATTERN = Regex("(http|https)://[a-z0-9A-Z%-]+(\\.[a-z0-9A-Z%-]+)+(:\\d{1,5})?(/[a-zA-Z0-9-_~:#@!&',;=%/*.?+$\\[\\]()]+)?/?")
fun breakToTextAndUrl(origin: String, text: AnnotatedString): TextOrUrlList {
val urls = IMAGE_PATTERN.findAll(origin).toList()
if (urls.isEmpty()) return listOf(text.right())
val iter = urls.iterator()
var currentOfs = 0
return buildList<TextOrUrl> {
while (true) {
val index = text.text.indexOf(IMAGE_OBJ, currentOfs)
if (index == -1) break
add(text.subSequence(currentOfs, index).right())
add(iter.next().groupValues[1].left())
currentOfs = index + 1
}
add(text.subSequence(currentOfs, text.length).right())
}
}
@Composable
fun processComment(
comment: GalleryComment,
): CharSequence {
val sp = comment.comment.parseAsHtml(imageGetter = CoilImageGetter(textView))
val ssb = SpannableStringBuilder(sp)
if (0L != comment.id && 0 != comment.score) {
val score = comment.score
val scoreString = if (score > 0) "+$score" else score.toString()
ssb.append(" ").inSpans(
RelativeSizeSpan(0.8f),
StyleSpan(Typeface.BOLD),
ForegroundColorSpan(theme.resolveColor(android.R.attr.textColorSecondary)),
) {
append(scoreString)
linkStyle: TextLinkStyles,
onLinkClick: LinkInteractionListener,
) = AnnotatedString.fromHtml(comment.comment, linkStyle, onLinkClick).let { text ->
buildAnnotatedString {
val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
append(text)
URL_PATTERN.findAll(text).forEach { result ->
val start = result.range.first
val end = result.range.last + 1
if (!text.hasLinkAnnotations(start, end)) {
addLink(LinkAnnotation.Url(result.groupValues[0], linkStyle, onLinkClick), start, end)
}
}
val style = SpanStyle(fontSize = 0.8f.em, fontWeight = FontWeight.Bold, color = onSurfaceVariant)
if (comment.id != 0L && comment.score != 0) {
val score = comment.score
val scoreString = if (score > 0) "+$score" else score.toString()
append(" ")
withStyle(style) {
append(scoreString)
}
}
if (comment.lastEdited != 0L) {
append("\n\n")
withStyle(style) {
append(
stringResource(
R.string.last_edited,
ReadableTime.getTimeAgo(comment.lastEdited),
),
)
}
}
}
if (comment.lastEdited != 0L) {
val str = getString(
R.string.last_edited,
ReadableTime.getTimeAgo(comment.lastEdited),
)
ssb.append("\n\n").inSpans(
RelativeSizeSpan(0.8f),
StyleSpan(Typeface.BOLD),
ForegroundColorSpan(theme.resolveColor(android.R.attr.textColorSecondary)),
) {
append(str)
}
}
return TextUrl.handleTextUrl(ssb)
}
private val MinimumContentPaddingEditText = 88.dp
@@ -156,7 +184,7 @@ fun AnimatedVisibilityScope.GalleryCommentsScreen(gid: Long, navigator: Destinat
val animationProgress by animateFloatMergePredictiveBackAsState(enable = commenting) { commenting = false }
val animateItems by Settings.animateItems.collectAsState()
val galleryDetail = remember { EhApplication.galleryDetailCache[gid]!! }
val galleryDetail = remember { detailCache[gid]!! }
val userCommentBackField = remember { mutableStateOf(TextFieldValue()) }
var userComment by userCommentBackField
var commentId by remember { mutableLongStateOf(-1) }
@@ -381,9 +409,9 @@ fun AnimatedVisibilityScope.GalleryCommentsScreen(gid: Long, navigator: Destinat
},
onCardClick = { launch { doCommentAction(item) } },
onUrlClick = { if (!jumpToReaderByPage(it, galleryDetail)) if (!navWithUrl(it)) openBrowser(it) },
) {
text = generateComment(this, item)
}
processComment = { c, s, l -> processComment(c, s, l) },
showImage = true,
)
}
if (comments.hasMore) {
item {
@@ -1,9 +1,8 @@
package com.hippo.ehviewer.ui.screen
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Context
import android.os.Environment
import android.text.TextUtils.TruncateAt.END
import android.content.Intent
import androidx.annotation.StringRes
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.MutatorMutex
@@ -29,7 +28,6 @@ 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
@@ -53,9 +51,9 @@ 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.text.style.TextOverflow
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
@@ -68,10 +66,10 @@ import androidx.paging.compose.itemKey
import androidx.window.core.layout.WindowWidthSizeClass
import arrow.core.partially1
import arrow.fx.coroutines.parMap
import arrow.fx.coroutines.parZip
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
@@ -92,6 +90,8 @@ 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.icons.EhIcons
import com.hippo.ehviewer.icons.filled.Magnet
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ktbuilder.launchIn
import com.hippo.ehviewer.ui.GalleryInfoBottomSheet
@@ -100,7 +100,6 @@ 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
@@ -126,29 +125,28 @@ 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 io.ktor.http.encodeURLParameter
import kotlin.coroutines.resume
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.flow.emptyFlow
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import splitties.systemservices.downloadManager
import moe.tarsin.coroutines.runSwallowingWithUI
context(CoroutineScope, DestinationsNavigator, DialogState, MainActivity, SnackbarHostState, SharedTransitionScope, TransitionsVisibilityScope)
@Composable
@@ -241,7 +239,7 @@ fun GalleryDetailContent(
}
}
val previews = galleryDetail?.let { collectPreviewItems(it, thumbColumns) }
val previews = galleryDetail.collectPreviewItems(thumbColumns)
when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.COMPACT -> FastScrollLazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
@@ -299,7 +297,7 @@ fun GalleryDetailContent(
}
}
}
if (previews != null) {
if (galleryDetail != null) {
galleryPreview(previews) { navToReader(galleryDetail.galleryInfo, it) }
}
}
@@ -367,7 +365,7 @@ fun GalleryDetailContent(
}
}
}
if (previews != null) {
if (galleryDetail != null) {
galleryPreview(previews) { navToReader(galleryDetail.galleryInfo, it) }
}
}
@@ -411,11 +409,10 @@ fun BelowHeader(galleryDetail: GalleryDetail) {
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))
}
maxLines = 5,
overflow = TextOverflow.Ellipsis,
showImage = false,
)
}
Box(
modifier = Modifier
@@ -502,7 +499,7 @@ fun BelowHeader(galleryDetail: GalleryDetail) {
text = stringResource(id = R.string.similar_gallery),
onClick = {
val keyword = EhUtils.extractTitle(galleryDetail.title)
val artistTag = galleryDetail.tags.getArtistTag()
val artistTag = galleryDetail.tagGroups.getArtistTag()
if (null != keyword) {
navigate(
ListUrlBuilder(
@@ -541,58 +538,47 @@ fun BelowHeader(galleryDetail: GalleryDetail) {
},
)
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)
parZip(
{ EhEngine.getTorrentList(galleryDetail.torrentUrl!!, galleryDetail.gid, galleryDetail.token) },
{ EhEngine.getTorrentKey() },
{ list, key -> list to key },
)
}
}
suspend fun showTorrentDialog() {
val torrentList = bgWork { torrentResult.await() }
val (torrentList, key) = bgWork { torrentResult.await() }
if (torrentList.isEmpty()) {
showSnackbar(noTorrents)
} else {
val selected = showNoButton(false) {
TorrentList(
items = torrentList,
onItemClick = { dismissWith(it) },
onItemClick = { resume(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)
val hash = selected.url.dropLast(8).takeLast(40)
val name = selected.name.encodeURLParameter()
val tracker = EhUrl.getTrackerUrl(galleryDetail.gid, key).encodeURLParameter()
val link = "magnet:?xt=urn:btih:$hash&dn=$name&tr=$tracker"
val intent = Intent(Intent.ACTION_VIEW, link.toUri())
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
withUIContext { addTextToClipboard(link, true) }
}
}
}
EhIconButton(
icon = Icons.Default.SwapVerticalCircle,
icon = EhIcons.Default.Magnet,
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)
when {
galleryDetail.torrentCount <= 0 -> showSnackbar(noTorrents)
else -> runSwallowingWithUI { showTorrentDialog() }
}
}
},
@@ -657,7 +643,7 @@ fun BelowHeader(galleryDetail: GalleryDetail) {
}
}
Spacer(modifier = Modifier.size(keylineMargin))
val tags = galleryDetail.tags
val tags = galleryDetail.tagGroups
if (tags.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth(),
@@ -737,18 +723,18 @@ private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt())
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("_")}"
}
val namespace = tagGroup.nameSpace
if (namespace == TagNamespace.Artist || namespace == TagNamespace.Cosplayer) {
return "$namespace:${tagGroup.tags[0].removePrefix("_")}"
}
}
return null
}
context(Context)
@Composable
private fun Context.collectPreviewItems(detail: GalleryDetail, prefetchDistance: Int) = rememberInVM {
private fun GalleryDetail?.collectPreviewItems(prefetchDistance: Int) = rememberInVM(this) {
val detail = this@collectPreviewItems ?: return@rememberInVM emptyFlow()
val pageSize = detail.previewList.size
val pages = detail.pages
val previewPagesMap = detail.previewList.associateBy { it.position } as MutableMap
@@ -8,6 +8,7 @@ import android.net.Uri
import android.os.Environment
import android.os.Parcelable
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.collection.SieveCache
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -40,7 +41,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
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
@@ -66,6 +66,7 @@ import com.hippo.ehviewer.ui.main.ArchiveList
import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip
import com.hippo.ehviewer.ui.navToReader
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.tools.launchInVM
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.AppHelper
@@ -83,6 +84,7 @@ import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlin.coroutines.resume
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -91,6 +93,8 @@ import kotlinx.parcelize.Parcelize
import moe.tarsin.coroutines.runSuspendCatching
import splitties.systemservices.downloadManager
val detailCache = SieveCache<Long, GalleryDetail>(25)
sealed interface GalleryDetailScreenArgs : Parcelable
@Parcelize
@@ -108,46 +112,30 @@ data class TokenArgs(
@Destination<RootGraph>
@Composable
fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
var galleryInfo by rememberInVM {
val casted = args as? GalleryInfoArgs
mutableStateOf<GalleryInfo?>(casted?.galleryInfo)
}
val (gid, token) = remember {
val (gid, token) = remember(args) {
when (args) {
is GalleryInfoArgs -> with(args.galleryInfo) { gid to token }
is TokenArgs -> args.gid to args.token
}
}
val galleryDetailUrl = remember { EhUrl.getGalleryDetailUrl(gid, token, 0, false) }
var showReadAction by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(args, galleryInfo) {
if (showReadAction) {
val page = (args as? TokenArgs)?.page ?: 0
val gi = galleryInfo
if (page != 0 && gi != null) {
showReadAction = false
val result = showSnackbar(
getString(R.string.read_from, page),
getString(R.string.read),
true,
)
if (result == SnackbarResult.ActionPerformed) {
navToReader(gi.findBaseInfo(), page)
}
}
}
}
val galleryDetailUrl = remember(gid, token) { EhUrl.getGalleryDetailUrl(gid, token, 0, false) }
ProvideAssistContent(galleryDetailUrl)
var galleryInfo by rememberInVM {
val casted = args as? GalleryInfoArgs
mutableStateOf<GalleryInfo?>(casted?.galleryInfo)
}
var getDetailError by rememberSaveable { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
if (galleryInfo !is GalleryDetail && getDetailError.isBlank()) {
LaunchedEffect(Unit) {
val galleryDetail = galleryDetailCache[gid]
val galleryDetail = detailCache[gid]
?: runSuspendCatching {
withIOContext { EhEngine.getGalleryDetail(galleryDetailUrl) }
}.onSuccess { galleryDetail ->
galleryDetailCache.put(galleryDetail.gid, galleryDetail)
detailCache[galleryDetail.gid] = galleryDetail
if (Settings.preloadThumbAggressively) {
launchIO {
galleryDetail.previewList.forEach {
@@ -185,7 +173,7 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
showSnackbar(signInFirst)
} else {
runSuspendCatching {
val (paramOr, archiveList, funds) = bgWork { archiveResult.await() }
val (archiveList, funds) = bgWork { archiveResult.await() }
if (archiveList.isEmpty()) {
showSnackbar(noArchive)
} else {
@@ -193,10 +181,10 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
ArchiveList(
funds = funds,
items = archiveList,
onItemClick = { dismissWith(it) },
onItemClick = { resume(it) },
)
}
EhEngine.downloadArchive(gid, token, paramOr, selected.res, selected.isHAtH)?.let {
EhEngine.downloadArchive(gid, token, selected.res, selected.isHAtH)?.let {
val uri = Uri.parse(it)
val intent = Intent().apply {
action = Intent.ACTION_VIEW
@@ -314,7 +302,7 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
onClick = {
dropdown = false
// Invalidate cache
galleryDetailCache.remove(gid)
detailCache.remove(gid)
// Trigger recompose
galleryInfo = galleryInfo?.findBaseInfo()
@@ -397,6 +385,16 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n
) {
val gi = galleryInfo
if (gi != null) {
if (args is TokenArgs && args.page != 0) {
val from = stringResource(id = R.string.read_from, args.page)
val read = stringResource(id = R.string.read)
launchInVM {
val result = showSnackbar(from, read, true)
if (result == SnackbarResult.ActionPerformed) {
navToReader(gi.findBaseInfo(), args.page)
}
}
}
GalleryDetailContent(
galleryInfo = gi,
contentPadding = it,
@@ -1,27 +1,18 @@
package com.hippo.ehviewer.ui.screen
import android.content.Context
import android.net.Uri
import android.view.ViewConfiguration
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.itemsIndexed
@@ -35,15 +26,11 @@ import androidx.compose.material.icons.automirrored.filled.LastPage
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Reorder
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.outlined.Bookmarks
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -64,20 +51,15 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.lerp
import androidx.lifecycle.viewModelScope
import androidx.paging.LoadState
import androidx.paging.Pager
@@ -117,29 +99,24 @@ import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.destinations.ProgressScreenDestination
import com.hippo.ehviewer.ui.doGalleryInfoAction
import com.hippo.ehviewer.ui.main.AdvancedSearchOption
import com.hippo.ehviewer.ui.main.AvatarIcon
import com.hippo.ehviewer.ui.main.FAB_ANIMATE_TIME
import com.hippo.ehviewer.ui.main.FabLayout
import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
import com.hippo.ehviewer.ui.main.GalleryList
import com.hippo.ehviewer.ui.main.ImageSearch
import com.hippo.ehviewer.ui.main.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
import com.hippo.ehviewer.ui.tools.foldToLoadResult
import com.hippo.ehviewer.ui.tools.rememberHapticFeedback
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.ui.tools.rememberMutableStateInDataStore
import com.hippo.ehviewer.ui.tools.snackBarPadding
import com.hippo.ehviewer.ui.tools.thenIf
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.pickVisualMedia
import com.hippo.ehviewer.util.sha1
import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -179,12 +156,10 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
var urlBuilder by rememberSaveable(lub) { mutableStateOf(lub) }
var searchBarExpanded by rememberSaveable { mutableStateOf(false) }
var searchBarOffsetY by remember { mutableIntStateOf(0) }
var showSearchLayout by rememberSaveable { mutableStateOf(false) }
val animateItems by Settings.animateItems.collectAsState()
var category by rememberMutableStateInDataStore("SearchCategory") { EhUtils.ALL_CATEGORY }
var advancedSearchOption by rememberMutableStateInDataStore("AdvancedSearchOption") { AdvancedSearchOption() }
var imageUri by rememberSaveable { mutableStateOf<Uri?>(null) }
DrawerHandle(!searchBarExpanded)
@@ -201,11 +176,6 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
}
}
val animatedSearchLayout by animateFloatMergePredictiveBackAsState(
enable = showSearchLayout,
animationSpec = tween(FAB_ANIMATE_TIME * 2),
onBack = { showSearchLayout = false },
)
val density = LocalDensity.current
val listState = rememberLazyGridState()
val gridState = rememberLazyStaggeredGridState()
@@ -221,7 +191,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
override suspend fun load(params: LoadParams<String>) = withIOContext {
if (urlBuilder.mode == MODE_TOPLIST) {
// TODO: Since we know total pages, let pager support jump
val key = (params.key ?: urlBuilder.mJumpTo)?.toInt() ?: 0
val key = (params.key ?: urlBuilder.jumpTo)?.toInt() ?: 0
val prev = (key - 1).takeIf { it > 0 }
val next = (key + 1).takeIf { it < TOPLIST_PAGES }
runSuspendCatching {
@@ -237,8 +207,8 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
is LoadParams.Refresh -> {
val key = params.key
if (key.isNullOrBlank()) {
if (urlBuilder.mJumpTo != null) {
urlBuilder.mNext ?: urlBuilder.setIndex("2", true)
if (urlBuilder.jumpTo != null) {
urlBuilder.next ?: urlBuilder.setIndex("2", true)
}
} else {
urlBuilder.setIndex(key, false)
@@ -249,7 +219,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
val url = urlBuilder.build()
EhEngine.getGalleryList(url)
}.foldToLoadResult { result ->
urlBuilder.mJumpTo = null
urlBuilder.jumpTo = null
LoadResult.Page(result.galleryInfoList, result.prev, result.next)
}
}
@@ -292,7 +262,6 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
Settings.recentToplist = keyword
urlBuilder = ListUrlBuilder(MODE_TOPLIST, mKeyword = keyword)
data.refresh()
showSearchLayout = false
launch { sheetState.close() }
},
headlineContent = {
@@ -436,7 +405,6 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
}
data.refresh()
}
showSearchLayout = false
launch { sheetState.close() }
},
tonalElevation = 1.dp,
@@ -493,10 +461,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
abstract class UrlSuggestion : Suggestion() {
override val keyword = openGalleryKeyword
override val canOpenDirectly = true
override fun onClick() {
navigator.navigate(destination)
showSearchLayout = false
}
override fun onClick() = navigate(destination)
abstract val destination: Direction
}
@@ -508,41 +473,30 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
override val destination = ProgressScreenDestination(gid, pToken, page)
}
val selectImageFirst = stringResource(R.string.select_image_first)
fun onApplySearch(query: String) = launchIO {
val builder = ListUrlBuilder()
val oldMode = urlBuilder.mode
if (!showSearchLayout) {
// If it's MODE_SUBSCRIPTION, keep it
val newMode = if (oldMode == MODE_SUBSCRIPTION) MODE_SUBSCRIPTION else MODE_NORMAL
builder.mode = newMode
builder.keyword = query
builder.category = category
builder.language = languageFilter
builder.advanceSearch = advancedSearchOption.advanceSearch
builder.minRating = advancedSearchOption.minRating
builder.pageFrom = advancedSearchOption.fromPage
builder.pageTo = advancedSearchOption.toPage
} else {
if (imageUri == null) {
showSnackbar(selectImageFirst)
return@launchIO
}
builder.mode = MODE_IMAGE_SEARCH
builder.hash = imageUri!!.toOkioPath().sha1()
}
// If it's MODE_SUBSCRIPTION, keep it
val newMode = if (oldMode == MODE_SUBSCRIPTION) MODE_SUBSCRIPTION else MODE_NORMAL
builder.mode = newMode
builder.keyword = query
builder.category = category
builder.language = languageFilter
builder.advanceSearch = advancedSearchOption.advanceSearch
builder.minRating = advancedSearchOption.minRating
builder.pageFrom = advancedSearchOption.fromPage
builder.pageTo = advancedSearchOption.toPage
when (oldMode) {
MODE_TOPLIST, MODE_WHATS_HOT -> {
// Wait for search view to hide
delay(300)
withUIContext { navigator.navigate(builder.asDst()) }
withUIContext { navigate(builder.asDst()) }
}
else -> {
urlBuilder = builder
data.refresh()
}
}
showSearchLayout = false
}
SearchBarScreen(
@@ -569,15 +523,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
IconButton(onClick = { launch { sheetState.open() } }) {
Icon(imageVector = Icons.Outlined.Bookmarks, contentDescription = stringResource(id = R.string.quick_search))
}
IconButton(onClick = { showSearchLayout = !showSearchLayout }) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = lerp(135f, 0f, animatedSearchLayout)
},
)
}
AvatarIcon()
},
filter = {
SearchFilter(
@@ -589,64 +535,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
onAdvancedOptionChange = { advancedSearchOption = it },
)
},
floatingActionButton = {
val hiddenState by animateFloatAsState(
targetValue = if (showSearchLayout && !fabHidden) 1f else 0f,
animationSpec = tween(
FAB_ANIMATE_TIME,
if (showSearchLayout && !fabHidden) FAB_ANIMATE_TIME else 0,
),
label = "hiddenState",
)
FloatingActionButton(
onClick = { onApplySearch("") },
modifier = Modifier.snackBarPadding().graphicsLayer {
rotationZ = lerp(90f, 0f, hiddenState)
scaleX = hiddenState
scaleY = hiddenState
},
) {
Icon(imageVector = Icons.Default.Search, contentDescription = null)
}
},
) { contentPadding ->
val layoutDirection = LocalLayoutDirection.current
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v)
ElevatedCard(
modifier = Modifier.graphicsLayer {
scaleX = 1 - animatedSearchLayout
scaleY = 1 - animatedSearchLayout
alpha = 1 - animatedSearchLayout
}.padding(
top = contentPadding.calculateTopPadding() + marginV,
start = contentPadding.calculateStartPadding(layoutDirection) + marginH,
end = contentPadding.calculateEndPadding(layoutDirection) + marginH,
bottom = 8.dp,
).padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v)),
) {
Column(
modifier = Modifier.padding(
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.search_image),
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
ImageSearch(
image = imageUri,
onSelectImage = {
launch { imageUri = pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly) }
},
)
}
}
val height by collectListThumbSizeAsState()
val showPages by Settings.showGalleryPages.collectAsState()
val searchBarConnection = remember {
@@ -666,11 +555,6 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
}
}
GalleryList(
modifier = Modifier.graphicsLayer {
scaleX = animatedSearchLayout
scaleY = animatedSearchLayout
alpha = animatedSearchLayout
},
data = data,
contentModifier = Modifier.nestedScroll(searchBarConnection),
contentPadding = contentPadding,
@@ -708,7 +592,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
val outOfRange = stringResource(R.string.error_out_of_range)
val hideFab by delegateSnapshotUpdate {
record { fabHidden || showSearchLayout }
record { fabHidden }
transform {
// Bug: IDE failed to inference 'hide's type
onEachLatest { hide: Boolean ->
@@ -736,7 +620,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
if (urlBuilder.mode != MODE_WHATS_HOT) {
onClick(EhIcons.Default.GoTo) {
if (isTopList) {
val page = urlBuilder.mJumpTo?.toIntOrNull() ?: 0
val page = urlBuilder.jumpTo?.toIntOrNull() ?: 0
val hint = getString(R.string.go_to_hint, page + 1, TOPLIST_PAGES)
val text = awaitInputText(title = gotoTitle, hint = hint, isNumber = true) { oriText ->
val goto = ensureNotNull(oriText.trim().toIntOrNull()) { invalidNum } - 1
@@ -745,7 +629,7 @@ fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: De
urlBuilder.setJumpTo(text)
} else {
val date = awaitSelectDate()
urlBuilder.mJumpTo = date
urlBuilder.jumpTo = date
}
data.refresh()
}
@@ -0,0 +1,113 @@
package com.hippo.ehviewer.ui.screen
import android.net.Uri
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_IMAGE_SEARCH
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.main.ImageSearch
import com.hippo.ehviewer.ui.main.plus
import com.hippo.ehviewer.ui.tools.snackBarPadding
import com.hippo.ehviewer.util.pickVisualMedia
import com.hippo.ehviewer.util.sha1
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.launchUI
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.coroutines.launch
@Destination<RootGraph>
@Composable
fun AnimatedVisibilityScope.ImageSearchScreen(navigator: DestinationsNavigator) = composing(navigator) {
val selectImageFirst = stringResource(R.string.select_image_first)
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v)
var imageUri by rememberSaveable { mutableStateOf<Uri?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringResource(id = R.string.image_search)) },
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
val uri = imageUri
if (uri != null) {
launchUI {
navigate(
ListUrlBuilder(
mode = MODE_IMAGE_SEARCH,
hash = withIOContext { uri.toOkioPath().sha1() },
).asDst(),
)
}
} else {
launch { showSnackbar(selectImageFirst) }
}
},
modifier = Modifier.snackBarPadding(),
) {
Icon(imageVector = Icons.Default.Search, contentDescription = null)
}
},
) { contentPadding ->
ElevatedCard(
modifier = Modifier.padding(
paddingValues = contentPadding + PaddingValues(marginH, marginV),
).padding(
vertical = dimensionResource(id = R.dimen.search_layout_margin_v),
),
) {
Column(
modifier = Modifier.padding(
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.search_image),
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
ImageSearch(
image = imageUri,
onSelectImage = {
launch { imageUri = pickVisualMedia(ImageOnly) }
},
)
}
}
}
}
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.screen
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
@@ -30,6 +31,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ImageSearch
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -65,11 +67,13 @@ import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.Search
import com.hippo.ehviewer.dao.SearchDao
import com.hippo.ehviewer.ui.LocalNavDrawerState
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.destinations.ImageSearchScreenDestination
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.rememberCompositionActiveState
import com.hippo.ehviewer.ui.tools.thenIf
import com.jamal.composeprefs3.ui.ifNotNullThen
import com.jamal.composeprefs3.ui.ifTrueThen
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import kotlinx.coroutines.Dispatchers
@@ -91,9 +95,9 @@ abstract class Suggestion {
open val canOpenDirectly: Boolean = false
}
suspend fun SearchDao.suggestions(prefix: String, limit: Int) =
(if (prefix.isBlank()) list(limit) else rawSuggestions(prefix, limit))
suspend fun SearchDao.suggestions(prefix: String, limit: Int) = (if (prefix.isBlank()) list(limit) else rawSuggestions(prefix, limit))
context(DialogState, DestinationsNavigator)
@Composable
fun SearchBarScreen(
onApplySearch: (String) -> Unit,
@@ -114,7 +118,6 @@ fun SearchBarScreen(
val mSearchDatabase = searchDatabase.searchDao()
val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
val dialogState = LocalDialogState.current
val animateItems by Settings.animateItems.collectAsState()
class TagSuggestion(
@@ -189,9 +192,7 @@ fun SearchBarScreen(
fun deleteKeyword(keyword: String) {
scope.launch {
dialogState.awaitConfirmationOrCancel(
confirmText = R.string.delete,
) {
awaitConfirmationOrCancel(confirmText = R.string.delete) {
Text(text = stringResource(id = R.string.delete_search_history, keyword))
}
mSearchDatabase.deleteQuery(keyword)
@@ -244,9 +245,15 @@ fun SearchBarScreen(
},
trailingIcon = {
if (expanded) {
if (searchFieldState.text.isNotEmpty()) {
IconButton(onClick = { searchFieldState.clearText() }) {
Icon(Icons.Default.Close, contentDescription = null)
AnimatedContent(targetState = searchFieldState.text.isNotEmpty()) { hasText ->
if (hasText) {
IconButton(onClick = { searchFieldState.clearText() }) {
Icon(Icons.Default.Close, contentDescription = null)
}
} else {
IconButton(onClick = { navigate(ImageSearchScreenDestination) }) {
Icon(Icons.Default.ImageSearch, contentDescription = null)
}
}
}
} else {
@@ -301,7 +308,7 @@ fun wrapTagKeyword(keyword: String, translate: Boolean = false): String = if (ke
val tag = keyword.substringAfter(':')
val prefix = keyword.dropLast(tag.length + 1)
if (translate) {
val namespacePrefix = TagNamespace(prefix).toPrefix()
val namespacePrefix = TagNamespace.from(prefix)?.prefix
val newPrefix = EhTagDatabase.getTranslation(tag = prefix) ?: prefix
val newTag = EhTagDatabase.getTranslation(namespacePrefix, tag) ?: tag
"$newPrefix:$newTag"
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more