Compare commits

..
445 Commits
Author SHA1 Message Date
FooIbar 571969fca9 Prepare for release 1.13.1
Releases / build (push) Has been cancelled
2024-11-11 21:14:29 +08:00
FooIbar 16fa52790c Misc cleanups (#1921)
* Clean up resources

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

* Fixup

---------

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

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

* Fix

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

* Fix

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

* Refine

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

---------

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

* Drop unnecessary logging

* Refine

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

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

---------

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

* Handle open failed on first open

---------

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

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

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

---------

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

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

* Check 404

* Refine

---------

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

* Fix topBarOnDrawerColor

* Refine location

---------

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

* Add Japanese README

* Update ja-jp.md

* Update ja-jp.md

* Update ja-jp.md

* Update ja-jp.md

* Update ja-jp.md

* Update ja-jp.md

* fix text & suffix.

* Update ja.md

* Update ja.md

* Update ja.md

* Update ja.md

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

* ADT Page model

* Refine

* Refine

* Remove type conversion

* Cleanup

---------

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

* Impl

* Refine

* respect ahb align

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

* Don't copy unnecessary things

* Cleanup

* Recycle

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

This reverts commit cea06f0c

* Fix marshmallow build

* Fix cmake

* Address cr

* Refine

* Cleanup

* log it

---------

Signed-off-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: Dude so hot <djohn@fbi.gov>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-09-19 14:25:55 +00:00
FooIbar 02e8fcc534 Fix lint
This reverts commit cea06f0c
2024-09-19 21:54:28 +08:00
renovate[bot]andDude so hot 70852c2725 Crop bitmap before draw (#1646)
* Crop bitmap before draw

* Spilt

* Refine

* Cleanup

* Address code review

* Address code review

* Address code review

---------

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

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

* Framework

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

* Add Infra

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

* Implement

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

* Refine

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

* Impl

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

* Fixes

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

* Implement

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

* Update profile code

* Remove corrent but slow code that slow for 1000x

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

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

This reverts commit 644d093ddc20f4a129ee304157767707337d149d.

* Fix speed

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

* refine

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

* refine

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

* Refine

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

* cleanup type conversion

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

* cleanup

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

* refine

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

* Remove C cropborder impl

* impl nth to accel

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

* F**K

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

---------

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

* Don't spam in debug build

* Cleanup

* Revert "Cleanup"

This reverts commit 45b9d76dd363aa1fd3d58b5102b68649028e313a.

* Revert "Respect Arch's ndk location"

This reverts commit 3fd773422d3ae440a6466e2fa161a45018fd8ed7.

* Respect Arch ndk location

---------

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

* Add QRCodeInterceptor Framework

* Let image take hasQRCode info

* Keep ReaderPage index internal

* Move imageCache to use ReaderPage as key

* Make PageLoader size & pages internal

* Refine

* Kill entry if QRCode

* Rewrite request to respect index mapping

* Debug: enable by default

* Fix crash

* Fixup

* Fix identity

* Revert to lruCache to avoid crash

* Fixup

* Clippy check for android only

* Fixup

* Resolve clippy

* Switch

* Apply switch

* Restart pageloader restore pages

* Cleanup

* Cleanup

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

* let it inference

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

* Don't detect border for ads

* Make coil interceptor runs in `Dispatchers.Default`

* Revert "Restart pageloader restore pages"

This reverts commit 8fd02fb7d9c677c119df72a767a6079f1899dc31.

* Revert drop page changes

* Use backGround color painter for ads

* Only check ads for tags contains extraneous ads

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

* Move strip ADs switch to AdvancedScreen

* Pick placeholder

* Show Placeholder

* Don't try to extract & decode

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

* cleanup

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

* Naming

* Naming

* Naming

* Naming

* For ArchivePageLoader as well

* Hint

* Fix flicker

* Refine

* Naming

* QR -> Qr

* Naming

* QR -> Qr

* Naming

* Refine

* Revert

* Remove header check

* Allow show ads

* Adjust

* Refine

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

This reverts commit f35ef46f6ffc81485cf0f4c646ed79e524c29d32.

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

* be strict

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

* Just Extract, don't decode

* Refine

* Refine placeholder

---------

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

* Fix format
2024-08-27 07:37:05 +08:00
renovate[bot] 4bb1a17d39 Update dependency com.android.tools:desugar_jdk_libs_nio to v2.1.0 2024-08-26 17:43:40 +00:00
renovate[bot] 94d68fe38b Lock file maintenance 2024-08-26 00:56:27 +00:00
renovate[bot] cf6c850d2a Update aboutlibraries to v11.2.3 2024-08-23 11:06:34 +00:00
renovate[bot] c8a3a0fc20 Update dependency sh.calvin.reorderable:reorderable to v2.4.0-alpha02 2024-08-23 01:22:29 +00:00
renovate[bot] 433896b267 Update dependency com.google.devtools.ksp to v2.0.20-1.0.24 2024-08-22 23:11:02 +00:00
renovate[bot] 7e9c1f00ef Update Kotlin & KSP to v2.0.20 2024-08-22 10:56:10 +00:00
renovate[bot] 2b5b0d695a Update dependency me.saket.telephoto:zoomable to v0.13.0 2024-08-22 03:41:35 +00:00
renovate[bot] 2265b0943e Update dependency androidx.core:core-ktx to v1.15.0-alpha02 2024-08-22 01:09:20 +00:00
renovate[bot] 78f12065ab Update dependency dev.chrisbanes.compose:compose-bom to v2024.08.00-alpha02 2024-08-21 22:04:09 +00:00
renovate[bot] f28c45a13a Update dependency androidx.profileinstaller:profileinstaller to v1.4.0-beta01 2024-08-21 22:03:53 +00:00
renovate[bot] e030500917 Update benchmark to v1.3.0 2024-08-21 21:01:47 +00:00
renovate[bot] 9c042cd323 Update androidx.room to v2.7.0-alpha07 2024-08-21 21:01:30 +00:00
FooIbar 44eea7a273 Adapt status bar content color for reader theme 2024-08-21 15:20:25 +08:00
FooIbar 834cfc5e8c Support scrolling with mouse wheel in reader 2024-08-21 15:20:16 +08:00
renovate[bot] 876bb68873 Update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.6.1 2024-08-20 17:10:58 +00:00
FooIbar 40c1b7ef21 Prepare for next iteration 2024-08-19 21:02:30 +08:00
FooIbar 6191ad74d3 Prepare for release 1.12.0
Releases / build (push) Has been cancelled
2024-08-19 21:01:58 +08:00
FooIbar 5aae8f5649 Fix thumbnail resolution setting not working 2024-08-19 17:45:39 +08:00
FooIbar 6886465afb Refine reader
Fix navigation when switching from `WebtoonViewer` to `PagerViewer`.

Hide navigation overlay and reset slider to follow pager on click.
2024-08-19 17:45:26 +08:00
FooIbar 5a46fc4573 Don't recycle visible images 2024-08-19 13:49:36 +08:00
renovate[bot] a08b8e96e5 Lock file maintenance 2024-08-19 01:05:11 +00:00
FooIbar 5aeded7a24 Don't show load state indicator when no hits found 2024-08-18 17:40:48 +08:00
FooIbar 1883b82159 Fix pager unclickability when image is not loaded 2024-08-18 17:40:37 +08:00
FooIbar 302b57d4ac Fix normal preview in exhentai
Remove force e-hentai thumb option due to infeasible.
2024-08-17 00:52:44 +08:00
FooIbar c6b793923e Temporarily disable shared element transitions 2024-08-16 20:20:13 +08:00
FooIbar 058c280fd7 Avoid out-of-screen shared element transitions 2024-08-16 19:45:19 +08:00
renovate[bot] 0fa42d731b Update agp to v8.7.0-alpha07 2024-08-16 00:45:15 +00:00
FooIbar 9b54b57934 Refactor reader prefetch logic (#1523)
* Refactor reader prefetch logic

* Refine
2024-08-16 00:46:44 +08:00
FooIbar d3ab3ba61f [no ci] Update workflow 2024-08-15 18:29:41 +08:00
renovate[bot] 921f570151 Update dependency gradle to v8.10 2024-08-14 12:46:28 +00:00
FooIbar 03ab435401 Fix preload in reader (#1517)
* Fix preload in reader

* Reduce preload page count for ArchivePageLoader
2024-08-14 12:58:06 +08:00
renovate[bot] b73704ede1 Update dependency com.google.devtools.ksp to v2.0.20-RC2-1.0.24 2024-08-14 01:37:30 +00:00
renovate[bot] 97b2181ff7 Update agp to v8.7.0-alpha06 2024-08-13 19:11:16 +00:00
renovate[bot] ef5e3f7c51 Update Kotlin & KSP to v2.0.20-RC2 2024-08-13 13:41:34 +00:00
FooIbar 8a29d46b38 Revert^3 "Update Compose snapshot and migrate to BTF2 (#1474)"
This reverts commit 22b5a175f4.
2024-08-13 18:54:38 +08:00
FooIbar 75d8ff4b64 Fix ComicInfo.xml for galleries with temp tags 2024-08-13 17:46:01 +08:00
FooIbar 33258ea554 Fix read progress insert crash when exiting reader 2024-08-13 17:40:21 +08:00
renovate[bot] a723b4ecd3 Lock file maintenance 2024-08-12 00:15:22 +00:00
renovate[bot] 78a237fb0e Update agp to v8.7.0-alpha05 2024-08-09 02:18:54 +00:00
renovate[bot] 8fe60c0163 Update dependency dev.chrisbanes.compose:compose-bom to v2024.08.00-alpha01 2024-08-08 06:19:26 +00:00
renovate[bot] ffe47b2ff2 Update dependency androidx.lifecycle:lifecycle-runtime-compose to v2.9.0-alpha01 2024-08-08 03:14:46 +00:00
renovate[bot] e05fffb41c Update dependency androidx.biometric:biometric-ktx to v1.4.0-alpha02 2024-08-08 03:14:00 +00:00
renovate[bot] 1fb5de2527 Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha10 2024-08-08 00:40:20 +00:00
renovate[bot] 46790a1fbe Update dependency androidx.profileinstaller:profileinstaller to v1.4.0-alpha02 2024-08-08 00:39:56 +00:00
renovate[bot] af1316f06b Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.0-alpha14 2024-08-07 22:11:39 +00:00
renovate[bot] 24564cac8d Update dependency androidx.paging:paging-compose to v3.3.2 2024-08-07 22:11:14 +00:00
renovate[bot] c4868f137b Update benchmark to v1.3.0-rc01 2024-08-07 20:38:52 +00:00
renovate[bot] 3ccfa0dd5b Update androidx.room to v2.7.0-alpha06 2024-08-07 20:38:26 +00:00
FooIbar fded0fd08d Fix GitHub authorization and clean up code 2024-08-07 18:37:51 +08:00
renovate[bot] b1afe9bcd1 Update agp to v8.7.0-alpha04 2024-08-06 22:54:54 +00:00
FooIbar 22b5a175f4 Reapply "Update Compose snapshot and migrate to BTF2 (#1474)"
This reverts commit 94b138f254.
2024-08-06 23:01:20 +08:00
FooIbar ee9e0df12e [no ci] Fix release workflow 2024-08-06 22:03:30 +08:00
FooIbar dd525baf88 Use encoded token to avoid detection & revocation (#1492) 2024-08-06 21:33:46 +08:00
FooIbar 9180603ead Fix a division by zero error in ScrollbarAdapter 2024-08-06 21:30:12 +08:00
renovate[bot] 66fe19369e Lock file maintenance 2024-08-05 00:38:20 +00:00
renovate[bot] b9db025f57 Update gradle/actions action to v4 2024-08-04 00:54:28 +00:00
FooIbar 44f6eca3aa Fix main thread IO (really) and float comparison 2024-08-03 14:55:05 +08:00
FooIbar 1ff1caf421 Fix list item container color
Also compile debug build with SDK 35 as sources for SDK 35 have been
available.
2024-08-03 08:42:33 +08:00
github-actions[bot] ec1355c0e7 Update Chrome version for default User-Agent 2024-08-01 11:21:29 +08:00
FooIbar d23a453f71 Don't let navigation overlay consume click event
So it won't take two actions to dismiss overlay & app bar.
2024-08-01 07:25:47 +08:00
FooIbar 94b138f254 Revert "Update Compose snapshot and migrate to BTF2 (#1474)"
This reverts commit abcef9a86e.
2024-08-01 03:06:53 +08:00
FooIbar c281daa4ea Update NDK to r27 2024-08-01 01:42:33 +08:00
FooIbar 4f9eba8c63 Update Kotlin and remove workaround 2024-08-01 01:08:41 +08:00
FooIbar a2547f1e8d Update page range filter limit 2024-07-31 18:54:51 +08:00
FooIbar abcef9a86e Update Compose snapshot and migrate to BTF2 (#1474)
* Update Compose snapshot and remove workaround

* Sync SearchBarInputField with upstream

* Migrate to BTF2 text fields

* Fix search page range error text container color

* Update snapshot to fix a SieveCache crash

https://r.android.com/3199791
2024-07-31 17:40:09 +08:00
FooIbar 7d6a9b10e4 Fix crash when opening archives simultaneously 2024-07-30 16:27:16 +08:00
renovate[bot] 9322b80790 Update dependency com.google.devtools.ksp to v2.0.0-1.0.24 2024-07-30 00:51:18 +00:00
FooIbar e12897bd13 Fix layout issue with comments that contain images 2024-07-29 21:59:40 +08:00
FooIbar 28279cc671 Fix page transitions when reading with volume keys 2024-07-29 18:19:43 +08:00
FooIbar 2531fa289b Update dependency sh.calvin.reorderable:reorderable to v2.4.0-alpha01 2024-07-29 10:47:13 +08:00
renovate[bot] 9673163e8f Lock file maintenance 2024-07-29 00:55:33 +00:00
renovate[bot] 3f7091f880 Update dependency sh.calvin.reorderable:reorderable to v2.3.0 2024-07-28 20:01:11 +00:00
FooIbar 5df75fd534 Don't save baseline profile in sources (#1462)
* Don't save baseline profile in sources

* Fixup
2024-07-28 17:25:36 +00:00
FooIbarandGG Bond 62061e54ee Add shared element transitions (#1049)
* Add shared element transitions

* Fixup CodeStyle

* Add a overload for `Modifier.sharedBounds`

* Add TransitionsVisibilityScope to hold multiple transition

* advance AnimatedVisibilityScope to TransitionsVisibilityScope

* composing use advance

* Correctly remember

* Convert to use `Set`

* share SharedContentState

* Refine

* Cleanup

* Cleanup

* Update snapshot

* Temporarily disable navigation transition

* Fix build

* Make `TransitionsVisibilityScope` a value class

* Naming

* Use `implicit` instead

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-07-28 14:11:47 +00:00
github-actions[bot] 436c07fb6b Update baseline profile 2024-07-28 21:34:06 +08:00
FooIbar a16f873f7d Fix crash when switching reading mode 2024-07-28 21:15:09 +08:00
FooIbar 98fdcf0588 Fix double free on closing failed-to-open archives 2024-07-28 20:48:56 +08:00
renovate[bot] 539ad1af30 Fix read encrypted archive 2024-07-28 12:05:04 +00:00
FooIbar 4370606977 [Reader] Show progress indicator & fix background 2024-07-28 19:45:18 +08:00
FooIbar 1ecdc07655 Graduate Compose Reader to stable 2024-07-28 17:29:50 +08:00
FooIbar 01a1113a07 [New Reader] Avoid initial scroll on reader start 2024-07-28 17:14:07 +08:00
renovate[bot] fd6c2977b0 Update plugin composeCompilerReportGenerator to v1.4.0 2024-07-28 08:50:51 +00:00
FooIbar 02e36e7021 [New Reader] Implement open archive intent 2024-07-28 14:00:23 +08:00
FooIbar 7f8e4aa003 [New Reader] Hide menu on scroll 2024-07-28 14:00:23 +08:00
FooIbar 8ce16bc918 [New Reader] Implement navigation overlay (#1445)
* [New Reader] Implement navigation overlay

* Fix typo

* Refine

* Reuse navigation regions
2024-07-26 23:45:41 +08:00
renovate[bot] fbe35a0fa0 Update agp to v8.7.0-alpha03 2024-07-25 23:43:05 +00:00
renovate[bot] 931f1ec514 Update dependency dev.chrisbanes.compose:compose-bom to v2024.07.00-alpha02 2024-07-25 17:31:31 +00:00
renovate[bot] 7c4a59e9a4 Update compose.destinations to v2.1.0-beta11 2024-07-25 13:33:39 +00:00
FooIbar c1c6f25763 [New Reader] Refine landscape zoom (#1442) 2024-07-25 18:53:16 +08:00
renovate[bot] e7f515c70f Update dependency androidx.paging:paging-compose to v3.3.1 2024-07-24 19:48:04 +00:00
renovate[bot] 64fecbfa52 Update dependency androidx.lifecycle:lifecycle-process to v2.8.4 2024-07-24 17:18:31 +00:00
renovate[bot] c6c181a9fa Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha09 2024-07-24 22:05:22 +08:00
FooIbar 53a1bd9959 [New Reader] Implement landscape zoom 2024-07-24 20:36:43 +08:00
FooIbar 7b1f94cf5a [New Reader] Implement custom image scale types 2024-07-24 09:02:45 +08:00
renovate[bot] 4b8ecbf6a7 Update agp to v8.7.0-alpha02 2024-07-22 21:19:13 +00:00
renovate[bot] 0e0b0519e7 Lock file maintenance 2024-07-22 02:12:22 +00:00
FooIbar 7fb3b7c785 Tweak baseline profile generation schedule
[no ci]
2024-07-21 15:00:14 +08:00
renovate[bot]andFooIbar e18ebb1feb Update compose.destinations to v2.1.0-beta10 (#1435)
* Update compose.destinations to v2.1.0-beta10

* 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-07-21 06:30:57 +00:00
FooIbar f76dcfc97b [New Reader] Misc improvements 2024-07-21 08:30:32 +08:00
renovate[bot] c0c630bd9f Update dependency me.saket.telephoto:zoomable to v0.12.1 2024-07-20 09:00:29 +00:00
FooIbar 04df7da5dc Remove obsolete workaround 2024-07-17 17:38:01 +08:00
FooIbar d156638a79 Fix comment screen refresh indicator padding 2024-07-17 17:33:44 +08:00
FooIbar c78fe0b72c [New Reader] Implement touch navigation 2024-07-17 17:32:08 +08:00
renovate[bot] 799c37ea71 Update dependency sh.calvin.reorderable:reorderable to v2.2.0 2024-07-17 05:42:34 +00:00
renovate[bot] 9559ca571b Update agp 2024-07-15 19:40:24 +00:00
renovate[bot] 2f5c1e11c6 Lock file maintenance 2024-07-15 01:07:35 +00:00
renovate[bot] 734c73fa4d Update dependency me.saket.telephoto:zoomable to v0.12.0 2024-07-13 19:51:02 +00:00
renovate[bot] c09173d62c Update dependency com.google.devtools.ksp to v2.0.0-1.0.23 2024-07-11 21:35:55 +00:00
renovate[bot] 533f50ed08 Update dependency gradle to v8.9 2024-07-11 18:03:51 +00:00
Dude so hot b4422fe910 Drop MAP_UNINITIALIZED
This is not available on modern MMU devices
2024-07-11 21:31:34 +08:00
Dude so hot 22e58a034f Fix memleak while do archive 2024-07-11 21:12:31 +08:00
FooIbar 784856d341 Revert "Update agp to v8.6.0-beta01"
This reverts commit b982705b2f.
2024-07-11 08:45:00 +08:00
renovate[bot] f82832ca36 Update dependency dev.chrisbanes.compose:compose-bom to v2024.07.00-alpha01 2024-07-11 00:15:30 +00:00
renovate[bot] 6a70aa792e Update benchmark to v1.3.0-beta02 2024-07-10 19:10:50 +00:00
renovate[bot] a0016dbf85 Update androidx.room to v2.7.0-alpha05 2024-07-10 19:10:30 +00:00
renovate[bot] b982705b2f Update agp to v8.6.0-beta01 2024-07-10 17:01:40 +00:00
FooIbar 4c36398bed Prepare for next iteration 2024-07-10 22:13:25 +08:00
294 changed files with 8537 additions and 91974 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
-84
View File
@@ -1,84 +0,0 @@
name: Baseline profile generation
on:
schedule:
- cron: '30 0 * * 0'
workflow_dispatch:
push:
branches:
- '**'
paths:
- 'benchmark/**'
- '.github/workflows/baseline-profile.yml'
jobs:
baseline-profile:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Free disk space
run: rm -rf ${ANDROID_SDK_ROOT}/ndk
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- 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
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: "./app/src/main/rust"
shared-key: ${{ runner.os }}
save-if: "false"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
cache-read-only: true
- name: CMake Cache
uses: actions/cache/restore@v4
with:
path: app/.cxx
key: cmake-
- 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: Build
run: ./gradlew generateBaselineProfile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
- name: Update baseline profile
if: github.ref == 'refs/heads/main'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
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/baseline-profile
if git commit -am "Update baseline profile"; then
git push
gh pr create -f
fi
- name: Upload reports
if: always()
uses: actions/upload-artifact@v4
with:
name: reports
path: benchmark/build/reports
+11 -24
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
@@ -35,16 +35,13 @@ jobs:
- name: Clippy Check
working-directory: ./app/src/main/rust
run: cargo clippy --all-targets --all-features -- -D warnings
run: cargo clippy --target aarch64-linux-android --all-features -- -D warnings
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: true
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- name: Spotless Check
run: ./gradlew spotlessCheck
@@ -63,11 +60,8 @@ jobs:
distribution: temurin
java-version: 21
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- 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
@@ -77,21 +71,18 @@ jobs:
save-if: "false"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
gradle-home-cache-cleanup: true
- name: CMake Cache
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
env:
GITHUB_TOKEN: ${{ secrets.GithubToken }}
run: ./gradlew assembleDefaultRelease
- name: Upload ARM64
@@ -113,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 }}
@@ -136,11 +128,8 @@ jobs:
distribution: temurin
java-version: 21
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- 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
@@ -150,21 +139,18 @@ jobs:
save-if: "false"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
gradle-home-cache-cleanup: true
- name: CMake Cache
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
env:
GITHUB_TOKEN: ${{ secrets.GithubToken }}
run: ./gradlew assembleMarshmallowRelease
- name: Upload ARM64
@@ -186,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 }}
+22 -11
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,11 +27,8 @@ jobs:
distribution: temurin
java-version: 21
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- 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
@@ -30,18 +36,23 @@ 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: Gradle Build
uses: gradle/actions/setup-gradle@v3
env:
GITHUB_TOKEN: ${{ secrets.GithubToken }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
arguments: assembleRelease
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
- name: Generate Baseline Profile
run: ./gradlew generateBaselineProfile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
- name: Build
run: ./gradlew assembleRelease
- name: Rename Apks
run: |
@@ -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
+107
View File
@@ -1,5 +1,112 @@
# 变更日志
## [1.13.1] - 2024-11-11
### 重大变更
* 由于网站限制,移除搜索封面功能
* 移除桌面版网站选项,默认使用 1280x 分辨率
### 改进
* 更新日本语翻译
* 优化压缩包读取
* 缓解某些小米系统上应用闪退 [#1826](https://github.com/FooIbar/EhViewer/issues/1826)
* 杂项优化
### Bug 修复
* Android 9 上的下载失败误报 [#1914](https://github.com/FooIbar/EhViewer/issues/1914)
## [1.13.0] - 2024-11-01
### 重大变更
* 不再捆绑 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
### 新功能
* 屏蔽含有二维码的图片(位于高级设置)
### 改进
* 移除高级设置中的自定义 User Agent,改为桌面版网站选项
* 画廊预览与画廊详情页面合并
* 为快速搜索和下载标签列表添加了滚动条
* 无网络条件下启动时自动转到下载页面
* 杂项优化
### Bug 修复
* 从左到右或右到左模式观看使用鼠标滚轮无法翻页 [#1541](https://github.com/FooIbar/EhViewer/issues/1541)
* 状态栏颜色未跟随阅读器主题变化 [#1542](https://github.com/FooIbar/EhViewer/issues/1542)
* 打开 EH 设置时闪退 [#1563](https://github.com/FooIbar/EhViewer/issues/1563)
* 画廊种子过多时显示没有种子 [#1561](https://github.com/FooIbar/EhViewer/issues/1561)
* 加载大于 100 MB 的位图时崩溃
* 阅读器加载小圆圈在黑色背景下不自动变色 [#1579](https://github.com/FooIbar/EhViewer/issues/1579)
* 因闪退造成的无法挽回的下载失败 [#1602](https://github.com/FooIbar/EhViewer/issues/1602)
* 某些情况下闪退 [#1548](https://github.com/FooIbar/EhViewer/issues/1548) [#1555](https://github.com/FooIbar/EhViewer/issues/1555) [#1568](https://github.com/FooIbar/EhViewer/issues/1568)
* Android 7.0 及以下的设备无法加载图片 [#1633](https://github.com/FooIbar/EhViewer/issues/1633)
* 某些情况下导入数据失败 [#1634](https://github.com/FooIbar/EhViewer/issues/1634)
* 下载时创建重复文件夹 [#1619](https://github.com/FooIbar/EhViewer/issues/1619)
* 滚动方向改变后预载图片不生效 [#1560](https://github.com/FooIbar/EhViewer/issues/1560)
* 选择无效的下载位置后闪退
* 杂项修复
## [1.12.0] - 2024-08-19
### 重大变更
* Compose 阅读器已稳定,移除了旧阅读器实现 [#847](https://github.com/FooIbar/EhViewer/issues/847)
* 由于 E 站缩略图服务器变更,移除了强制使用 e-hentai 缩略图服务器的选项
### 改进
* 更新高级搜索选项中页数过滤器的范围限制以遵循 E 站变更
### Bug 修复
* 评论页面刷新指示器边距不正确 [#1428](https://github.com/FooIbar/EhViewer/issues/1428)
* 关闭打开失败的压缩包时崩溃
* 无法打开带密码的压缩包 [#1454](https://github.com/FooIbar/EhViewer/issues/1454)
* 使用音量键翻页时无法关闭过渡动画 [#1467](https://github.com/FooIbar/EhViewer/issues/1467)
* 带有图片的评论显示错位 [#1468](https://github.com/FooIbar/EhViewer/issues/1468)
* 在包含相同项目的页面间切换时崩溃 [#1490](https://github.com/FooIbar/EhViewer/issues/1490)
* 无法检查更新 [#1512](https://github.com/FooIbar/EhViewer/issues/1512)
* 画廊详情未加载完成时退出阅读器导致崩溃 [#1507](https://github.com/FooIbar/EhViewer/issues/1507)
* 无法为带有临时标签的画廊生成 ComicInfo.xml [#1509](https://github.com/FooIbar/EhViewer/issues/1509)
* 已下载的画廊翻页时出现加载动画 [#1510](https://github.com/FooIbar/EhViewer/issues/1510)
* 画廊详情页面的缩略图无法加载 [#1529](https://github.com/FooIbar/EhViewer/issues/1529)
* 图片未加载完成时无法通过点击翻页
* 搜索结果为空时解析失败 [#1418](https://github.com/FooIbar/EhViewer/issues/1418)
* 缩略图分辨率设置不起作用
## [1.11.7] - 2024-07-10
### Bug 修复
+11 -7
View File
@@ -8,6 +8,10 @@
<a href="/docs/README/zh-tw.md">
正體中文
</a>
<span> | </span>
<a href="/docs/README/ja.md">
日本語
</a>
</p>
<h1 align="center">
@@ -59,17 +63,19 @@
# Description
EhViewer fork dedicated to lightweight and high-performance
A modern EhViewer fork dedicated to high-performance
with [Material Design 3](https://m3.material.io/)
and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview) Support
# Download
| Flavor | Feature |
|-------------|----------------------------------|
| Default | Android 8.0+, full support |
| Marshmallow | Android 6.0-7.1, limited support |
| Flavor | Minimum Android Version | Notes |
|-------------|-------------------------|-------------------------------------------|
| Default | 8.0 | Animated WebP support requires Android 9+ |
| Marshmallow | 6.0* | Limited support, no guarantees |
*Devices running Android 6 need to have the [ISRG Root X1](https://letsencrypt.org/certs/isrgrootx1.pem) certificate installed
<a href="https://github.com/FooIbar/EhViewer/releases">
<img alt="Get it on GitHub" src="https://github.com/Ehviewer-Overhauled/Art/blob/master/get-it-on-github.svg" width="200px"/>
@@ -94,8 +100,6 @@ Here is the libraries
- [libarchive](https://www.libarchive.org/)
- [jsoup](https://jsoup.org/)
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
# License
Copyright 2014-2019 Hippo Seven
+46 -58
View File
@@ -1,6 +1,6 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag
val isRelease: Boolean
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
@@ -21,10 +21,23 @@ plugins {
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
android {
compileSdk = if (isRelease) 35 else 34
buildToolsVersion = "35.0.0"
ndkVersion = "27.0.11902837-rc1"
androidResources.generateLocaleConfig = true
androidResources {
generateLocaleConfig = true
localeFilters += listOf(
"zh",
"zh-rCN",
"zh-rHK",
"zh-rTW",
"es",
"ja",
"ko",
"fr",
"de",
"th",
"tr",
"nb-rNO",
)
}
splits {
abi {
@@ -61,41 +74,18 @@ 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 githubToken = gradleLocalProperties(rootDir, providers)["GITHUB_TOKEN"] as? String
?: System.getenv("GITHUB_TOKEN").orEmpty()
val chromeVersion = rootProject.layout.projectDirectory.file("chrome.version").asFile.readText().trim()
defaultConfig {
applicationId = "moe.tarsin.ehviewer"
minSdk = 26
targetSdk = 35
versionCode = 180058
versionName = "1.11.7"
versionCode = 180062
versionName = "1.13.1"
// versionNameSuffix = "-SNAPSHOT"
resourceConfigurations.addAll(
listOf(
"zh",
"zh-rCN",
"zh-rHK",
"zh-rTW",
"es",
"ja",
"ko",
"fr",
"de",
"th",
"tr",
"nb-rNO",
),
)
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
buildConfigField("long", "COMMIT_TIME", commitTime)
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
buildConfigField("String", "CHROME_VERSION", "\"$chromeVersion\"")
buildConfigField("String", "GITHUB_TOKEN", "\"$githubToken\"")
ndk {
if (isRelease) {
abiFilters.addAll(supportedAbis)
@@ -140,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
@@ -153,9 +156,6 @@ android {
}
debug {
applicationIdSuffix = ".debug"
lint {
abortOnError = false
}
}
create("benchmarkRelease") {
initWith(buildTypes.getByName("release"))
@@ -169,26 +169,13 @@ android {
buildFeatures {
buildConfig = true
compose = true
viewBinding = true
}
namespace = "com.hippo.ehviewer"
}
composeCompiler {
enableNonSkippingGroupOptimization = true
enableStrongSkippingMode = true
}
androidComponents {
onVariants(selector().withBuildType("release")) {
it.packaging.resources.excludes.addAll(
"/META-INF/**",
"/kotlin/**",
"**.txt",
"**.bin",
)
}
featureFlags = setOf(ComposeFeatureFlag.OptimizeNonSkippingGroups)
}
baselineProfile {
@@ -223,19 +210,20 @@ dependencies {
// https://developer.android.com/jetpack/androidx/releases/paging
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.viewpager2)
// https://developer.android.com/jetpack/androidx/releases/room
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.work.runtime)
implementation(libs.photoview)
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,9 +234,7 @@ dependencies {
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.accompanist.drawable.painter)
implementation(libs.insetter) // Dead Dependency
// implementation(libs.reorderable)
implementation(libs.reorderable)
implementation(platform(libs.arrow.stack))
implementation(libs.arrow.fx.coroutines)
@@ -260,7 +246,7 @@ dependencies {
implementation(libs.telephoto.zoomable)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.bundles.kotlinx.serialization)
@@ -270,8 +256,6 @@ dependencies {
coreLibraryDesugaring(libs.desugar)
implementation(libs.cronet.embedded)
implementation(libs.androidx.profileinstaller)
"baselineProfile"(project(":benchmark"))
@@ -287,14 +271,18 @@ kotlin {
"-progressive",
"-Xjvm-default=all",
"-Xcontext-receivers",
"-Xwhen-guards",
"-Xsuppress-warning=CONTEXT_RECEIVERS_DEPRECATED",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-opt-in=androidx.compose.animation.ExperimentalSharedTransitionApi",
"-opt-in=androidx.paging.ExperimentalPagingApi",
"-opt-in=kotlin.contracts.ExperimentalContracts",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
-7
View File
@@ -11,16 +11,9 @@
public static final ** CREATOR;
}
# https://issuetracker.google.com/347963245
-keep class * extends androidx.room.RoomDatabase {
<init>();
}
# 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*
+14 -20
View File
@@ -70,26 +70,6 @@
<data android:host="exhentai.org" />
<data android:host="e-hentai.org" />
</intent-filter>
<intent-filter android:label="@string/keyword_search">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:label="@string/image_search">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity
android:name="eu.kanade.tachiyomi.ui.reader.ReaderActivity"
android:configChanges="screenSize"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -123,6 +103,20 @@
<data android:mimeType="application/x-lzma-compressed-tar" />
<data android:mimeType="application/x-xz-compressed-tar" />
</intent-filter>
<intent-filter android:label="@string/keyword_search">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:label="@string/image_search">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity
android:name=".ui.SecurityActivity"
+17 -7
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}
)
@@ -44,10 +45,12 @@ set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}"
ExternalProject_Add(
nettle
PREFIX nettle
URL https://ftp.gnu.org/gnu/nettle/nettle-3.10.tar.gz
URL_MD5 c61453139d5fb44e9cdcc5b684b26e55
GIT_REPOSITORY https://gitlab.com/gnutls/nettle.git
GIT_TAG nettle_3.10_release_20240616
GIT_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
@@ -89,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.4
GIT_TAG v3.7.7
GIT_SHALLOW 1
PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH}
)
@@ -100,12 +104,18 @@ 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 android-26)
else ()
corrosion_set_features(ehviewer_rust FEATURES android)
endif ()
# Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c hash.c natsort/strnatcmp.c)
add_library(${PROJECT_NAME} SHARED archive.c gifutils.c hash.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics -Wl,--exclude-libs,ALL)
+154 -187
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2022 Tarsin Norbin
* Copyright 2022-2024 Tarsin Norbin
*
* This file is part of EhViewer
*
@@ -45,31 +45,25 @@ typedef struct {
typedef struct {
const char *filename;
int index;
size_t size;
ssize_t size;
void *addr;
} entry;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static archive_ctx **ctx_pool = NULL;
#define CTX_POOL_SIZE 20
#define MAX_PARALLEL_DECOMP 4
#define max(a, b) ((a) > (b) ? (a) : (b))
static void *mempool = NULL;
static size_t *mempoolofs = NULL;
static int page_size = 0;
#define PAGE_ALIGN(x) ((x + page_size - 1) & ~(page_size - 1))
#define MEMPOOL_ADDR_BY_SORTED_IDX(x) (mempool + (index ? mempoolofs[index - 1] : 0))
#define MEMPOOL_SIZE (mempoolofs[entryCount - 1])
#define PROT_RW (PROT_WRITE | PROT_READ)
#define MAP_ANON_POOL (MAP_ANONYMOUS | MAP_NORESERVE | MAP_PRIVATE | MAP_UNINITIALIZED)
static pthread_mutex_t ctx_pool_mutex = PTHREAD_MUTEX_INITIALIZER;
static archive_ctx **ctx_pool = NULL;
static pthread_mutex_t buffer_mutex = PTHREAD_MUTEX_INITIALIZER;
static void *decode_buffer[MAX_PARALLEL_DECOMP];
static bool need_encrypt = false;
static char *passwd = NULL;
static void *archiveAddr = NULL;
static void *archiveAddr = MAP_FAILED;
static size_t archiveSize = 0;
static entry *entries = NULL;
static size_t entryCount = 0;
static ssize_t max_file_size = 0;
const char supportExt[10][6] = {
"jpeg",
@@ -117,56 +111,68 @@ static inline int compare_entries(const void *a, const void *b) {
return strnatcmp(fa, fb);
}
static long archive_map_entries_index(archive_ctx *ctx, bool sort) {
long count = 0;
#define ADDR_IN_FILE_MAPPING(addr) (addr >= archiveAddr && addr < archiveAddr + archiveSize)
static bool fill_entry_zero_copy(struct archive *arc, entry *entry) {
void *buffer = NULL;
size_t buffer_size = 0;
la_int64_t output_ofs = 0;
archive_read_data_block(arc, (const void **) &buffer, &buffer_size, &output_ofs);
bool zero_copy = ADDR_IN_FILE_MAPPING(buffer) && !output_ofs && buffer_size == entry->size;
entry->addr = zero_copy ? buffer : NULL;
return zero_copy;
}
static void archive_map_entries_index(archive_ctx *ctx, bool sort) {
int count = 0;
bool zero_copy = true;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
const char *name = archive_entry_pathname2(ctx->entry);
if (archive_entry_is_file(ctx->entry) && filename_is_playable_file(name)) {
entries[count].filename = strdup(name);
entries[count].index = count;
entries[count].size = archive_entry_size(ctx->entry);
ssize_t size = archive_entry_size(ctx->entry);
max_file_size = max(size, max_file_size);
entries[count].size = size;
// We don't expect zero copy if first content can't do zero copy
if (zero_copy) zero_copy = fill_entry_zero_copy(ctx->arc, &entries[count]);
count++;
}
}
if (sort)
qsort(entries, entryCount, sizeof(entry), compare_entries);
return count;
if (sort) qsort(entries, entryCount, sizeof(entry), compare_entries);
}
static bool archive_prealloc_mempool() {
mempoolofs = calloc(entryCount, sizeof(size_t));
for (int i = 0; i < entryCount; ++i) {
if (!i)
mempoolofs[i] = PAGE_ALIGN(entries[i].size);
else
mempoolofs[i] = PAGE_ALIGN(entries[i].size) + mempoolofs[i - 1];
static void *acquire_decode_buffer() {
void *addr = NULL;
pthread_mutex_lock(&buffer_mutex);
for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) {
addr = decode_buffer[i];
if (addr) {
decode_buffer[i] = NULL;
break;
}
}
mempool = mmap(0, MEMPOOL_SIZE, PROT_RW, MAP_ANON_POOL, -1, 0);
if (mempool == MAP_FAILED) {
LOGE("%s%s", "mmap failed with error ", strerror(errno));
return false;
pthread_mutex_unlock(&buffer_mutex);
if (!addr) addr = malloc(max_file_size);
return addr;
}
static void release_decode_buffer(void *buffer) {
pthread_mutex_lock(&buffer_mutex);
for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) {
void *addr = decode_buffer[i];
if (!addr) {
decode_buffer[i] = buffer;
pthread_mutex_unlock(&buffer_mutex);
return;
}
}
return true;
pthread_mutex_unlock(&buffer_mutex);
free(buffer);
}
#define ADDR_IN_FILE_MAPPING(addr) (addr >= archiveAddr && addr < archiveAddr + archiveSize)
static void mempool_release_pages(void *addr, size_t size) {
size = PAGE_ALIGN(size);
madvise_log_if_error(addr, size, MADV_DONTNEED);
}
static bool kernel_can_prefault = true;
static void mempool_prefault_pages(void *addr, size_t size) {
size = PAGE_ALIGN(size);
if (kernel_can_prefault) {
if (madvise(addr, size, MADV_POPULATE_WRITE)) kernel_can_prefault = false;
}
}
static long archive_list_all_entries(archive_ctx *ctx) {
long count = 0;
static int archive_list_all_entries(archive_ctx *ctx) {
int count = 0;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK)
if (archive_entry_is_playable(ctx->entry))
count++;
@@ -181,18 +187,10 @@ static void archive_release_ctx(archive_ctx *ctx) {
}
}
static int archive_alloc_ctx(archive_ctx **ctxptr) {
archive_ctx *ctx;
int err;
ctx = calloc(1, sizeof(archive_ctx));
if (!ctx)
return -ENOMEM;
static archive_ctx *archive_alloc_ctx() {
archive_ctx *ctx = calloc(1, sizeof(archive_ctx));
ctx->arc = archive_read_new();
ctx->using = 1;
if (!ctx->arc) {
free(ctx);
return -ENOMEM;
}
archive_read_support_format_tar(ctx->arc);
archive_read_support_format_7zip(ctx->arc);
archive_read_support_format_rar5(ctx->arc);
@@ -202,14 +200,14 @@ static int archive_alloc_ctx(archive_ctx **ctxptr) {
archive_read_set_option(ctx->arc, "zip", "ignorecrc32", "1");
if (passwd)
archive_read_add_passphrase(ctx->arc, passwd);
err = archive_read_open_memory(ctx->arc, archiveAddr, archiveSize);
if (err) {
int err = archive_read_open_memory(ctx->arc, archiveAddr, archiveSize);
if (err < ARCHIVE_OK) {
LOGE("%s%s", "Open archive failed: ", archive_error_string(ctx->arc));
archive_read_free(ctx->arc);
free(ctx);
return err;
return NULL;
}
*ctxptr = ctx;
return 0;
return ctx;
}
static int archive_skip_to_index(archive_ctx *ctx, int index) {
@@ -226,7 +224,7 @@ static int archive_skip_to_index(archive_ctx *ctx, int index) {
static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
int ret;
archive_ctx *ctx = NULL;
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&ctx_pool_mutex);
for (int i = 0; i < CTX_POOL_SIZE; i++) {
if (!ctx_pool[i])
continue;
@@ -241,16 +239,14 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
}
if (ctx)
ctx->using = 1;
pthread_mutex_unlock(&mutex);
pthread_mutex_unlock(&ctx_pool_mutex);
if (!ctx) {
archive_ctx *victimCtx = NULL;
int victimIdx = 0;
int replace = 1;
ret = archive_alloc_ctx(&ctx);
if (ret)
return ret;
pthread_mutex_lock(&mutex);
ctx = archive_alloc_ctx();
pthread_mutex_lock(&ctx_pool_mutex);
for (int i = 0; i < CTX_POOL_SIZE; i++) {
if (!ctx_pool[i]) {
ctx_pool[i] = ctx;
@@ -265,7 +261,7 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
}
}
if (replace) ctx_pool[victimIdx] = ctx;
pthread_mutex_unlock(&mutex);
pthread_mutex_unlock(&ctx_pool_mutex);
if (replace) archive_release_ctx(victimCtx);
}
ret = archive_skip_to_index(ctx, idx);
@@ -283,7 +279,6 @@ JNIEXPORT jint JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd, jlong size, jboolean sort_entries) {
EH_UNUSED(env);
EH_UNUSED(thiz);
page_size = getpagesize();
archive_ctx *ctx = NULL;
archiveAddr = mmap(0, size, PROT_READ, MAP_PRIVATE, fd, 0);
if (archiveAddr == MAP_FAILED) {
@@ -292,109 +287,72 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
}
archiveSize = size;
ctx_pool = calloc(CTX_POOL_SIZE, sizeof(archive_ctx **));
if (!ctx_pool) {
LOGE("Allocate archive ctx pool failed:ENOMEM");
ctx = archive_alloc_ctx();
if (!ctx) return 0;
entryCount = archive_list_all_entries(ctx);
LOGI("%s%zu%s", "Found ", entryCount, " images in archive");
if (!entryCount) {
archive_release_ctx(ctx);
return 0;
}
long r = archive_alloc_ctx(&ctx);
if (r) {
if (r == -ENOMEM)
LOGE("%s", "Mem alloc failed");
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
} else {
entryCount = archive_list_all_entries(ctx);
LOGI("%s%zu%s", "Found ", entryCount, " image entries in archive");
if (!entryCount) {
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
archive_release_ctx(ctx);
return 0;
}
// We must read through the file|vm then we can know whether it is encrypted
int encryptRet = archive_read_has_encrypted_entries(ctx->arc);
switch (encryptRet) {
case 1: // At lease 1 encrypted entry
need_encrypt = true;
break;
case 0: // format supports but no encrypted entry found
default:
need_encrypt = false;
}
int format = archive_format(ctx->arc);
switch (format) {
case ARCHIVE_FORMAT_ZIP:
case ARCHIVE_FORMAT_RAR_V5:
madvise_log_if_error(archiveAddr, archiveSize, MADV_SEQUENTIAL);
break;
case ARCHIVE_FORMAT_7ZIP: // Seek is bad
madvise_log_if_error(archiveAddr, archiveSize, MADV_RANDOM);
break;
default:;
}
// We must read through the file|vm then we can know whether it is encrypted
int encryptRet = archive_read_has_encrypted_entries(ctx->arc);
switch (encryptRet) {
case 1: // At lease 1 encrypted entry
need_encrypt = true;
break;
case 0: // format supports but no encrypted entry found
default:
need_encrypt = false;
}
archive_release_ctx(ctx);
r = archive_alloc_ctx(&ctx);
if (r) {
if (r == -ENOMEM)
LOGE("%s", "Mem alloc failed");
r = 0;
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
} else {
entries = calloc(entryCount, sizeof(entry));
r = archive_map_entries_index(ctx, sort_entries);
if (!archive_prealloc_mempool()) {
r = 0;
for (int i = 0; i < entryCount; ++i) {
free((void *) entries[i].filename);
}
free(entries);
}
int format = archive_format(ctx->arc);
switch (format) {
case ARCHIVE_FORMAT_ZIP:
case ARCHIVE_FORMAT_RAR_V5:
madvise_log_if_error(archiveAddr, archiveSize, MADV_SEQUENTIAL);
break;
case ARCHIVE_FORMAT_7ZIP: // Seek is bad
madvise_log_if_error(archiveAddr, archiveSize, MADV_RANDOM);
break;
default:;
}
archive_release_ctx(ctx);
return r;
ctx = archive_alloc_ctx();
entries = calloc(entryCount, sizeof(entry));
archive_map_entries_index(ctx, sort_entries);
archive_release_ctx(ctx);
return (int) entryCount;
}
JNIEXPORT jobject JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToByteBuffer(JNIEnv *env, jclass thiz, jint index) {
EH_UNUSED(env);
EH_UNUSED(thiz);
void *addr = MEMPOOL_ADDR_BY_SORTED_IDX(index);
size_t size = entries[index].size;
index = entries[index].index;
archive_ctx *ctx = NULL;
int ret = archive_get_ctx(&ctx, index);
if (ret) return 0;
void *buffer = NULL;
size_t buffer_size = 0;
la_int64_t output_ofs = 0;
archive_read_data_block(ctx->arc, (const void **) &buffer, &buffer_size, &output_ofs);
bool zero_copy = ADDR_IN_FILE_MAPPING(buffer) && !output_ofs && buffer_size == size;
if (zero_copy) {
ctx->using = 0;
return (*env)->NewDirectByteBuffer(env, buffer, buffer_size);
entry *entry = &entries[index];
ssize_t size = entry->size;
if (entry->addr) {
return (*env)->NewDirectByteBuffer(env, entry->addr, size);
} else {
size_t bytes = 0;
mempool_prefault_pages(addr, size);
do {
memcpy(addr + output_ofs, buffer, buffer_size);
bytes += buffer_size;
ret = archive_read_data_block(ctx->arc, (const void **) &buffer, &buffer_size, &output_ofs);
} while (bytes != size && !ret);
ctx->using = 0;
if (bytes == size) {
return (*env)->NewDirectByteBuffer(env, addr, size);
} else {
if (ret < 0) {
LOGE("%s%s", "Archive read failed:", archive_error_string(ctx->arc));
archive_ctx *ctx = NULL;
if (!archive_get_ctx(&ctx, entry->index)) {
void *addr = acquire_decode_buffer();
ssize_t bytes = archive_read_data(ctx->arc, addr, size);
ctx->using = 0;
if (bytes == size) {
return (*env)->NewDirectByteBuffer(env, addr, size);
} else {
LOGE("%s", "No enough data read, WTF?");
if (bytes < 0) {
LOGE("%s%s", "Archive read failed:", archive_error_string(ctx->arc));
} else {
LOGE("%s", "No enough data read, WTF?");
}
}
release_decode_buffer(addr);
}
mempool_release_pages(addr, size);
}
return 0;
}
@@ -403,20 +361,31 @@ JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
EH_UNUSED(env);
EH_UNUSED(thiz);
if (!ctx_pool) return;
for (int i = 0; i < CTX_POOL_SIZE; i++)
archive_release_ctx(ctx_pool[i]);
if (ctx_pool) {
for (int i = 0; i < CTX_POOL_SIZE; i++)
archive_release_ctx(ctx_pool[i]);
free(ctx_pool);
ctx_pool = NULL;
}
free(passwd);
free(ctx_pool);
passwd = NULL;
need_encrypt = false;
munmap(archiveAddr, archiveSize);
munmap(mempool, MEMPOOL_SIZE);
free(mempoolofs);
for (int i = 0; i < entryCount; ++i) {
free((void *) entries[i].filename);
if (archiveAddr != MAP_FAILED) {
munmap(archiveAddr, archiveSize);
archiveAddr = MAP_FAILED;
}
for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) {
free(decode_buffer[i]);
decode_buffer[i] = NULL;
}
max_file_size = 0;
if (entries) {
for (int i = 0; i < entryCount; ++i) {
free((void *) entries[i].filename);
}
free(entries);
entries = NULL;
}
free(entries);
}
JNIEXPORT jboolean JNICALL
@@ -433,14 +402,11 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
archive_ctx *ctx;
jboolean ret = true;
int len = (*env)->GetStringUTFLength(env, str);
passwd = realloc(passwd, len * sizeof(char));
if (!passwd) {
LOGE("Allocate passwd buffer failed");
return false;
}
passwd = realloc(passwd, len + 1);
(*env)->GetStringUTFRegion(env, str, 0, len, passwd);
archive_alloc_ctx(&ctx);
void *tmpBuf = alloca(4096);
passwd[len] = 0;
ctx = archive_alloc_ctx();
char tmpBuf[4096];
while (archive_read_next_header(ctx->arc, &entry) == ARCHIVE_OK) {
if (!archive_entry_is_playable(entry))
continue;
@@ -483,11 +449,12 @@ JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz, jobject buffer) {
EH_UNUSED(thiz);
void *addr = (*env)->GetDirectBufferAddress(env, buffer);
size_t size = (*env)->GetDirectBufferCapacity(env, buffer);
if (!ADDR_IN_FILE_MAPPING(addr)) mempool_release_pages(addr, size);
if (!ADDR_IN_FILE_MAPPING(addr)) {
release_decode_buffer(addr);
}
}
JNIEXPORT jboolean JNICALL
JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz, jintArray fd_batch, jobjectArray names, jint arc_fd, jint size) {
EH_UNUSED(clazz);
struct archive *arc = archive_write_new();
@@ -509,15 +476,15 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz,
archive_entry_copy_stat(entry, &st);
archive_entry_set_perm(entry, 0644);
archive_write_header(arc, entry);
int len = read(fd, buff, sizeof(buff));
while (len > 0) {
archive_write_data(arc, buff, len);
size_t len;
do {
len = read(fd, buff, sizeof(buff));
}
archive_write_data(arc, buff, len);
} while (len > 0);
archive_write_finish_entry(arc);
archive_entry_clear(entry);
}
archive_entry_free(entry);
archive_write_close(arc);
archive_write_free(arc);
return true;
}
-241
View File
@@ -1,241 +0,0 @@
#include <jni.h>
#include <math.h>
#include <stdbool.h>
#include <android/bitmap.h>
#include <stdlib.h>
#include <string.h>
/** A line will be considered as having content if 0.25% of it is filled. */
const float filledRatioLimit = 0.0025;
/** When the threshold is closer to 1, less content will be cropped. **/
#define THRESHOLD 0.75
const uint8_t thresholdForBlack = (uint8_t) (255.0 * THRESHOLD);
const uint8_t thresholdForWhite = (uint8_t) (255.0 - 255.0 * THRESHOLD);
static uint8_t inline grey(uint32_t pixel) {
const uint8_t r = ((uint8_t *) &pixel)[0];
const uint8_t g = ((uint8_t *) &pixel)[1];
const uint8_t b = ((uint8_t *) &pixel)[2];
return (r * 77 + g * 151 + b * 28) >> 8;
}
static bool inline isBlackPixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
return grey(pixel) < thresholdForBlack;
}
static bool inline isWhitePixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
return grey(pixel) > thresholdForWhite;
}
/** Return the first x position where there is a substantial amount of fill,
* starting the search from the left. */
uint32_t findBorderLeft(uint32_t *pixels, uint32_t width, uint32_t height,
uint32_t top, uint32_t bottom) {
int x, y;
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
for (y = top; y < bottom; y += 2) {
if (isBlackPixel(pixels, width, 0, y)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, 0, y)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return 0;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan vertical lines in search of filled lines
for (x = 1; x < width; x++) {
uint32_t filledCount = 0;
for (y = top; y < bottom; y += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return x;
}
}
// No fill found... don't crop anything
return 0;
}
/** Return the first x position where there is a substantial amount of fill,
* starting the search from the right. */
uint32_t findBorderRight(uint32_t *pixels, uint32_t width, uint32_t height,
uint32_t top, uint32_t bottom) {
int x, y;
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
uint32_t lastX = width - 1;
for (y = top; y < bottom; y += 2) {
if (isBlackPixel(pixels, width, lastX, y)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, lastX, y)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return width;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan vertical lines in search of filled lines
for (x = width - 2; x > 0; x--) {
uint32_t filledCount = 0;
for (y = top; y < bottom; y += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return x + 1;
}
}
// No fill found... don't crop anything
return width;
}
/** Return the first y position where there is a substantial amount of fill,
* starting the search from the top. */
uint32_t findBorderTop(uint32_t *pixels, uint32_t width, uint32_t height) {
int x, y;
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
for (x = 0; x < width; x += 2) {
if (isBlackPixel(pixels, width, x, 0)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, x, 0)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return 0;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan horizontal lines in search of filled lines
for (y = 1; y < height; y++) {
uint32_t filledCount = 0;
for (x = 0; x < width; x += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return y;
}
}
// No fill found... don't crop anything
return 0;
}
/** Return the first y position where there is a substantial amount of fill,
* starting the search from the bottom. */
uint32_t findBorderBottom(uint32_t *pixels, uint32_t width, uint32_t height) {
int x, y;
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
uint32_t lastY = height - 1;
for (x = 0; x < width; x += 2) {
if (isBlackPixel(pixels, width, x, lastY)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, x, lastY)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return height;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan horizontal lines in search of filled lines
for (y = height - 2; y > 0; y--) {
uint32_t filledCount = 0;
for (x = 0; x < width; x += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return y + 1;
}
}
// No fill found... don't crop anything
return height;
}
JNIEXPORT jintArray JNICALL
Java_com_hippo_ehviewer_image_ImageKt_detectBorder(JNIEnv *env, jclass clazz, jobject bitmap) {
void *pixels = NULL;
AndroidBitmapInfo info;
memset(&info, 0, sizeof(info));
AndroidBitmap_getInfo(env, bitmap, &info);
AndroidBitmap_lockPixels(env, bitmap, &pixels);
uint32_t width = info.width;
uint32_t height = info.height;
uint32_t top = findBorderTop(pixels, width, height);
uint32_t bottom = findBorderBottom(pixels, width, height);
uint32_t left = findBorderLeft(pixels, width, height, top, bottom);
uint32_t right = findBorderRight(pixels, width, height, top, bottom);
int array[4] = {left, top, right, bottom};
AndroidBitmap_unlockPixels(env, bitmap);
jintArray ret = (*env)->NewIntArray(env, 4);
(*env)->SetIntArrayRegion(env, ret, 0, 4, array);
return ret;
}
+9 -2
View File
@@ -1,6 +1,13 @@
TOOLCHAIN=$ANDROID_HOME/ndk/$1/toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
SUFFIX=toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
CI_NDK_LOCATION=$ANDROID_HOME/ndk/$1/$SUFFIX
ARCH_NDK_LOCATION=$ANDROID_HOME/ndk-bundle/$SUFFIX
if test -e $CI_NDK_LOCATION ;then
TOOLCHAIN=$CI_NDK_LOCATION
else
TOOLCHAIN=$ARCH_NDK_LOCATION
fi
TARGET=$2
./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
./.bootstrap && ./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
AR=$TOOLCHAIN/llvm-ar \
CC="$TOOLCHAIN/clang -target $TARGET" \
RANLIB=$TOOLCHAIN/llvm-ranlib
+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
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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
@@ -183,7 +185,7 @@ private class AnchoredHorizontalDraggableNode<T>(
}
}
override fun onDragStarted(startedPosition: Offset) { }
override fun onDragStarted(startedPosition: Offset) = Unit
override fun onDragStopped(velocity: Velocity) {
if (!isAttached) return
@@ -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 {
@@ -28,6 +28,7 @@ import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -78,6 +79,7 @@ val LocalScrollbarStyle = staticCompositionLocalOf { defaultScrollbarStyle() }
data class ScrollbarStyle(
val minimalHeight: Dp,
val thickness: Dp,
val padding: Dp,
val shape: Shape,
val hoverDurationMillis: Int,
val unhoverColor: Color,
@@ -90,6 +92,7 @@ data class ScrollbarStyle(
fun defaultScrollbarStyle() = ScrollbarStyle(
minimalHeight = 16.dp,
thickness = 8.dp,
padding = 0.dp,
shape = RoundedCornerShape(4.dp),
hoverDurationMillis = 300,
unhoverColor = Color.Black.copy(alpha = 0.12f),
@@ -239,7 +242,7 @@ private fun Scrollbar(
)
}
val scrollThickness = style.thickness.roundToPx()
val scrollThickness = (style.thickness + style.padding * 2).roundToPx()
val measurePolicy = if (isVertical) {
remember(sliderAdapter, scrollThickness) {
verticalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness)
@@ -261,7 +264,11 @@ private fun Scrollbar(
Layout(
{
Box(
Modifier
if (isVertical) {
Modifier.padding(horizontal = style.padding)
} else {
Modifier.padding(vertical = style.padding)
}
.background(if (isVisible) color else Color.Transparent, style.shape)
.scrollbarDrag(
interactionSource = interactionSource,
@@ -365,25 +365,23 @@ internal class LazyGridScrollbarAdapter(
}
override fun averageVisibleLineSize(): Double {
val visibleItemsInfo = scrollState.layoutInfo.visibleItemsInfo
val indexOfFirstKnownLineItem = visibleItemsInfo.indexOfFirst { it.line() != unknownLine }
if (indexOfFirstKnownLineItem == -1) {
val visibleItemsInfo = // Non-exiting visible items
scrollState.layoutInfo.visibleItemsInfo.filter { it.line() != unknownLine }
if (visibleItemsInfo.isEmpty()) {
return 0.0
}
val reallyVisibleItemsInfo = // Non-exiting visible items
visibleItemsInfo.subList(indexOfFirstKnownLineItem, visibleItemsInfo.size)
// Compute the size of the last line
val lastLine = reallyVisibleItemsInfo.last().line()
val lastLineSize = reallyVisibleItemsInfo
val last = visibleItemsInfo.last()
val lastLine = last.line()
val lastLineSize = visibleItemsInfo
.asReversed()
.asSequence()
.takeWhile { it.line() == lastLine }
.maxOf { it.mainAxisSize() }
val first = reallyVisibleItemsInfo.first()
val last = reallyVisibleItemsInfo.last()
val lineCount = last.line() - first.line() + 1
val first = visibleItemsInfo.first()
val lineCount = lastLine - first.line() + 1
val lineSpacingSum = (lineCount - 1) * lineSpacing
return (
last.mainAxisOffset() + lastLineSize - first.mainAxisOffset() - lineSpacingSum
@@ -1,104 +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
val SearchBarHorizontalPadding = 16.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,
@@ -69,6 +69,11 @@ interface SystemUiController {
isStatusBarVisible = value
isNavigationBarVisible = value
}
/**
* Property which holds whether the status bar icons + content are 'dark' or not.
*/
var statusBarDarkContentEnabled: Boolean
}
/**
@@ -93,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
@@ -149,4 +152,10 @@ internal class AndroidSystemUiController(
windowInsetsController?.hide(WindowInsetsCompat.Type.navigationBars())
}
}
override var statusBarDarkContentEnabled: Boolean
get() = windowInsetsController?.isAppearanceLightStatusBars == true
set(value) {
windowInsetsController?.isAppearanceLightStatusBars = value
}
}
@@ -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,8 +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.compose.runtime.snapshots.Snapshot
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil3.EventListener
@@ -30,20 +28,24 @@ import coil3.SingletonImageLoader
import coil3.asImage
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.ktor.KtorNetworkFetcherFactory
import coil3.network.ConnectivityChecker
import coil3.network.NetworkFetcher
import coil3.network.ktor3.asNetworkClient
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
import com.hippo.ehviewer.coil.HardwareBitmapInterceptor
import com.hippo.ehviewer.coil.MapExtraInfoInterceptor
import com.hippo.ehviewer.coil.MergeInterceptor
import com.hippo.ehviewer.cronet.cronetHttpClient
import com.hippo.ehviewer.coil.QrCodeInterceptor
import com.hippo.ehviewer.coil.exchangeSite
import com.hippo.ehviewer.coil.limitConcurrency
import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase
import com.hippo.ehviewer.download.DownloadManager
@@ -51,22 +53,29 @@ 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
import logcat.LogPriority
@@ -83,6 +92,7 @@ class EhApplication :
Application(),
SingletonImageLoader.Factory {
override fun onCreate() {
initSETConnection()
// Initialize Settings on first access
lifecycleScope.launchIO {
val mode = Settings.theme
@@ -96,22 +106,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
@@ -122,23 +125,13 @@ 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) {
StrictMode.enableDefaults()
Snapshot.registerApplyObserver { anies, _ ->
logcat(LogPriority.VERBOSE) { anies.toString() }
}
}
}
@@ -167,15 +160,24 @@ class EhApplication :
}
override fun newImageLoader(context: Context) = context.imageLoader {
interceptorCoroutineContext(Dispatchers.Default)
components {
serviceLoaderEnabled(false)
add(KtorNetworkFetcherFactory { ktorClient })
add(
NetworkFetcher.Factory(
networkClient = { ktorClient.asNetworkClient().limitConcurrency().exchangeSite() },
connectivityChecker = { ConnectivityChecker.ONLINE },
),
)
add(MergeInterceptor)
add(DownloadThumbInterceptor)
if (isAtLeastO) {
add(HardwareBitmapInterceptor)
}
add(CropBorderInterceptor)
add(DetectBorderInterceptor)
add(QrCodeInterceptor)
add(MapExtraInfoInterceptor)
if (isAtLeastP) {
add(AnimatedImageDecoder.Factory(false))
} else {
@@ -201,30 +203,22 @@ class EhApplication :
companion object {
val ktorClient by lazy {
HttpClient(Cronet) {
engine {
client = cronetHttpClient
if (isAtLeastSExtension7 && Settings.enableCronet.value) {
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)
}
}
@@ -150,7 +150,7 @@ object EhDB {
if (artists.isNotEmpty()) {
val dao = db.downloadArtistDao()
dao.deleteByGid(gid)
dao.insert(artists)
dao.insertOrIgnore(artists)
}
}
@@ -9,7 +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_USER_AGENT
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.download.DownloadsFilterMode
@@ -107,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)
@@ -114,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)
@@ -122,27 +126,26 @@ object Settings : DataStorePreferences(null) {
val qSSaveProgress = boolPref("qs_save_progress", true)
val security = boolPref("require_unlock", false)
val animateItems = boolPref("animate_items", true)
val enableCronet = boolPref("enable_cronet", true)
val displayName = stringOrNullPref("display_name", null)
val recentDownloadLabel = stringOrNullPref("recent_download_label", "")
val avatar = stringOrNullPref("avatar", null)
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", 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)
var hardwareBitmapThreshold by intPref("hardware_bitmap_threshold", 16384)
var forceEhThumb by boolPref("force_eh_thumb", false)
var showComments by boolPref("show_gallery_comments", true)
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
var hideHvEvents by boolPref("hide_hv_events", false)
@@ -157,12 +160,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 desktopSite by boolPref("desktop_site", true)
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
var defaultFavSlot by intPref("default_favorite_slot", -2)
var securityDelay by intPref("require_unlock_delay", 0)
@@ -171,13 +174,10 @@ object Settings : DataStorePreferences(null) {
var updateIntervalDays by intPref("update_interval_days", 7)
var lastDawnDays by intPref("last_dawn_days", 0)
var recentToplist by stringPref("recent_toplist", "11")
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
var lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
// Tachiyomi Reader
var newReader by boolPref("new_compose_reader", false)
// Reader
val cropBorder = boolPref("crop_borders", false)
val colorFilter = boolPref("pref_color_filter_key", false)
val colorFilterValue = intPref("color_filter_value", 0)
@@ -195,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)
@@ -209,6 +206,9 @@ object Settings : DataStorePreferences(null) {
val imageScaleType = intPref("pref_image_scale_type_key", 1)
val landscapeZoom = boolPref("landscape_zoom", false)
val zoomStart = intPref("pref_zoom_start_key", 1)
val showNavigationOverlayNewUser = boolPref("reader_navigation_overlay_new_user", true)
val showNavigationOverlayOnStart = boolPref("reader_navigation_overlay_on_start", false)
val stripExtraneousAds = boolPref("strip_extraneous_ads", false)
init {
if ("CN" == Locale.getDefault().country) {
@@ -216,6 +216,9 @@ object Settings : DataStorePreferences(null) {
if (KEY_SHOW_TAG_TRANSLATIONS !in prefs) showTagTranslations = true
}
}
if (downloadFilterMode.value == DownloadsFilterMode.ARTIST.flag && recentDownloadLabel.value == null) {
recentDownloadLabel.value = ""
}
}
interface Delegate<R> {
@@ -17,30 +17,42 @@ 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
// ExHentai Large Preview: https://s.exhentai.org/t/***
// ExHentai Normal Preview: https://s.exhentai.org/m/***
// EHentai Normal Preview: https://ehgt.org/m/***
// EHentai 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/"
private const val URL_PREFIX_LARGE_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
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(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_LARGE_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
fun getThumbKey(url: String): String = url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_V1_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
val GalleryPreview.url
get() = if (exThumb) {
if (this is NormalGalleryPreview) URL_PREFIX_THUMB_EX else URL_PREFIX_LARGE_THUMB_EX
} else {
URL_PREFIX_THUMB_E
} + imageKey
fun getV2PreviewKey(url: String) = "$".plus(
V2PreviewKeyRegex.find(url)?.let {
it.groupValues[1] + it.groupValues[2]
} ?: url,
)
val String.isV2PreviewKey
get() = startsWith('$')
val GalleryInfo.thumbUrl
get() = (if (exThumb) URL_PREFIX_LARGE_THUMB_EX else URL_PREFIX_THUMB_E) + thumbKey!!
get() = keyToUrl(thumbKey!!)
val exThumb get() = EhUtils.isExHentai && !Settings.forceEhThumb
fun keyToUrl(key: String, exSite: Boolean = useExThumb) = if (key.endsWith("webp")) {
if (exSite) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
} else {
if (exSite) URL_PREFIX_V1_THUMB_EX else URL_PREFIX_THUMB_E
} + key
fun flipThumbSite(url: String) = keyToUrl(getThumbKey(url), !useExThumb)
private val useExThumb
get() = EhUtils.isExHentai && !Settings.forceEhThumb.value
@@ -13,11 +13,12 @@ object EhCookieStore : CookiesStorage {
fun hasSignedIn(): Boolean = getCookies(EhUrl.HOST_E)?.run {
containsKey(KEY_IPB_MEMBER_ID) && containsKey(KEY_IPB_PASS_HASH)
} ?: false
} == true
const val KEY_IPB_MEMBER_ID = "ipb_member_id"
const val KEY_IPB_PASS_HASH = "ipb_pass_hash"
const val KEY_IGNEOUS = "igneous"
private const val KEY_HATH_PERKS = "hath_perks"
private const val KEY_CONTENT_WARNING = "nw"
private const val CONTENT_WARNING_NOT_SHOW = "1"
private const val KEY_UTMP_NAME = "__utmp"
@@ -33,6 +34,10 @@ object EhCookieStore : CookiesStorage {
)
}
fun getUserId() = getCookies(EhUrl.HOST_E)?.get(KEY_IPB_MEMBER_ID)
fun getHathPerks() = getCookies(EhUrl.HOST_E)?.get(KEY_HATH_PERKS)?.substringBefore('-')
fun getIdentityCookies(): List<Pair<String, String?>> {
val eCookies = getCookies(EhUrl.HOST_E)
val exCookies = getCookies(EhUrl.HOST_EX)
@@ -48,8 +53,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) {
@@ -29,6 +29,7 @@ import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.NoHitsFoundException
import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.parser.ArchiveParser
@@ -55,13 +56,15 @@ import com.hippo.ehviewer.dailycheck.showEventNotification
import com.hippo.ehviewer.dailycheck.today
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.bodyAsUtf8Text
import com.hippo.ehviewer.util.ensureSuccess
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.statement.HttpStatement
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpStatusCode
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import io.ktor.utils.io.readAvailable
import java.io.File
import java.nio.ByteBuffer
import kotlin.math.ceil
@@ -96,7 +99,7 @@ fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
}
}
fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
fun rethrowExactly(status: HttpStatusCode, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
// Don't translate coroutine cancellation
if (e is CancellationException) throw e
@@ -124,17 +127,15 @@ fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): N
}
// Check bad response code
if (code >= 400) {
throw StatusCodeException(code)
}
status.ensureSuccess()
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 EhException(R.string.gallery_list_empty_hit)
"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)
@@ -151,7 +152,7 @@ suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend
runSuspendCatching {
block(body)
}.onFailure {
rethrowExactly(response.status.value, body.left(), it)
rethrowExactly(response.status, body.left(), it)
}.getOrThrow()
}
@@ -163,7 +164,7 @@ suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: s
block(buffer)
}.onFailure {
buffer.rewind()
rethrowExactly(response.status.value, buffer.right(), it)
rethrowExactly(response.status, buffer.right(), it)
}.getOrThrow()
}
}
@@ -179,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))
@@ -272,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)
@@ -297,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 {
@@ -333,7 +338,7 @@ object EhEngine {
suspend fun resetImageLimits() = ehRequest(EhUrl.URL_HOME) {
formBody {
append("reset_imagelimit", "Reset Limit")
append("reset_imagelimit", "Reset Quota")
}
}.fetchUsingAsText(HomeParser::parseResetLimits)
@@ -378,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 {
@@ -55,8 +55,8 @@ object EhFilter : CoroutineScope {
suspend fun needTags() = filters.await().any { it.enable && (it.mode == FilterMode.TAG || it.mode == FilterMode.TAG_NAMESPACE) }
suspend fun filterTitle(info: GalleryInfo) = anyActive(FilterMode.TITLE) { info.title.orEmpty().contains(it.text, true) }
suspend fun filterUploader(info: GalleryInfo) = anyActive(FilterMode.UPLOADER) { it.text == info.uploader }
suspend fun filterTag(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG) { matchTag(tag, it.text.lowercase()) } } ?: false
suspend fun filterTagNamespace(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG_NAMESPACE) { matchTagNamespace(tag, it.text.lowercase()) } } ?: false
suspend fun filterTag(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG) { matchTag(tag, it.text.lowercase()) } } == true
suspend fun filterTagNamespace(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG_NAMESPACE) { matchTagNamespace(tag, it.text.lowercase()) } } == true
suspend fun filterCommenter(commenter: String) = anyActive(FilterMode.COMMENTER) { it.text == commenter }
suspend fun filterComment(comment: String) = anyActive(FilterMode.COMMENT) { regex(it).containsMatchIn(comment) }
}
@@ -15,11 +15,10 @@
*/
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
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.forms.FormDataContent
@@ -29,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
@@ -45,23 +42,19 @@ import okio.BufferedSource
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely { it.bodyAsUtf8Text().parseAs<T>() }
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely {
it.status.ensureSuccess()
it.bodyAsUtf8Text().parseAs<T>()
}
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
val json = Json { ignoreUnknownKeys = true }
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
const val CHROME_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(
@@ -24,16 +24,14 @@ import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.copyTo
import com.hippo.ehviewer.util.ensureSuccess
import com.hippo.ehviewer.util.sha1
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.isSuccess
import java.io.File
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -105,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) ->
@@ -126,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 }
@@ -135,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()
@@ -143,11 +140,8 @@ object EhTagDatabase : CoroutineScope {
private suspend fun save(client: HttpClient, url: String, file: File) {
runCatching {
client.prepareGet(url).executeSafely {
if (it.status.isSuccess()) {
it.bodyAsChannel().copyTo(file)
} else {
throw StatusCodeException(it.status.value)
}
it.status.ensureSuccess()
it.bodyAsChannel().copyTo(file)
}
}.onFailure {
file.delete()
@@ -53,70 +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() = if (Settings.forceEhThumb) {
API_E
} else {
when (Settings.gallerySite) {
SITE_E -> API_E
SITE_EX -> API_EX
else -> API_E
}
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()
@@ -130,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(
@@ -15,17 +15,32 @@
*/
package com.hippo.ehviewer.client
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Environment
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.graphics.ColorUtils
import androidx.core.net.toUri
import arrow.core.memoize
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.parser.Archive
import com.hippo.ehviewer.spider.SpiderDen
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.addTextToClipboard
import com.materialkolor.hct.Hct
import com.materialkolor.ktx.from
import com.materialkolor.ktx.toColor
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlin.math.abs
import splitties.systemservices.downloadManager
object EhUtils {
const val NONE = -1 // Use it for homepage
@@ -84,7 +99,10 @@ 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
val isMpvAvailable
get() = EhCookieStore.getHathPerks()?.contains('q') == true
fun getCategory(type: String?): Int {
for (entry in CATEGORY_STRINGS) {
@@ -99,51 +117,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 +173,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) {
@@ -172,22 +193,34 @@ object EhUtils {
return title.substringBeforeLast('|').trim().ifEmpty { null }
}
fun handleThumbUrlResolution(url: String?): String? {
if (null == url) {
return null
}
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
context(Context)
suspend fun downloadArchive(galleryDetail: GalleryDetail, archive: Archive) {
val gid = galleryDetail.gid
EhEngine.downloadArchive(gid, galleryDetail.token, archive.res, archive.isHAtH)?.let {
val uri = it.toUri()
val intent = Intent().apply {
action = Intent.ACTION_VIEW
setDataAndType(uri, "application/zip")
}
val name = "$gid-${getSuitableTitle(galleryDetail)}.zip"
try {
startActivity(intent)
withUIContext { addTextToClipboard(name, true) }
} catch (_: ActivityNotFoundException) {
val r = DownloadManager.Request(uri)
r.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
)
r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
downloadManager.enqueue(r)
}
if (Settings.archiveMetadata) {
SpiderDen(galleryDetail).apply {
initDownloadDir()
writeComicInfo()
}
}
}
}
}
@@ -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)
}
}
@@ -16,21 +16,26 @@
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import com.hippo.ehviewer.client.keyToUrl
import kotlinx.parcelize.Parcelize
interface GalleryPreview : Parcelable {
sealed interface GalleryPreview : Parcelable {
val url: String
val imageKey: String
val position: Int
}
@Parcelize
data class LargeGalleryPreview(
data class V1GalleryPreview(
override val imageKey: String,
override val position: Int,
) : GalleryPreview
) : GalleryPreview {
override val url get() = keyToUrl(imageKey)
}
@Parcelize
data class NormalGalleryPreview(
data class V2GalleryPreview(
override val url: String,
override val imageKey: String,
override val position: Int,
val offsetX: 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
}
}
}
@@ -0,0 +1,5 @@
package com.hippo.ehviewer.client.exception
import com.hippo.ehviewer.R
class NoHitsFoundException : EhException(R.string.gallery_list_empty_hit)
@@ -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()
}
}
@@ -16,7 +16,6 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.EhUtils.getCategory
import com.hippo.ehviewer.client.EhUtils.handleThumbUrlResolution
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.getThumbKey
import com.hippo.ehviewer.client.parseAs
@@ -32,7 +31,7 @@ object GalleryApiParser {
title = item.title.unescapeXml()
titleJpn = item.titleJpn.unescapeXml()
category = getCategory(item.category)
thumbKey = getThumbKey(handleThumbUrlResolution(item.thumb)!!)
thumbKey = getThumbKey(item.thumb)
uploader = item.uploader?.unescapeXml()
posted = ParserUtils.formatDate(item.posted * 1000)
rating = item.rating
@@ -15,29 +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.EhUtils.handleThumbUrlResolution
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
@@ -51,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
@@ -62,7 +61,7 @@ object GalleryDetailParser {
RegexOption.DOT_MATCHES_ALL,
)
private val PATTERN_TORRENT =
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download[^<]+(\\d+)[^<]+</a")
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download \\((\\d+)\\)</a>")
private val PATTERN_ARCHIVE =
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Archive Download</a>")
private val PATTERN_COVER = Regex("width:(\\d+)px; height:(\\d+)px.+?url\\((.+?)\\)")
@@ -70,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 {
@@ -107,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,
@@ -136,31 +133,26 @@ 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 {
gd.thumbKey = getThumbKey(
PATTERN_COVER.find(it)?.run {
handleThumbUrlResolution(groupValues[3])
}!!,
)
gd.thumbKey = getThumbKey(PATTERN_COVER.find(it)!!.groupValues[3])
}
gd.title = gm.getElementById("gn")?.text()?.trim()
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
@@ -223,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")
}
@@ -283,161 +274,139 @@ 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
}
}
},
chd!!,
)
GalleryCommentList(list, hasMore)
}.getOrElse {
logcat(it)
EMPTY_GALLERY_COMMENT_LIST
}
/**
@@ -452,32 +421,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 imageKey = getThumbKey(it.groupValues[3].trim())
val pageUrl = it.groupValues[1].trim()
LargeGalleryPreview(imageKey, 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 imageKey = getThumbKey(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(imageKey, 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()
}
@@ -19,22 +19,18 @@ package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.parseAs
import com.hippo.ehviewer.util.ExceptionUtils
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
object GalleryMultiPageViewerPTokenParser {
private const val IMAGE_LIST_STRING = "var imagelist = "
fun parse(body: String): List<String> {
fun parse(body: String): List<String> = runCatching {
val index = body.indexOf(IMAGE_LIST_STRING)
val imagelist = body.substring(index + IMAGE_LIST_STRING.length, body.indexOf(";", index))
return runCatching {
imagelist.parseAs<List<Item>>().map(Item::token)
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw ParseException("Parse pToken from MPV error", it)
}
imagelist.parseAs<List<Item>>().map(Item::token)
}.getOrElse {
throw ParseException("Parse pToken from MPV error", it)
}
@Serializable
@@ -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
@@ -41,7 +41,7 @@ object ParserUtils {
fun parseDate(str: String): Long = formatter.parse(str).toEpochMillis()
fun trim(str: String?): String = str?.unescapeXml()?.trim() ?: ""
fun trim(str: String?): String = str?.unescapeXml()?.trim().orEmpty()
fun parseInt(str: String?, defValue: Int): Int = trim(str).replace(",", "").toIntOrDefault(defValue)
@@ -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,55 +1,58 @@
package com.hippo.ehviewer.coil
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Extras
import coil3.Image
import coil3.getExtra
import android.graphics.Bitmap
import android.hardware.HardwareBuffer
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toIntRect
import arrow.fx.coroutines.autoCloseable
import arrow.fx.coroutines.resourceScope
import coil3.asImage
import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.image.detectBorder
import com.hippo.ehviewer.image.copyBitmapToAHB
import com.hippo.ehviewer.util.isAtLeastQ
import eu.kanade.tachiyomi.util.system.logcat
import moe.tarsin.coroutines.runSuspendCatching
private const val CROP_THRESHOLD = 0.75f
private const val RATIO_THRESHOLD = 2
@RequiresApi(Build.VERSION_CODES.O)
private const val FORMAT = HardwareBuffer.RGBA_8888
private val maybeCropBorderKey = Extras.Key(default = false)
fun ImageRequest.Builder.maybeCropBorder(enable: Boolean) = apply {
extras[maybeCropBorderKey] = enable
}
val ImageRequest.maybeCropBorder: Boolean
get() = getExtra(maybeCropBorderKey)
class BitmapImageWithRect(
val rect: IntRect,
val image: BitmapImage,
) : Image by image
@RequiresApi(Build.VERSION_CODES.O)
private const val USAGE = HardwareBuffer.USAGE_CPU_WRITE_RARELY or HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE
object CropBorderInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val result = chain.proceed()
if (chain.request.maybeCropBorder && result is SuccessResult) {
if (result is SuccessResult) {
val image = result.image
if (image is BitmapImage) {
val bitmap = image.bitmap
val ratio = if (image.height > image.width) {
image.height / image.width
} else {
image.width / image.height
}
if (ratio < RATIO_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
val rect = IntRect(array[0], array[1], array[2], array[3])
if (rect.width > minWidth && rect.height > minHeight) {
val new = BitmapImageWithRect(rect, image)
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
}
if (image is BitmapImageWithExtraInfo) {
// Copy with cropped region
val srcSize = IntSize(image.width, image.height)
if (image.rect.size != srcSize) {
val (x, y) = image.rect.topLeft
val (w, h) = image.rect.size
val src = image.image.bitmap
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
// This is not ideal but I haven't figured out how to probe the threshold.
// All we know is that it's less than the maximum texture size.
val meetHardwareThreshold = maxOf(w, h) <= chain.request.hardwareThreshold
val bitmap = when {
isAtLeastQ && meetHardwareThreshold -> runSuspendCatching {
resourceScope {
val buffer = autoCloseable { HardwareBuffer.create(w, h, FORMAT, 1, USAGE) }
copyBitmapToAHB(src, buffer, x, y)
Bitmap.wrapHardwareBuffer(buffer, src.colorSpace)
}
}.onFailure { logcat(it) }.getOrNull()
else -> null
} ?: Bitmap.createBitmap(src, x, y, w, h)
src.recycle()
return result.copy(image = image.copy(image = bitmap.asImage(), rect = image.rect.size.toIntRect()))
}
}
}
@@ -0,0 +1,51 @@
package com.hippo.ehviewer.coil
import androidx.compose.ui.unit.IntRect
import coil3.Extras
import coil3.getExtra
import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.image.detectBorder
private const val CROP_THRESHOLD = 0.75f
private const val RATIO_THRESHOLD = 2
private val maybeCropBorderKey = Extras.Key(default = false)
fun ImageRequest.Builder.maybeCropBorder(enable: Boolean) = apply {
extras[maybeCropBorderKey] = enable
}
val ImageRequest.maybeCropBorder: Boolean
get() = getExtra(maybeCropBorderKey)
object DetectBorderInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult {
val result = chain.proceed()
if (chain.request.maybeCropBorder && result is SuccessResult) {
val image = result.image
if (image is BitmapImageWithExtraInfo && !image.hasQrCode) {
val bitmap = image.image.bitmap
val ratio = if (image.height > image.width) {
image.height / image.width
} else {
image.width / image.height
}
if (ratio < RATIO_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
val rect = IntRect(array[0], array[1], array[2], array[3])
if (rect.width > minWidth && rect.height > minHeight) {
val new = image.copy(rect = rect)
return result.copy(image = new)
}
}
}
}
return result
}
}
@@ -1,43 +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 eu.kanade.tachiyomi.util.lang.withIOContext
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 = withIOContext { downloadLocation / location / THUMB_FILE }
if (withIOContext { 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) {
withIOContext {
// Accessing the recreated file immediately after deleting it throws
// FileNotFoundException, so we just overwrite the existing file.
val key = requireNotNull(chain.request.memoryCacheKey)
imageCache.read(key) {
data sendTo thumb
}
}
}
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()
}
@@ -0,0 +1,27 @@
package com.hippo.ehviewer.coil
import coil3.network.HttpException
import coil3.network.NetworkClient
import coil3.network.NetworkRequest
import coil3.network.NetworkResponse
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.flipThumbSite
class ExchangeSiteNetworkClient(val impl: NetworkClient) : NetworkClient {
override suspend fun <T> executeRequest(req: NetworkRequest, f: suspend (NetworkResponse) -> T): T {
if (EhUtils.isExHentai) {
try {
return impl.executeRequest(req, f)
} catch (e: HttpException) {
if (e.response.code == 500) {
return impl.executeRequest(req.copy(url = flipThumbSite(req.url)), f)
}
throw e
}
} else {
return impl.executeRequest(req, f)
}
}
}
fun NetworkClient.exchangeSite() = ExchangeSiteNetworkClient(this)
@@ -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.url
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())
}
@@ -3,8 +3,6 @@ package com.hippo.ehviewer.coil
import android.graphics.Bitmap
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Extras
import coil3.asImage
import coil3.getExtra
@@ -30,25 +28,18 @@ object HardwareBitmapInterceptor : Interceptor {
val result = chain.proceed()
val request = result.request
if (!request.allowHardware && result is SuccessResult) {
var rect: IntRect? = null
val image = result.image
val bitmap = when (image) {
is BitmapImageWithRect -> image.image.also { rect = image.rect }
is BitmapImage -> image
else -> return result
}.bitmap
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
// This is not ideal but I haven't figured out how to probe the threshold.
// All we know is that it's less than the maximum texture size.
if (maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
bitmap.recycle()
val newImage = hwBitmap.asImage()
return result.copy(
image = rect?.let { BitmapImageWithRect(it, newImage as BitmapImage) } ?: newImage,
request = request,
dataSource = result.dataSource,
)
if (image is BitmapImageWithExtraInfo) {
val bitmap = image.image.bitmap
val isHardware = bitmap.config == Bitmap.Config.HARDWARE
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
// This is not ideal but I haven't figured out how to probe the threshold.
// All we know is that it's less than the maximum texture size.
if (!isHardware && maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
bitmap.recycle()
return result.copy(image = image.copy(image = hwBitmap.asImage()))
}
}
}
}
@@ -0,0 +1,28 @@
package com.hippo.ehviewer.coil
import coil3.network.NetworkClient
import coil3.network.NetworkRequest
import coil3.network.NetworkResponse
import com.hippo.ehviewer.client.URL_PREFIX_THUMB_EX
import com.hippo.ehviewer.client.URL_SIGNATURE_THUMB_NORMAL
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 NetworkClient.limitConcurrency() = LimitConcurrencyNetworkClient(this)
@@ -0,0 +1,29 @@
package com.hippo.ehviewer.coil
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Image
import coil3.intercept.Interceptor
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
data class BitmapImageWithExtraInfo(
val image: BitmapImage,
val rect: IntRect = IntRect(0, 0, image.width, image.height),
val hasQrCode: Boolean = false,
) : Image by image
object MapExtraInfoInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val result = chain.proceed()
val needMap = with(chain.request) { maybeCropBorder || detectQrCode || !allowHardware }
if (needMap && result is SuccessResult) {
val image = result.image
if (image is BitmapImage) {
return result.copy(image = BitmapImageWithExtraInfo(image))
}
}
return result
}
}
@@ -1,82 +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 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 kotlinx.coroutines.withContext
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.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorCoroutineContext) { 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 withContext(req.interceptorCoroutineContext) { 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()
}
}
}
@@ -0,0 +1,33 @@
package com.hippo.ehviewer.coil
import coil3.Extras
import coil3.getExtra
import coil3.intercept.Interceptor
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.image.hasQrCode
private val detectQrCodeKey = Extras.Key(default = false)
fun ImageRequest.Builder.detectQrCode(enable: Boolean) = apply {
extras[detectQrCodeKey] = enable
}
val ImageRequest.detectQrCode: Boolean
get() = getExtra(detectQrCodeKey)
object QrCodeInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val result = chain.proceed()
if (chain.request.detectQrCode && result is SuccessResult) {
val image = result.image
if (image is BitmapImageWithExtraInfo) {
val hasQrCode = hasQrCode(image.image.bitmap)
val new = image.copy(hasQrCode = hasQrCode)
return result.copy(image = new)
}
}
return result
}
}
@@ -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()
@@ -2,12 +2,13 @@ package com.hippo.ehviewer.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface DownloadArtistDao {
@Insert
suspend fun insert(downloadArtists: List<DownloadArtist>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnore(downloadArtists: List<DownloadArtist>)
@Query("DELETE FROM DOWNLOAD_ARTISTS WHERE GID = :gid")
suspend fun deleteByGid(gid: Long)
@@ -10,7 +10,7 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
@Dao
interface HistoryDao {
@Query("SELECT * FROM HISTORY ORDER BY TIME")
@Query("SELECT HISTORY.* FROM HISTORY JOIN GALLERIES USING(GID) ORDER BY TIME")
suspend fun list(): List<HistoryInfo>
@Query("SELECT GALLERIES.* FROM HISTORY JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
@@ -14,7 +14,7 @@ interface LocalFavoritesDao {
@Query("SELECT COUNT(*) FROM LOCAL_FAVORITES")
fun count(): Flow<Int>
@Query("SELECT * FROM LOCAL_FAVORITES ORDER BY TIME")
@Query("SELECT LOCAL_FAVORITES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY TIME")
suspend fun list(): List<LocalFavoriteInfo>
@Query("SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
@@ -27,7 +27,7 @@ interface LocalFavoritesDao {
fun joinListLazy(title: String): PagingSource<Int, BaseGalleryInfo>
@Query("SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY RANDOM() LIMIT 1")
suspend fun random(): BaseGalleryInfo
suspend fun random(): BaseGalleryInfo?
@Query("SELECT EXISTS(SELECT * FROM LOCAL_FAVORITES WHERE GID = :gid)")
suspend fun contains(gid: Long): Boolean
@@ -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,10 +15,12 @@
*/
package com.hippo.ehviewer.gallery
import android.os.ParcelFileDescriptor
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
@@ -27,142 +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.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,
passwdProvider: PasswdProvider? = null,
) : PageLoader2(gid, startPage) {
private lateinit var pfd: ParcelFileDescriptor
private val hostJob = launch(start = CoroutineStart.LAZY) {
logcat(DEBUG_TAG) { "Open archive ${file.toUri().displayPath}" }
pfd = file.openFileDescriptor("r")
size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
if (size == 0) {
return@launch
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) }
}
if (passwdProvider != null && needPassword()) {
archivePasswds?.forEach {
it ?: return@forEach
if (providePassword(it)) return@launch
}
val toAdd = passwdProvider { providePassword(it) }
archivePasswds = archivePasswds?.toMutableSet()?.apply { add(toAdd) } ?: setOf(toAdd)
}
}
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 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) ?: 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 {
hostJob.join()
return super.awaitReady() && size != 0
}
override val isReady: Boolean
get() = size != 0
override fun onCancelRequest(index: Int) {
mJobMap[index]?.cancel()
}
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 preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {}
}
private const val DEBUG_TAG = "ArchivePageLoader"
@@ -1,111 +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 onCancelRequest(index: Int) {
mSpiderQueen.cancelRequest(index)
}
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 preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {
mSpiderQueen.preloadPages(pages, pair)
loader.progressJob.join()
block(loader)
}
}
@@ -0,0 +1,47 @@
package com.hippo.ehviewer.gallery
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import com.hippo.ehviewer.image.Image
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
class Page(
val index: Int,
val statusFlow: MutableStateFlow<PageStatus> = MutableStateFlow(PageStatus.Queued),
)
fun Page.unblock() = statusFlow.update { status ->
when (status) {
is PageStatus.Blocked -> PageStatus.Ready(status.ad)
else -> error("Call unblock on page not blocked!!!")
}
}
fun Page.reset() = statusFlow.update { PageStatus.Queued }
val Page.status
get() = statusFlow.value
val Page.statusObserved
@Composable
get() = statusFlow.collectAsState().value
val PageStatus.progressObserved
@Composable
get() = when (this) {
is PageStatus.Loading -> progress.collectAsState().value
else -> 0f
}
sealed interface PageStatus {
data object Queued : PageStatus
@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,72 +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 androidx.annotation.CallSuper
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.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 = launch(start = CoroutineStart.LAZY) {
if (startPage == -1) {
startPage = EhDB.getReadProgress(gid)
}
}
override fun start() {
super.start()
progressJob.start()
}
@CallSuper
override suspend fun awaitReady(): Boolean {
progressJob.join()
return 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)
}
@@ -18,11 +18,13 @@
package com.hippo.ehviewer.image
import android.graphics.Bitmap
import android.graphics.drawable.Animatable
import androidx.compose.ui.unit.IntRect
import android.hardware.HardwareBuffer
import androidx.compose.ui.unit.IntSize
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import arrow.fx.coroutines.ExitCase
import arrow.fx.coroutines.bracketCase
import coil3.BitmapImage
import coil3.DrawableImage
import coil3.Image as CoilImage
@@ -34,7 +36,8 @@ import coil3.request.allowHardware
import coil3.size.Dimension
import coil3.size.Precision
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.coil.BitmapImageWithRect
import com.hippo.ehviewer.coil.BitmapImageWithExtraInfo
import com.hippo.ehviewer.coil.detectQrCode
import com.hippo.ehviewer.coil.hardwareThreshold
import com.hippo.ehviewer.coil.maybeCropBorder
import com.hippo.ehviewer.jni.isGif
@@ -46,31 +49,29 @@ 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
class Image private constructor(image: CoilImage, private val src: AutoCloseable) {
val size = image.size
val rect = when (image) {
is BitmapImageWithRect -> image.rect
else -> image.run { IntRect(0, 0, width, height) }
class Image private constructor(image: CoilImage, private val src: ImageSource) {
val intrinsicSize = with(image) { IntSize(width, height) }
val allocationSize = image.size
val hasQrCode = when (image) {
is BitmapImageWithExtraInfo -> image.hasQrCode
else -> false
}
var innerImage: CoilImage? = when (image) {
is BitmapImageWithRect -> image.image
is BitmapImageWithExtraInfo -> image.image
else -> image
}
var isRecyclable = true
@Synchronized
fun recycle() {
when (val image = innerImage ?: return) {
is DrawableImage -> {
(image.drawable as Animatable).stop()
image.drawable.callback = null
src.close()
}
is DrawableImage -> src.close()
is BitmapImage -> image.bitmap.recycle()
}
innerImage = null
@@ -79,7 +80,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
companion object {
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(): CoilImage {
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(checkExtraneousAds: Boolean): CoilImage {
val req = appCtx.imageRequest {
onLeft { data(it.source) }
onRight { data(it.source.toUri()) }
@@ -88,6 +89,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
allowHardware(false)
hardwareThreshold(Settings.hardwareBitmapThreshold)
maybeCropBorder(Settings.cropBorder.value)
detectQrCode(checkExtraneousAds)
memoryCachePolicy(CachePolicy.DISABLED)
}
return when (val result = appCtx.imageLoader.execute(req)) {
@@ -96,43 +98,34 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
}
}
suspend fun decode(src: ImageSource): 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)
}
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()
}
src.right().decodeCoil(checkExtraneousAds)
}
is ByteBufferSource -> {
if (isAtLeastP && !isAtLeastU) {
rewriteGifSource(src.source)
}
src.left().decodeCoil()
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()
}
}
}
}
@@ -148,4 +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)
@@ -10,4 +10,4 @@ external fun getExtension(index: Int): String
external fun needPassword(): Boolean
external fun providePassword(str: String): Boolean
external fun closeArchive()
external fun archiveFdBatch(fdBatch: IntArray, names: Array<String>, arcFd: Int, size: Int): Boolean
external fun archiveFdBatch(fdBatch: IntArray, names: Array<String>, arcFd: Int, size: Int)
@@ -0,0 +1,37 @@
package com.hippo.ehviewer.ktor
import com.hippo.ehviewer.BuildConfig
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
const val CHROME_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Safari/537.36"
private const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
private const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.9"
fun <T : HttpClientEngineConfig> HttpClientConfig<T>.configureCommon(redirect: Boolean = true) = apply {
install(HttpCookies) {
storage = EhCookieStore
}
install(HttpTimeout) {
requestTimeoutMillis = 10_000
}
install(UserAgent) {
agent = CHROME_USER_AGENT
}
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,21 @@
@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.NetworkException
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 com.hippo.ehviewer.util.restartApplication
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
@@ -15,6 +29,7 @@ import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.InternalAPI
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import io.ktor.utils.io.writeFully
import io.ktor.utils.io.writer
import java.nio.ByteBuffer
import kotlin.coroutines.CoroutineContext
@@ -26,17 +41,19 @@ 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
import splitties.preferences.edit
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)
@@ -47,7 +64,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(
@@ -86,16 +103,26 @@ 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 is NetworkException && error.errorCode == NetworkException.ERROR_OTHER) {
Settings.edit(true) {
enableCronet.value = false
}
config.context.restartApplication()
}
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()) }
@@ -116,7 +143,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)
}
},
@@ -132,6 +159,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
@@ -15,7 +24,6 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.XmlUtilInternal
import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.newWriter
import nl.adaptivity.xmlutil.serialization.XML
@@ -26,8 +34,6 @@ import okio.Path
const val COMIC_INFO_FILE = "ComicInfo.xml"
private const val TAG_ORIGINAL = "original"
// Workaround for https://youtrack.jetbrains.com/issue/KT-69182
@OptIn(XmlUtilInternal::class)
private val xml = XML {
recommended {
ignoreUnknownChildren()
@@ -45,31 +51,31 @@ 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
}
}
else -> simpleTags?.forEach { tagString ->
val (namespace, tag) = tagString.split(':', limit = 2)
when (val ns = TagNamespace(namespace)) {
.takeIf { it.size == 2 } ?: return@forEach // Ignore temp tags that don't have 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
}
}
@@ -46,16 +46,21 @@ import com.hippo.files.exists
import com.hippo.files.find
import com.hippo.files.isDirectory
import com.hippo.files.list
import com.hippo.files.mkdirs
import com.hippo.files.moveTo
import com.hippo.files.openFileDescriptor
import com.hippo.files.openOutputStream
import eu.kanade.tachiyomi.util.system.logcat
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.jvm.nio.copyTo
import kotlin.io.path.readText
import io.ktor.utils.io.copyTo
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
import kotlinx.coroutines.CancellationException
import okio.Path
@@ -67,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
@@ -90,22 +97,22 @@ class SpiderDen(val info: GalleryInfo) {
mode = value
if (mode == SpiderQueen.MODE_DOWNLOAD) {
if (downloadDir == null) {
downloadDir = getGalleryDownloadDir(info)
downloadDir = getGalleryDownloadDir(info).apply { mkdirs() }
}
if (saveAsCbz && tempDownloadDir == null) {
tempDownloadDir = info.tempDownloadDir!!
tempDownloadDir = info.tempDownloadDir!!.apply { mkdirs() }
}
}
}
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) = synchronized(fileCache) {
private fun findImageFile(index: Int, temp: Boolean = false) = lock.read {
val head = perFilename(index)
fileCache.entries.firstOrNull { (name) -> name.startsWith(head) }?.value
fileCache.entries.firstOrNull { (name) -> name.startsWith(head) && temp == name.endsWith(TEMP_SUFFIX) }?.value
}
private fun containInDownloadDir(index: Int): Boolean = findImageFile(index) != null
@@ -118,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)
@@ -144,20 +150,19 @@ class SpiderDen(val info: GalleryInfo) {
return sCache.remove(key)
}
private fun removeFromDownloadDir(index: Int) = findImageFile(index)?.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 remove(index: Int) = removeFromCache(index).also { removeFromDownloadDir(index) }
fun removeIntermediateFiles(index: Int) {
removeFromCache(index)
removeTempFile(index)
}
private fun Path.findDownloadFileForIndex(index: Int, extension: String): Path {
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(
@@ -171,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)
@@ -185,7 +193,16 @@ class SpiderDen(val info: GalleryInfo) {
crossinline fops: suspend (Path) -> Unit,
): Boolean {
imageDir?.run {
fops(findDownloadFileForIndex(index, ext))
val tempFile = findDownloadFileForIndex(index, "$ext$TEMP_SUFFIX")
fops(tempFile)
val file = resolve(tempFile.name.removeSuffix(TEMP_SUFFIX))
file.delete()
lock.write { fileCache.remove(file.name) }
tempFile.moveTo(file)
lock.write {
fileCache.remove(tempFile.name)
fileCache[file.name] = file
}
return true
}
@@ -207,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" }
}
}
}
@@ -241,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)
@@ -253,19 +272,19 @@ 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 {
FileUtils.getExtensionFromFilename(source.name)!!
}
override fun close() {}
override fun close() = Unit
}
}
@@ -300,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 {
@@ -315,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") }
@@ -328,33 +346,30 @@ class SpiderDen(val info: GalleryInfo) {
}
suspend fun initDownloadDir() {
downloadDir = getGalleryDownloadDir(info)
downloadDir = getGalleryDownloadDir(info).apply { mkdirs() }
}
suspend fun writeComicInfo(fetchMetadata: Boolean = true) {
downloadDir?.run {
resolve(COMIC_INFO_FILE).also {
runCatching {
if (info !is GalleryDetail && fetchMetadata) {
EhEngine.fillGalleryListByApi(listOf(info))
if (info !is GalleryDetail && fetchMetadata) {
EhEngine.fillGalleryListByApi(listOf(info))
}
info.getComicInfo().apply {
write(it)
DownloadManager.getDownloadInfo(gid)?.let { downloadInfo ->
downloadInfo.artistInfoList = DownloadArtist.from(gid, penciller.orEmpty())
EhDB.putDownloadArtist(gid, downloadInfo.artistInfoList)
}
info.getComicInfo().apply {
write(it)
DownloadManager.getDownloadInfo(gid)?.let { downloadInfo ->
downloadInfo.artistInfoList = DownloadArtist.from(gid, penciller.orEmpty())
EhDB.putDownloadArtist(gid, downloadInfo.artistInfoList)
}
}
}.onFailure {
logcat(it)
}
}
}
}
}
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
@@ -27,6 +25,7 @@ import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUrl.getGalleryDetailUrl
import com.hippo.ehviewer.client.EhUrl.getGalleryMultiPageViewerUrl
import com.hippo.ehviewer.client.EhUrl.referer
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.client.exception.QuotaExceededException
@@ -36,30 +35,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
@@ -68,10 +63,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)
@@ -120,7 +115,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
index,
mFinishedPages.get(),
mDownloadedPages.get(),
mPageStateArray.size,
pageStates.size,
)
}
}
@@ -134,7 +129,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
error,
mFinishedPages.get(),
mDownloadedPages.get(),
mPageStateArray.size,
pageStates.size,
)
}
}
@@ -146,50 +141,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) {
@@ -224,20 +203,19 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
check(!(mReadReference < 0 || mDownloadReference < 0)) { "Mode reference < 0" }
}
private val prepareJob = launch { 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.join()
return isReady
}
suspend fun awaitReady() = prepareJob.await()
private fun stop() {
val queenScope = this
@@ -245,9 +223,13 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
if (archiveJob.isActive) {
archiveJob.join()
}
if (!mSpiderDen.postArchive()) {
if (!spiderDen.postArchive()) {
if (mWorkerScope.isDownloadMode) {
mSpiderDen.writeComicInfo()
runCatching {
spiderDen.writeComicInfo()
}.onFailure {
logcat(it)
}
}
runCatching {
writeSpiderInfoToLocal()
@@ -260,38 +242,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 cancelRequest(index: Int) {
mWorkerScope.cancelDecode(index)
}
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)
@@ -308,7 +278,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
return if (STATE_FINISHED != state) {
false
} else {
mSpiderDen.saveToPath(index, file)
spiderDen.saveToPath(index, file)
}
}
@@ -317,11 +287,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
@@ -348,22 +318,20 @@ 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
}
private val isMpvAvailable = EhUtils.isMpvAvailable
suspend fun getPTokenFromMultiPageViewer(index: Int): String? {
val spiderInfo = mSpiderInfo
if (!isMpvAvailable) return null
val url = getGalleryMultiPageViewerUrl(
galleryInfo.gid,
galleryInfo.token,
@@ -382,8 +350,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
suspend fun getPTokenFromInternet(index: Int): String? {
val spiderInfo = mSpiderInfo
// Check previewIndex
var previewIndex: Int
previewIndex = if (spiderInfo.previewPerPage >= 0) {
@@ -414,8 +380,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
@@ -423,8 +389,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)) {
@@ -457,14 +423,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 {
@@ -475,11 +439,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() }
}
@@ -499,22 +463,16 @@ 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 delayLock = Mutex()
private val downloadDelay = Settings.downloadDelay.milliseconds
private var lastRequestTime = TimeSource.Monotonic.markNow()
var isDownloadMode = false
private set
fun cancelDecode(index: Int) {
decoder.cancel(index)
}
@Synchronized
fun enterDownloadMode() {
if (isDownloadMode) return
@@ -522,16 +480,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)
}
}
@@ -539,22 +502,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 {
if (!force && index in mSpiderDen) {
return@runCatching updatePageState(index, STATE_FINISHED)
}
delayLock.withLock {
delay(mDownloadDelay - lastRequestTime.elapsedNow())
lastRequestTime = TimeSource.Monotonic.markNow()
}
mSemaphore.withPermit {
semaphore.withPermit {
doInJob(index, force, orgImg, skipHath)
}
}.onFailure {
@@ -575,36 +529,36 @@ 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 }
?: getPTokenFromInternet(index)
?: getPTokenFromInternet(index)
return spiderInfo.pTokenMap[index]
?: getPTokenFromMultiPageViewer(index)
?: getPTokenFromInternet(index)
// Preview size may changed, so try to get pToken twice
?: getPTokenFromInternet(index)
}
updatePageState(index, STATE_DOWNLOADING)
val previousPToken: String?
val pToken: String
pTokenLock.withLock {
if (force) {
if (mSpiderInfo.pTokenMap[index] == TOKEN_FAILED) {
mSpiderInfo.pTokenMap.remove(index)
}
if (!force && 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
}
pToken = getPToken(index) ?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE)
previousPToken = getPToken(index - 1)
// The lock for delay should be acquired before anything else to maintain FIFO order
delay(downloadDelay - lastRequestTime.elapsedNow())
lastRequestTime = TimeSource.Monotonic.markNow()
}
updatePageState(index, STATE_DOWNLOADING)
var skipHathKey: String? = null
var originImageUrl: String? = null
@@ -636,7 +590,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
if (imageUrl == null) {
runSuspendCatching {
EhEngine.getGalleryPageApi(
mSpiderInfo.gid,
spiderInfo.gid,
index,
pToken,
localShowKey,
@@ -658,48 +612,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 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.remove(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()
}
}
}
@@ -712,61 +656,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 suspend fun doInJob(index: Int) {
val src = mSpiderDen.getImageSource(index) ?: return
val image = Image.decode(src)
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"
@@ -18,6 +18,7 @@ package com.hippo.ehviewer.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -54,6 +55,7 @@ import com.hippo.ehviewer.download.downloadDir
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.download.tempDownloadDir
import com.hippo.ehviewer.ui.destinations.ReaderScreenDestination
import com.hippo.ehviewer.ui.reader.ReaderScreenArgs
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.util.FavouriteStatusRouter
@@ -68,7 +70,6 @@ import com.hippo.files.delete
import com.hippo.files.isDirectory
import com.hippo.files.openOutputStream
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.sync.Mutex
@@ -105,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,
@@ -280,21 +280,11 @@ suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorite
localFavorited = EhDB.containLocalFavorites(galleryInfo.gid),
)
context (Context, DestinationsNavigator)
fun navToReader(
info: BaseGalleryInfo,
page: Int = -1,
) {
if (Settings.newReader) {
navigate(ReaderScreenDestination(info, page))
} else {
val intent = Intent(this@Context, ReaderActivity::class.java)
intent.action = ReaderActivity.ACTION_EH
intent.putExtra(ReaderActivity.KEY_GALLERY_INFO, info)
intent.putExtra(ReaderActivity.KEY_PAGE, page)
startActivity(intent)
}
}
fun DestinationsNavigator.navToReader(info: BaseGalleryInfo, page: Int = -1) = navToReader(ReaderScreenArgs.Gallery(info, page))
fun DestinationsNavigator.navToReader(uri: Uri) = navToReader(ReaderScreenArgs.Archive(uri))
private fun DestinationsNavigator.navToReader(args: ReaderScreenArgs) = navigate(ReaderScreenDestination(args)) { launchSingleTop = true }
context(DialogState, Context, DestinationsNavigator)
suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
@@ -1,25 +1,37 @@
package com.hippo.ehviewer.ui
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import com.hippo.ehviewer.ui.screen.implicit
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.NoopTransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.togetherWith
import com.hippo.ehviewer.util.findActivity
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.coroutines.CoroutineScope
@Composable
inline fun <R> composing(
inline fun <R> AnimatedVisibilityScope.composing(
navigator: DestinationsNavigator,
block: @Composable context(MainActivity, SnackbarHostState, DialogState, DestinationsNavigator, CoroutineScope)
block: @Composable context(MainActivity, SnackbarHostState, DialogState, SharedTransitionScope, TransitionsVisibilityScope, DestinationsNavigator, CoroutineScope)
() -> R,
) = block(
LocalContext.current.run { remember { findActivity<MainActivity>() } },
LocalSnackBarHostState.current,
LocalDialogState.current,
navigator,
rememberCoroutineScope(),
)
) = with(NoopTransitionsVisibilityScope) {
togetherWith(implicit<AnimatedVisibilityScope>()) {
block(
with(LocalContext.current) { remember { findActivity() } },
LocalSnackBarHostState.current,
LocalDialogState.current,
LocalSharedTransitionScope.current,
this,
navigator,
rememberCoroutineScope(),
)
}
}
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -20,7 +21,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -62,10 +62,10 @@ private fun GalleryDetail.generateContent() = arrayOf(
R.string.favorite_name to favoriteName,
)
context(Context, DestinationsNavigator)
@Composable
fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigator) {
fun GalleryInfoBottomSheet(detail: GalleryDetail) {
Column(modifier = Modifier.fillMaxWidth()) {
val context = LocalContext.current
Text(
text = stringResource(id = R.string.gallery_info),
modifier = Modifier.align(Alignment.CenterHorizontally),
@@ -79,10 +79,10 @@ fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigat
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)).clickable {
if (index == INDEX_PARENT) {
if (content != null) {
navigator.navWithUrl(content)
navWithUrl(content)
}
} else {
context.addTextToClipboard(content, true)
addTextToClipboard(content, true)
if (index == INDEX_URL) {
// Save it to avoid detect the gallery
Settings.clipboardTextHashCode = data[index].hashCode()
@@ -17,6 +17,8 @@ package com.hippo.ehviewer.ui
import android.annotation.SuppressLint
import android.app.assist.AssistContent
import android.content.ContentResolver.SCHEME_CONTENT
import android.content.ContentResolver.SCHEME_FILE
import android.content.Intent
import android.content.pm.verify.domain.DomainVerificationManager
import android.content.pm.verify.domain.DomainVerificationUserState.DOMAIN_STATE_NONE
@@ -24,6 +26,7 @@ import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
@@ -134,6 +137,7 @@ import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.NoopSharedTransitionScope
import com.hippo.ehviewer.updater.AppUpdater
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.addTextToClipboard
@@ -147,7 +151,7 @@ import com.hippo.ehviewer.util.sha1
import com.hippo.files.isDirectory
import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
import com.ramcosta.composedestinations.spec.Direction
import com.ramcosta.composedestinations.utils.currentDestinationAsState
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import eu.kanade.tachiyomi.util.lang.withIOContext
@@ -162,7 +166,7 @@ import moe.tarsin.coroutines.runSuspendCatching
import splitties.systemservices.clipboardManager
import splitties.systemservices.connectivityManager
private val navItems = arrayOf<Triple<DirectionDestinationSpec, Int, ImageVector>>(
private val navItems = arrayOf<Triple<Direction, Int, ImageVector>>(
Triple(HomePageScreenDestination, R.string.homepage, Icons.Default.Home),
Triple(SubscriptionScreenDestination, R.string.subscription, EhIcons.Default.Subscriptions),
Triple(WhatshotScreenDestination, R.string.whats_hot, Icons.Default.Whatshot),
@@ -230,7 +234,7 @@ class MainActivity : EhActivity() {
suspend fun DialogState.checkDownloadLocation() {
val valid = withIOContext { downloadLocation.isDirectory }
if (!valid) {
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.open_settings,
title = R.string.waring,
showCancelButton = false,
@@ -244,18 +248,24 @@ class MainActivity : EhActivity() {
}
}
val hasNetwork = remember { connectivityManager.activeNetwork != null }
if (!AppConfig.isBenchmark) {
val noNetwork = stringResource(R.string.no_network)
LaunchedEffect(Unit) {
runCatching { dialogState.checkDownloadLocation() }
runCatching { dialogState.checkAppLinkVerify() }
runSuspendCatching {
withIOContext {
AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
if (hasNetwork) {
runSuspendCatching {
withIOContext {
AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
}
}
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
}
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
} else {
snackbarState.showSnackbar(noNetwork)
}
}
}
@@ -265,10 +275,19 @@ class MainActivity : EhActivity() {
intentFlow.collect { intent ->
when (intent.action) {
Intent.ACTION_VIEW -> {
val url = intent.data?.toString()
if (url != null && !navigator.navWithUrl(url)) {
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
addTextToClipboard(new)
val uri = intent.data ?: return@collect
when (uri.scheme) {
SCHEME_CONTENT, SCHEME_FILE -> {
navigator.navToReader(uri)
}
else -> {
val url = uri.toString()
if (!navigator.navWithUrl(url)) {
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
addTextToClipboard(new)
}
}
}
}
Intent.ACTION_SEND -> {
@@ -357,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,
@@ -450,12 +470,22 @@ class MainActivity : EhActivity() {
drawerState = sideSheetState,
gesturesEnabled = sheet != null && drawerEnabled,
) {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
// https://issuetracker.google.com/336140982
// SharedTransitionLayout {
CompositionLocalProvider(LocalSharedTransitionScope provides NoopSharedTransitionScope) {
val start = when {
needSignIn -> SignInScreenDestination
hasNetwork -> StartDestination
else -> DownloadsScreenDestination
}
DestinationsNavHost(
navGraph = NavGraphs.root,
start = start,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
}
// }
}
}
}
@@ -477,7 +507,7 @@ class MainActivity : EhActivity() {
val hasUnverified = userState.hostToStateMap.values.any { it == DOMAIN_STATE_NONE }
if (hasUnverified) {
var checked by mutableStateOf(false)
awaitPermissionOrCancel(
awaitConfirmationOrCancel(
confirmText = R.string.open_settings,
title = R.string.app_link_not_verified_title,
onCancelButtonClick = {
@@ -508,7 +538,7 @@ class MainActivity : EhActivity() {
Uri.parse("package:$packageName"),
)
startActivity(intent)
} catch (t: Throwable) {
} catch (_: Throwable) {
val intent = Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName"),
@@ -537,6 +567,7 @@ val LocalSideSheetState = compositionLocalOf<DrawerState2> { error("CompositionL
val LocalDrawerHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalDrawerHandle not present!") }
val LocalSnackBarHostState = compositionLocalOf<SnackbarHostState> { error("CompositionLocal LocalSnackBarHostState not present!") }
val LocalSnackBarFabPadding = compositionLocalOf<State<Dp>> { error("CompositionLocal LocalSnackBarFabPadding not present!") }
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> { error("CompositionLocal LocalSharedTransitionScope not present!") }
@Composable
fun DrawerHandle(enabled: Boolean) {
@@ -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() {
@@ -5,10 +5,10 @@ import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.ComposeView
import androidx.navigation.NavBackStackEntry
import com.hippo.ehviewer.ui.theme.EhTheme
import com.hippo.ehviewer.ui.tools.DialogState
@@ -19,25 +19,11 @@ import soup.compose.material.motion.animation.materialSharedAxisXIn
import soup.compose.material.motion.animation.materialSharedAxisXOut
import soup.compose.material.motion.animation.rememberSlideDistance
inline fun ComposeView.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
EhTheme {
val dialogState = remember { DialogState() }
dialogState.Intercept()
CompositionLocalProvider(
LocalDialogState provides dialogState,
) {
content()
}
}
}
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
EhTheme {
EhTheme(useDarkTheme = isSystemInDarkTheme()) {
ProvideVectorPainterCache {
val dialogState = remember { DialogState() }
dialogState.Intercept()
CompositionLocalProvider(
LocalDialogState provides dialogState,
LocalDialogState provides remember { DialogState() }.apply { Intercept() },
) {
content()
}
@@ -45,15 +31,17 @@ inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -
}
}
private typealias Ty = AnimatedContentTransitionScope<NavBackStackEntry>
@Composable
fun rememberEhNavAnim(): NavHostAnimatedDestinationStyle {
val slideDistance = rememberSlideDistance()
return remember(slideDistance) {
object : NavHostAnimatedDestinationStyle() {
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { materialSharedAxisXIn(true, slideDistance) }
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { materialSharedAxisXOut(true, slideDistance) }
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { materialSharedAxisXIn(false, slideDistance) }
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { materialSharedAxisXOut(false, slideDistance) }
override val enterTransition: Ty.() -> EnterTransition = { materialSharedAxisXIn(true, slideDistance) }
override val exitTransition: Ty.() -> ExitTransition = { materialSharedAxisXOut(true, slideDistance) }
override val popEnterTransition: Ty.() -> EnterTransition = { materialSharedAxisXIn(false, slideDistance) }
override val popExitTransition: Ty.() -> ExitTransition = { materialSharedAxisXOut(false, slideDistance) }
}
}
}
@@ -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,29 +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 com.hippo.ehviewer.ktbuilder.imageRequest
class CoilImageGetter(
private val textView: TextView,
) : Html.ImageGetter {
override fun getDrawable(source: String) = object : DrawableWrapper(null) {}.apply {
textView.context.imageLoader.enqueue(
textView.context.imageRequest {
data(source)
crossfade(false)
target { drawable ->
setDrawable(drawable.asDrawable(textView.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)
}
}
Loaded 100 of 294 files, more files were not shown because too many files have changed in this diff. Show more