Compare commits

...
157 Commits
Author SHA1 Message Date
FooIbar 37660ef697 Prepare for release 1.10.2
Releases / Build (push) Has been cancelled
2023-12-31 19:50:11 +08:00
FooIbar 9821b3aaab Fix lint 2023-12-31 18:55:48 +08:00
FooIbar 1f5331af5a Update lint plugin and fix some issues 2023-12-31 18:55:48 +08:00
Vladimir DostoyevskiyandGG Bond 3ea6c82a6d Don't unwind (#574)
* Don't unwind

* Fixes

* Abort in panic

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-31 09:39:29 +00:00
FooIbar ba53d01fa1 Show tip if move favorites failed 2023-12-31 17:29:58 +08:00
FooIbar df8ae8d11a Refine crop border (#573)
* Refresh adapter instead of recreate viewer

* Convert crop broder icons to ImageVector

* Cleanup res
2023-12-31 07:42:01 +00:00
Vladimir DostoyevskiyandGG Bond c817726ab3 Use Cbor (#572)
* Track marshal/unmarshal performance

* Use Cbor

* Refine

* Drop logging

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-31 07:36:42 +00:00
FooIbar 9eb606810f Avoid detecting gallery URL copied from share menu 2023-12-31 14:10:16 +08:00
Vladimir DostoyevskiyandGG Bond fbb030ec8e Drop custom PtrCursor (#569)
* Drop custom PtrCursor

* Drop unused crate

* Fixup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-31 05:51:14 +00:00
FooIbar 28afebefb1 Follow coil 3 API changes (#570)
* Follow coil 3 API changes

* Use coil 3.0.0-alpha01
2023-12-31 05:14:25 +00:00
FooIbar da2610657c Fix typo 2023-12-31 12:37:17 +08:00
Vladimir DostoyevskiyandGG Bond 6e3471ba1b Refactor Rust parser, decouple with JNI for better further KMP support (#568)
* Use serde & kotlinx serialization to marshal jni data

* Introduce unmarshalParsingAs

* Comments

* Introduce parse_marshal_inplace

* Convert parseLimit

* Aha, convert parseGalleryInfoList

* Drop cache_unwind

* Unwind manually

* Fixes

* Drop jnix, update jni

* Data class

* Cleanups

* Cleanups

* Optimize a copy

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-30 20:45:11 +00:00
FooIbar 5a9ab6c4eb Fix snackbar dismiss action color 2023-12-31 01:25:47 +08:00
1fe553d039 Initial crop Border using cpu (#530)
* Initial get crop rect for bitmap

* Adapt for RGBA8888

* Support crop with rect in SubsamplingScaleImageView

* Settings to control crop border

* BottomBar to control crop border

* Fix rgb detect

* Update border.c

* Update Image.kt

* Fix webtoon

* Don't copy if not isAtLeastQ

* Refine

* Avoid too large bitmap

* Fix grey calculation

* Revert "Avoid too large bitmap"

This reverts commit d58dbe12062586ffe4ce4b872aace48b23ad9fde.

* Use matrix to draw bitmap to bypass the size limit

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2023-12-30 13:04:58 +00:00
GG Bond 42e0b0aeab Drop explicitly fabbuilder reallocation, buildFab within snapshotFlow 2023-12-30 11:44:27 +08:00
GG Bond 9e0ea32296 Update snapshot 2023-12-29 23:59:09 +08:00
FooIbar d3f5274b7e Hide comment button in guest mode 2023-12-29 22:37:10 +08:00
FooIbar faff0733e9 Fix unintended spaces in comments 2023-12-29 22:11:47 +08:00
FooIbar 7b32cde7dd Reword current image limits to reduce confusion 2023-12-29 21:14:12 +08:00
FooIbar 648d1d10b3 Always retrieve gallery detail from cache
Serialization & deserialization will create a new copy
2023-12-29 21:10:34 +08:00
Vladimir DostoyevskiyandGG Bond 52727100a0 Use ParcelFileDescriptor.open for RawFile, SAF sucks! (#561)
* Use ParcelFileDescriptor.open for RawFile, SAF sucks!

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-29 20:09:45 +08:00
FooIbar 8a2dbb99f0 Fix comments not showing after commenting 2023-12-29 13:22:36 +08:00
Vladimir DostoyevskiyandGG Bond d880b30ea9 Move to coil NetworkFetcher (#558)
* Move to coil NetworkFetcher

* Fixup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-29 04:15:24 +00:00
renovate[bot] 0e2ca4e150 Update dependency org.jsoup:jsoup to v1.17.2 2023-12-29 03:48:18 +00:00
FooIbar e0eef82ed8 Fix crash when suggestions have the same keyword 2023-12-29 09:51:00 +08:00
FooIbar 09839086a2 Refactor drag reordering 2023-12-29 09:17:46 +08:00
renovate[bot] 3ca3ab7273 Update compose.destinations to v1.9.56 2023-12-28 05:09:40 +00:00
GG Bond 8386bf9c69 Fix regression again 2023-12-28 00:39:06 +08:00
GG Bond 99b8ee1f78 Adjust anim specs 2023-12-28 00:39:06 +08:00
GG Bond 0333a8c679 Always enable predictive nav 2023-12-28 00:39:06 +08:00
GG Bond e0bb09e902 Update compose snapshot, Resolve #413 2023-12-28 00:39:06 +08:00
GG Bond 77677cead3 Fix regression 2023-12-27 22:39:35 +08:00
GG Bond d4b2ff7de9 New nav anim motion 2023-12-27 22:39:35 +08:00
GG Bond b81bae09d1 Update Predictive Back for NavHost, use SeekableTransitionState 2023-12-27 22:39:35 +08:00
Vladimir DostoyevskiyandGG Bond 846e66a56c UniFile Refactors (#546)
* Use DocumentsContractCompat

* Make UniFile.imageSource a extension

* Make UniFile.openFileDescriptor a extension

* Use appCtx

* Use appCtx

* Use appCtx

* Use appCtx

* Drop saveImageToLauncher

* Cleanup

* Use operator resolve

* Simplify

* Fix Toast

* Fixup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-27 08:00:32 +00:00
FooIbar 5cec635e52 Update lock-threads.yml
[no ci]
2023-12-27 15:45:35 +08:00
Vladimir DostoyevskiyandGG Bond 8d0dc88125 Implement lookup cache for UniFile (#545)
* Implement lookup cache for RawFile

* Implement lookup cache for TreeDocumentFile

* Drop unused UriHandler

* UniFile.subFile never return null

* Nonnull UniFile.fromFile

* SingleDocumentFile use appCtx

* TreeDocumentFile use appCtx

* MediaFile use appCtx

* Refine

* Refine

* Refine

* optimize TreeDocumentFile cache

* optimize RawFile cache

* Take care of dir lookup cache cause memleak

* Fixup

* Fixup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-27 04:30:20 +00:00
FooIbar 51b58bb797 Convert MainActivity dialogs to use DialogState (#544)
* Convert MainActivity dialogs to use DialogState

* Always open settings if the button is clicked
2023-12-26 14:58:00 +00:00
Vladimir DostoyevskiyandGG Bond f79aa2350c Refactor UniFile, allow all image format, Resolve #529 (#543)
* Refactor UniFile, allow all image format, Resolve #529

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-26 14:50:28 +00:00
FooIbar 719dddeab9 Fix search bar hint not updating 2023-12-26 21:12:32 +08:00
FooIbar c1522568fd Cleanup legacy code 2023-12-26 20:50:31 +08:00
FooIbar 3ad6b9106b Merge ConfigureActivity into MainActivity 2023-12-26 20:45:22 +08:00
FooIbar 549387ac91 Strip okhttp's public suffix database (#539)
As cookies are handled by ktor now
2023-12-25 20:24:42 +08:00
Vladimir DostoyevskiyandGG Bond 7d2f985ffa Introduce DownloadThumbInterceptor (#538)
* Introduce DownloadThumbInterceptor

* Fixup

* Update DownloadInfoMagics.kt

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-25 09:27:49 +00:00
GG Bond 7e8b0a5800 No longer need restart to switch HC 2023-12-23 15:23:45 +08:00
FooIbar 69252ae8a4 Fix old download label not displayed when renaming 2023-12-22 14:53:45 +08:00
FooIbar eeed56b211 Fix crash when suggestions have the same keyword 2023-12-22 14:44:06 +08:00
renovate[bot] 8aaad071ea Update dependency com.google.devtools.ksp to v2.0.0-Beta2-1.0.16 2023-12-20 00:51:14 +00:00
FooIbar 7ef67e1bf9 Update CHANGELOG.zh-CN.MD
[no ci]
2023-12-19 19:05:16 +08:00
FooIbar 1c15d9a956 Prepare for next iteration 2023-12-19 18:36:49 +08:00
FooIbar 70082090a1 Prepare for release 1.10.1
Releases / Build (push) Has been cancelled
2023-12-19 18:34:50 +08:00
FooIbar dbc1a32496 Update changelog for 1.10.1 (#526)
[no ci]
2023-12-19 18:34:04 +08:00
FooIbar e8c9ee5dd0 Cleanup obsolete logs 2023-12-19 16:41:33 +08:00
FooIbar 5c3657736b Dump heap on OOM 2023-12-19 16:41:33 +08:00
FooIbar 5191408d06 Provide dismiss action for snackbars with actions 2023-12-19 14:09:33 +08:00
FooIbar 347ce6854d Fix missing content warning cookie in guest mode 2023-12-19 11:17:58 +08:00
FooIbar f86dec9b2e Workaround unable to exit search field on API < 28 2023-12-19 09:46:24 +08:00
FooIbar c74b128b1a Debounce slider value changes 2023-12-19 09:46:13 +08:00
FooIbar 8ee28a4b4f Update history regardless of availability
In case the gallery is taken down by DMCA.
2023-12-18 13:56:01 +08:00
renovate[bot] 3b07b6997d Update dependency com.squareup.okhttp3:okhttp-bom to v5.0.0-alpha.12 2023-12-17 16:15:10 +00:00
FooIbar f59023ed03 Disable some unnecessary logging 2023-12-17 23:05:47 +08:00
FooIbar 3fc13e61c3 Refine download delay 2023-12-17 22:52:04 +08:00
FooIbar ae22f448a8 Revert to use OkHttp as fallback engine
This partially reverts commit 7b67210876

Reason for revert: `setApplicationProtocols` used by Apache 5 HC is not
available until API 29.
2023-12-17 19:12:56 +08:00
renovate[bot] 450c15d51e Update dependency com.squareup.okio:okio-jvm to v3.7.0 2023-12-17 01:17:50 +00:00
FooIbar d9e5bb4399 Fix unreleased lock 2023-12-17 01:03:57 +08:00
FooIbar 369904f659 Don't update page state on force retry 2023-12-16 16:35:49 +08:00
FooIbar 96302b2344 Log before making requests instead of after 2023-12-16 16:26:13 +08:00
FooIbar eeffb12e3b Cleanup code 2023-12-15 15:42:03 +08:00
FooIbar 57c12f7df0 Prepare for next iteration 2023-12-14 22:54:08 +08:00
FooIbar 37eb2f1aed Prepare for release 1.10.0
Releases / Build (push) Has been cancelled
2023-12-14 22:50:41 +08:00
renovate[bot] c70ec5f354 Update dependency dev.chrisbanes.compose:compose-bom to v2023.12.00-alpha04 2023-12-14 14:42:27 +00:00
FooIbar 7139af4629 Update renovate.json
[no ci]
2023-12-14 22:25:27 +08:00
FooIbar a2bac029f5 Update renovate.json
[no ci]
2023-12-14 22:21:04 +08:00
FooIbar 6c68074e8b Update CHANGELOG.zh-CN.MD (#503)
[no ci]
2023-12-14 22:12:55 +08:00
FooIbar 991caf73f4 Fix ReaderPageSheet padding and ripple 2023-12-14 20:18:40 +08:00
FooIbar 0ba11641fa Update compose compiler 2023-12-14 19:45:32 +08:00
FooIbar b51120e914 Drop deprecated Accompanist libraries (#500)
* Migrate XML themes to Compose

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

* Refine

---------

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

* Fix R8

---------

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

* Cleanup unused

---------

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

* Drop unnecessary fix

---------

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

* Use Compose to animate bottom reader menu bars

* Cleanup unused

* Refine

* Cleanup to use DropdownMenu

* Cleanup unused

* Refine BottomReaderBar layout

---------

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

* Delay for bulk favorite adding to prevent flooding

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

This reverts commit 0c175453101aab2d6d200a661259906291ceac36.

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

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

* Move to Apache5 HC instead

* Fix build

* Credit to Ktor instead

* Refactor Cronet Preference

---------

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

* Fix

---------

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

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

* Refine

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

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

* Update EhRequestBuilder.kt

* Update EhRequestBuilder.kt

---------

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

* Support ByteArrayContent

* Cleanup

---------

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

* Migration

* Migration

* Migration

* Make EhCookieStore a CookiesStorage

* Drop CookieDB migration

* Drop one Okhttp cookie usage

* Drop one Okhttp cookie usage

* Remove typealias

* Move EhTagDatabase to use ktor

* Make cronet just use Dispatchers.IO

* Make all okhttpClient internally used

* Cleanup

* Refine

---------

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

* Drop chunker since we no more se okhttp

* Move getGalleryDetail to ktor

* Migration

* Migration

* Migration

* Migration

* Migration

* Migration

* Migration

* Migration

---------

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

No files matched your search

-1
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- '*'
- 'renovate/**'
pull_request:
workflow_dispatch:
-1
View File
@@ -18,5 +18,4 @@ jobs:
- uses: dessant/lock-threads@v5
with:
issue-inactive-days: '1'
include-any-issue-labels: 'duplicate, wontfix'
process-only: 'issues'
+60 -1
View File
@@ -1,6 +1,65 @@
# 变更日志
## 1.9.0
## [1.10.2] - 2023-12-31
### 新功能
* [阅读器] 支持裁剪图片黑色/白色边缘
### 改进
* 切换 HTTP 引擎无需重启
* 优化过渡动画
* 杂项性能优化
### Bug 修复
* 修复搜索建议中出现关键词相同的条目时崩溃 [#533](https://github.com/FooIbar/EhViewer/issues/533) [#555](https://github.com/FooIbar/EhViewer/issues/555)
* 修复重命名下载标签时不显示原标签 [#534](https://github.com/FooIbar/EhViewer/issues/534)
* 修复调整下载项目顺序时卡顿 [#552](https://github.com/FooIbar/EhViewer/issues/552)
* 修复新增或修改评论后黑屏 [#554](https://github.com/FooIbar/EhViewer/issues/554)
* 修复剪贴板读取bug [#566](https://github.com/FooIbar/EhViewer/issues/566)
* 修复画廊信息对话框无法通过返回按钮/手势关闭 [#413](https://github.com/FooIbar/EhViewer/issues/413)
## [1.10.1] - 2023-12-19
### 重大变更
* 由于 Apache HttpClient 5 在 Android 10 以下无法使用,后备 HTTP 引擎恢复为 OkHttp [#514](https://github.com/FooIbar/EhViewer/issues/514)
### Bug 修复
* 修复获取 pToken 失败导致无限加载 [#508](https://github.com/FooIbar/EhViewer/issues/508)
* 修复阅读页快速滑动滑块导致下载延时过多累计 [#512](https://github.com/FooIbar/EhViewer/issues/512)
* 修复 Android 9 以下的设备上无法退出搜索栏 [#513](https://github.com/FooIbar/EhViewer/issues/513)
## [1.10.0] - 2023-12-14 [YANKED]
### 重大变更
* 后备 HTTP 引擎变更为 Apache HttpClient 5
* 类别和高级搜索选项移至搜索栏下方
### 新功能
* 支持在搜索时指定画廊语言
### 改进
* 在下载中阅读画廊时会更新历史记录
* 多处性能改进
### Bug 修复
* 修复无法由外部应用进入画廊页面 [#436](https://github.com/FooIbar/EhViewer/issues/436)
* 修复点击精确到页的画廊链接闪退 [#430](https://github.com/FooIbar/EhViewer/issues/430)
* 修复无法搜索本地收藏 [#491](https://github.com/FooIbar/EhViewer/issues/491)
* 修复 CI 版本不显示变更日志
## [1.9.0] - 2023-12-01
### 重大变更
+1
View File
@@ -96,6 +96,7 @@ Here is the libraries
- [MDC-Android](https://github.com/material-components/material-components-android)
- [Material Icons](https://github.com/google/material-design-icons)
- [OkHttp](https://square.github.io/okhttp/)
- [Ktor](https://ktor.io/)
- [Coil](https://coil-kt.github.io/coil/)
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
- [libarchive](http://www.libarchive.org/)
+6 -15
View File
@@ -64,9 +64,8 @@ android {
applicationId = "moe.tarsin.ehviewer"
minSdk = 28
targetSdk = 34
versionCode = 180046
versionName = "1.10.0"
versionNameSuffix = "-SNAPSHOT"
versionCode = 180049
versionName = "1.10.2"
resourceConfigurations.addAll(
listOf(
"zh",
@@ -132,7 +131,7 @@ android {
"-Xjvm-default=all",
"-Xlambdas=indy",
"-opt-in=coil.annotation.ExperimentalCoilApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
@@ -148,7 +147,6 @@ android {
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:intrinsicRemember=true",
)
}
@@ -160,6 +158,7 @@ android {
packaging {
resources {
excludes += "/META-INF/**"
excludes += "/okhttp3/**"
excludes += "/kotlin/**"
excludes += "**.txt"
excludes += "**.bin"
@@ -213,7 +212,6 @@ dependencies {
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.coordinatorlayout)
// https://developer.android.com/jetpack/androidx/releases/lifecycle
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.compose)
@@ -223,7 +221,7 @@ dependencies {
implementation(libs.bundles.androidx.navigation)
// https://developer.android.com/jetpack/androidx/releases/paging
implementation(libs.bundles.androidx.paging)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.recyclerview)
@@ -234,9 +232,8 @@ dependencies {
implementation(libs.androidx.work.runtime)
implementation(libs.photoview) // Dead Dependency
implementation(libs.directionalviewpager) // Dead Dependency
// https://github.com/google/accompanist/releases
implementation(libs.bundles.accompanist)
implementation(libs.material)
implementation(libs.material.motion.core)
implementation(libs.bundles.splitties)
@@ -269,9 +266,6 @@ dependencies {
implementation(libs.kotlinx.datetime)
implementation(libs.jsoup)
debugImplementation(libs.chucker)
releaseImplementation(libs.chucker.nop)
coreLibraryDesugaring(libs.desugar)
"gmsImplementation"(libs.bundles.cronet)
@@ -279,9 +273,6 @@ dependencies {
configurations.all {
resolutionStrategy {
// Workaround IME won't show again once hidden.
// Google issue-tracker link?
force("androidx.compose.ui:ui-text-android:1.6.0-alpha08")
exclude("androidx.navigation", "navigation-compose")
}
}
+4 -4
View File
@@ -11,10 +11,10 @@
public static final ** CREATOR;
}
# https://issuetracker.google.com/issues/307323842
-keepclassmembers class androidx.compose.ui.platform.WindowInfoImpl {
setWindowFocused(boolean);
}
# Ktor logger
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn org.conscrypt.Conscrypt
-allowaccessmodification
-repackageclasses
@@ -1,88 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "c801ee62729ba20a203b150211234d5c",
"entities": [
{
"tableName": "OK_HTTP_3_COOKIE",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`NAME` TEXT, `VALUE` TEXT, `EXPIRES_AT` INTEGER NOT NULL, `DOMAIN` TEXT, `PATH` TEXT, `SECURE` INTEGER NOT NULL, `HTTP_ONLY` INTEGER NOT NULL, `PERSISTENT` INTEGER NOT NULL, `HOST_ONLY` INTEGER NOT NULL, `_id` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "value",
"columnName": "VALUE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "expiresAt",
"columnName": "EXPIRES_AT",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "DOMAIN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "path",
"columnName": "PATH",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "secure",
"columnName": "SECURE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpOnly",
"columnName": "HTTP_ONLY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "persistent",
"columnName": "PERSISTENT",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hostOnly",
"columnName": "HOST_ONLY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c801ee62729ba20a203b150211234d5c')"
]
}
}
@@ -1,88 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "1b80cd29939b0f43934721f1289ba94d",
"entities": [
{
"tableName": "OK_HTTP_3_COOKIE",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`NAME` TEXT NOT NULL, `VALUE` TEXT NOT NULL, `EXPIRES_AT` INTEGER NOT NULL, `DOMAIN` TEXT NOT NULL, `PATH` TEXT NOT NULL, `SECURE` INTEGER NOT NULL, `HTTP_ONLY` INTEGER NOT NULL, `PERSISTENT` INTEGER NOT NULL, `HOST_ONLY` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "VALUE",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "expiresAt",
"columnName": "EXPIRES_AT",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "DOMAIN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "PATH",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "secure",
"columnName": "SECURE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpOnly",
"columnName": "HTTP_ONLY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "persistent",
"columnName": "PERSISTENT",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hostOnly",
"columnName": "HOST_ONLY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1b80cd29939b0f43934721f1289ba94d')"
]
}
}
@@ -1,106 +1,20 @@
package com.hippo.ehviewer.cronet
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.CHROME_ACCEPT
import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
import com.hippo.ehviewer.client.EhCookieStore
import java.nio.ByteBuffer
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.RequestBody
import okio.Buffer
import okio.Path.Companion.toOkioPath
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException
import org.chromium.net.UploadDataProvider
import org.chromium.net.UploadDataSink
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
import splitties.init.appCtx
val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
enableBrotli(true)
setUserAgent(Settings.userAgent)
// Cache Quic hint only since the real cache mechanism should on Ktor layer
val cache = (appCtx.cacheDir.toOkioPath() / "http_cache").toFile().apply { mkdirs() }
setStoragePath(cache.absolutePath)
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 100 * 1024)
setUserAgent(Settings.userAgent)
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096)
addQuicHint("e-hentai.org", 443, 443)
addQuicHint("forums.e-hentai.org", 443, 443)
addQuicHint("exhentai.org", 443, 443)
}.build()
class CronetRequest {
lateinit var consumer: (ByteBuffer) -> Unit
lateinit var onResponse: CronetRequest.(UrlResponseInfo) -> Unit
lateinit var request: UrlRequest
lateinit var onError: (Throwable) -> Unit
lateinit var readerCont: Continuation<Unit>
val callback = object : UrlRequest.Callback() {
override fun onRedirectReceived(req: UrlRequest, info: UrlResponseInfo, url: String) {
req.followRedirect()
}
override fun onResponseStarted(req: UrlRequest, info: UrlResponseInfo) {
onResponse(info)
}
override fun onReadCompleted(req: UrlRequest, info: UrlResponseInfo, data: ByteBuffer) {
consumer(data)
}
override fun onSucceeded(req: UrlRequest, info: UrlResponseInfo) {
readerCont.resume(Unit)
}
override fun onFailed(req: UrlRequest, info: UrlResponseInfo?, e: CronetException) {
onError(e)
}
}
}
inline fun cronetRequest(url: String, referer: String? = null, origin: String? = null, conf: UrlRequest.Builder.() -> Unit = {}) = CronetRequest().apply {
request = cronetHttpClient.newUrlRequestBuilder(url, callback, cronetHttpClientExecutor).apply {
addHeader("Cookie", EhCookieStore.getCookieHeader(url.toHttpUrl()))
addHeader("Accept", CHROME_ACCEPT)
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
referer?.let { addHeader("Referer", it) }
origin?.let { addHeader("Origin", it) }
}.apply(conf).build()
}
suspend inline fun <R> CronetRequest.execute(crossinline callback: suspend CronetRequest.(UrlResponseInfo) -> R): R {
contract {
callsInPlace(callback, InvocationKind.EXACTLY_ONCE)
}
return coroutineScope {
suspendCancellableCoroutine { cont ->
onResponse = { launch { cont.resume(callback(it)) } }
cont.invokeOnCancellation { request.cancel() }
onError = { cont.resumeWithException(it) }
request.start()
}
}
}
fun UrlRequest.Builder.withRequestBody(body: RequestBody) {
addHeader("Content-Type", body.contentType().toString())
val buffer = Buffer().apply { body.writeTo(this) }
val provider = object : UploadDataProvider() {
override fun getLength() = body.contentLength()
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
buffer.read(byteBuffer)
uploadDataSink.onReadSucceeded(false)
}
override fun rewind(uploadDataSink: UploadDataSink) {
error("OneShot!")
}
}
setUploadDataProvider(provider, cronetHttpClientExecutor)
}
fun UrlRequest.Builder.noCache(): UrlRequest.Builder = disableCache()
fun UrlResponseInfo.getHeadersMap(): Map<String, List<String>> = allHeaders
@@ -0,0 +1,176 @@
package com.hippo.ehviewer.ktor
import com.hippo.ehviewer.cronet.cronetDispatcher
import com.hippo.ehviewer.cronet.cronetHttpClient
import com.hippo.ehviewer.cronet.cronetHttpClientExecutor
import com.hippo.ehviewer.cronet.pool
import io.ktor.client.engine.HttpClientEngineBase
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.callContext
import io.ktor.client.request.HttpRequestData
import io.ktor.client.request.HttpResponseData
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpProtocolVersion
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.OutgoingContent
import io.ktor.http.headers
import io.ktor.util.date.GMTDate
import io.ktor.util.flattenForEach
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.InternalAPI
import io.ktor.utils.io.pool.useInstance
import io.ktor.utils.io.writer
import java.nio.ByteBuffer
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.chromium.net.CronetException
import org.chromium.net.UploadDataProvider
import org.chromium.net.UploadDataSink
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
object CronetEngine : HttpClientEngineBase("Cronet") {
override val config = HttpClientEngineConfig()
override val dispatcher = cronetDispatcher
@InternalAPI
override suspend fun execute(data: HttpRequestData): HttpResponseData {
val callContext = callContext()
return executeHttpRequest(callContext, data)
}
@OptIn(DelicateCoroutinesApi::class)
private suspend fun executeHttpRequest(
callContext: CoroutineContext,
data: HttpRequestData,
): HttpResponseData = suspendCancellableCoroutine { continuation ->
val requestTime = GMTDate()
val callback = object : UrlRequest.Callback() {
lateinit var chunkChan: Channel<ByteBuffer>
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) {
continuation.resume(
info.toHttpResponseData(
requestTime = requestTime,
callContext = callContext,
),
)
}
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
val channel = GlobalScope.writer(callContext) {
pool.useInstance {
chunkChan = Channel(1)
request.read(it)
chunkChan.consumeEach { buffer ->
buffer.flip()
channel.writeFully(buffer)
buffer.clear()
request.read(buffer)
}
}
}.channel
continuation.resume(
info.toHttpResponseData(
requestTime = requestTime,
callContext = callContext,
responseBody = channel,
),
)
}
override fun onReadCompleted(request: UrlRequest, info: UrlResponseInfo, byteBuffer: ByteBuffer) {
chunkChan.trySend(byteBuffer).getOrThrow()
}
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
chunkChan.close()
}
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
if (::chunkChan.isInitialized) {
chunkChan.close(error)
} else {
continuation.resumeWithException(error)
}
}
}
val request = cronetHttpClient.newUrlRequestBuilder(data.url.toString(), callback, cronetHttpClientExecutor).apply {
setHttpMethod(data.method.value)
data.headers.flattenForEach { key, value -> addHeader(key, value) }
data.body.contentType?.let { addHeader(HttpHeaders.ContentType, it.toString()) }
data.body.contentLength?.let { addHeader(HttpHeaders.ContentLength, it.toString()) }
data.body.toUploadDataProvider(callContext)?.let { setUploadDataProvider(it, cronetHttpClientExecutor) }
}.build()
request.start()
callContext[Job]!!.invokeOnCompletion { request.cancel() }
continuation.invokeOnCancellation { request.cancel() }
}
}
private fun UrlResponseInfo.toHttpResponseData(
requestTime: GMTDate,
callContext: CoroutineContext,
responseBody: ByteReadChannel = ByteReadChannel.Empty,
): HttpResponseData {
return HttpResponseData(
statusCode = HttpStatusCode.fromValue(httpStatusCode),
requestTime = requestTime,
headers = headers {
allHeaders.forEach { (key, value) ->
appendAll(key, value)
}
},
version = when (negotiatedProtocol) {
"h2" -> HttpProtocolVersion.HTTP_2_0
"h3" -> HttpProtocolVersion.QUIC
"quic/1+spdy/3" -> HttpProtocolVersion.SPDY_3
else -> HttpProtocolVersion.HTTP_1_1
},
body = responseBody,
callContext = callContext,
)
}
@OptIn(DelicateCoroutinesApi::class)
private fun OutgoingContent.toUploadDataProvider(callContext: CoroutineContext): UploadDataProvider? = when (this) {
is OutgoingContent.NoContent -> null
is OutgoingContent.ByteArrayContent -> object : UploadDataProvider() {
val buffer = ByteBuffer.wrap(bytes()).slice()
override fun getLength() = buffer.limit().toLong()
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
byteBuffer.put(buffer)
uploadDataSink.onReadSucceeded(false)
}
override fun rewind(uploadDataSink: UploadDataSink) {
buffer.position(0)
uploadDataSink.onRewindSucceeded()
}
}
is OutgoingContent.WriteChannelContent -> object : UploadDataProvider() {
fun launch() = GlobalScope.writer(callContext) { writeTo(channel) }.channel
var chan = launch()
override fun getLength() = contentLength!!
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
GlobalScope.launch(callContext) {
chan.readAvailable(byteBuffer)
uploadDataSink.onReadSucceeded(false)
}
}
override fun rewind(uploadDataSink: UploadDataSink) {
chan = launch()
uploadDataSink.onRewindSucceeded()
}
}
else -> error("UnsupportedContentType $this")
}
@@ -1,12 +1,11 @@
package com.hippo.ehviewer.util
import com.google.android.gms.net.CronetProviderInstaller
import com.hippo.ehviewer.Settings
import org.chromium.net.CronetProvider
import splitties.init.appCtx
val isCronetSupported: Boolean
get() = Settings.enableCronet && CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
val isCronetAvailable: Boolean
get() = CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
if (!it) {
CronetProviderInstaller.installProvider(appCtx)
}
-6
View File
@@ -37,11 +37,6 @@
tools:targetApi="tiramisu">
<profileable android:shell="true" />
<activity
android:name=".ui.ConfigureActivity"
android:exported="false"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.MainActivity"
android:banner="@mipmap/ic_leanback_banner"
@@ -139,7 +134,6 @@
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<activity android:name=".ui.WebViewActivity" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
+2 -2
View File
@@ -76,7 +76,7 @@ FetchContent_MakeAvailable(libarchive)
include_directories(${libarchive_SOURCE_DIR}/libarchive)
# Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c gifutils.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} archive_static log)
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} archive_static log jnigraphics)
add_custom_target("noop")
+241
View File
@@ -0,0 +1,241 @@
#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;
}
@@ -1,989 +0,0 @@
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
package androidx.compose.material3
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PixelFormat
import android.os.Build
import android.view.Gravity
import android.view.View
import android.view.ViewTreeObserver
import android.view.WindowManager
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.ViewRootForInspector
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.popup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
import com.hippo.ehviewer.util.isAtLeastT
import java.util.UUID
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
@Stable
@ExperimentalMaterial3Api
internal object AnchoredDraggableDefaults2 {
/**
* The default animation used by [AnchoredDraggableState].
*/
@get:ExperimentalMaterial3Api
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@ExperimentalMaterial3Api
val AnimationSpec = SpringSpec<Float>()
}
/**
* State of a sheet composable, such as [ModalBottomSheetFix]
*
* Contains states relating to its swipe position as well as animations between state values.
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
* interaction.
* @param initialValue The initial value of the state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
* programmatically or by user interaction.
*/
@Stable
@ExperimentalMaterial3Api
class SheetState2
@Deprecated(
message = "This constructor is deprecated. " +
"Please use the constructor that provides a [Density]",
replaceWith = ReplaceWith(
"SheetState(" +
"skipPartiallyExpanded, LocalDensity.current, initialValue, " +
"confirmValueChange, skipHiddenState)",
),
)
constructor(
internal val skipPartiallyExpanded: Boolean,
initialValue: SheetValue = SheetValue.Hidden,
val confirmValueChange: (SheetValue) -> Boolean = { true },
internal val skipHiddenState: Boolean = false,
) {
/**
* State of a sheet composable, such as [ModalBottomSheetFix]
*
* Contains states relating to its swipe position as well as animations between state values.
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
* interaction.
* @param initialValue The initial value of the state.
* @param density The density that this state can use to convert values to and from dp.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
* programmatically or by user interaction.
*/
@ExperimentalMaterial3Api
@Suppress("Deprecation")
constructor(
skipPartiallyExpanded: Boolean,
density: Density,
initialValue: SheetValue = SheetValue.Hidden,
confirmValueChange: (SheetValue) -> Boolean = { true },
skipHiddenState: Boolean = false,
) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) {
this.density = density
}
init {
if (skipPartiallyExpanded) {
require(initialValue != SheetValue.PartiallyExpanded) {
"The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
"is set to true."
}
}
if (skipHiddenState) {
require(initialValue != SheetValue.Hidden) {
"The initial value must not be set to Hidden if skipHiddenState is set to true."
}
}
}
/**
* The current value of the state.
*
* If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
* currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
* was in before the swipe or animation started.
*/
val currentValue: SheetValue get() = anchoredDraggableState.currentValue
/**
* The target value of the bottom sheet state.
*
* If a swipe is in progress, this is the value that the sheet would animate to if the
* swipe finishes. If an animation is running, this is the target value of that animation.
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
*/
val targetValue: SheetValue get() = anchoredDraggableState.targetValue
/**
* Whether the modal bottom sheet is visible.
*/
val isVisible: Boolean
get() = anchoredDraggableState.currentValue != SheetValue.Hidden
/**
* Require the current offset (in pixels) of the bottom sheet.
*
* The offset will be initialized during the first measurement phase of the provided sheet
* content.
*
* These are the phases:
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
*
* During the first composition, an [IllegalStateException] is thrown. In subsequent
* compositions, the offset will be derived from the anchors of the previous pass. Always prefer
* accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
* frame, after layout.
*
* @throws IllegalStateException If the offset has not been initialized yet
*/
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
/**
* Whether the sheet has an expanded state defined.
*/
val hasExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Expanded)
/**
* Whether the modal bottom sheet has a partially expanded state defined.
*/
val hasPartiallyExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.PartiallyExpanded)
/**
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
* animation has been cancelled.
* *
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun expand() {
anchoredDraggableState.animateTo(SheetValue.Expanded)
}
/**
* Animate the bottom sheet and suspend until it is partially expanded or animation has been
* cancelled.
* @throws [CancellationException] if the animation is interrupted
* @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
*/
suspend fun partialExpand() {
check(!skipPartiallyExpanded) {
"Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
" skipPartiallyExpanded to false to use this function."
}
animateTo(SheetValue.PartiallyExpanded)
}
/**
* Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
* else [Expanded].
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun show() {
val targetValue = when {
hasPartiallyExpandedState -> SheetValue.PartiallyExpanded
else -> SheetValue.Expanded
}
animateTo(targetValue)
}
/**
* Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
* been cancelled.
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun hide() {
check(!skipHiddenState) {
"Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
" to false to use this function."
}
animateTo(SheetValue.Hidden)
}
/**
* Animate to a [targetValue].
* If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
* [targetValue] without updating the offset.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
internal suspend fun animateTo(
targetValue: SheetValue,
velocity: Float = anchoredDraggableState.lastVelocity,
) {
anchoredDraggableState.animateTo(targetValue, velocity)
}
/**
* Snap to a [targetValue] without any animation.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
internal suspend fun snapTo(targetValue: SheetValue) {
anchoredDraggableState.snapTo(targetValue)
}
/**
* Find the closest anchor taking into account the velocity and settle at it with an animation.
*/
internal suspend fun settle(velocity: Float) {
anchoredDraggableState.settle(velocity)
}
internal var anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
animationSpec = AnchoredDraggableDefaults2.AnimationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } },
velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } },
)
internal val offset: Float get() = anchoredDraggableState.offset
internal var density: Density? = null
private fun requireDensity() = requireNotNull(density) {
"SheetState did not have a density attached. Are you using SheetState with " +
"BottomSheetScaffold or ModalBottomSheet component?"
}
companion object {
/**
* The default [Saver] implementation for [SheetState2].
*/
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean,
density: Density,
) = Saver<SheetState2, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
SheetState2(skipPartiallyExpanded, density, savedValue, confirmValueChange)
},
)
/**
* The default [Saver] implementation for [SheetState2].
*/
@Deprecated(
message = "This function is deprecated. Please use the overload where Density is" +
" provided.",
replaceWith = ReplaceWith(
"Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)",
),
)
@Suppress("Deprecation")
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean,
) = Saver<SheetState2, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
SheetState2(skipPartiallyExpanded, savedValue, confirmValueChange)
},
)
}
}
/**
* <a href="https://m3.material.io/components/bottom-sheets/overview" class="external" target="_blank">Material Design modal bottom sheet</a>.
*
* Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile,
* especially when offering a long list of action items, or when items require longer descriptions
* and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other
* app functionality when they appear, and remaining on screen until confirmed, dismissed, or a
* required action has been taken.
*
* ![Bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material3/bottom_sheet.png)
*
* A simple example of a modal bottom sheet looks like this:
*
* @sample androidx.compose.material3.samples.ModalBottomSheetSample
*
* @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet
* animates to [Hidden].
* @param modifier Optional [Modifier] for the bottom sheet.
* @param sheetState The state of the bottom sheet.
* @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take.
* Pass in [Dp.Unspecified] for a sheet that spans the entire screen width.
* @param shape The shape of the bottom sheet.
* @param containerColor The color used for the background of this bottom sheet
* @param contentColor The preferred color for content inside this bottom sheet. Defaults to either
* the matching content color for [containerColor], or to the current [LocalContentColor] if
* [containerColor] is not a color from the theme.
* @param tonalElevation The tonal elevation of this bottom sheet.
* @param scrimColor Color of the scrim that obscures content when the bottom sheet is open.
* @param dragHandle Optional visual marker to swipe the bottom sheet.
* @param windowInsets window insets to be passed to the bottom sheet window via [PaddingValues]
* params.
* @param properties [ModalBottomSheetProperties2] for further customization of this
* modal bottom sheet's behavior.
* @param content The content to be displayed inside the bottom sheet.
*/
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheetFix(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState2 = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
properties: ModalBottomSheetProperties2 = ModalBottomSheetDefaults2.properties(),
content: @Composable ColumnScope.() -> Unit,
) {
// b/291735717 Remove this once deprecated methods without density are removed
val density = LocalDensity.current
SideEffect {
sheetState.density = density
}
val scope = rememberCoroutineScope()
val animateToDismiss: () -> Unit = {
if (sheetState.confirmValueChange(SheetValue.Hidden)) {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) {
onDismissRequest()
}
}
}
}
LaunchedEffect(sheetState) {
snapshotFlow {
sheetState.targetValue == SheetValue.Hidden && !sheetState.isVisible
}.drop(1).collectLatest {
if (it) onDismissRequest()
}
}
val settleToDismiss: (velocity: Float) -> Unit = {
scope.launch { sheetState.settle(it) }.invokeOnCompletion {
if (!sheetState.isVisible) onDismissRequest()
}
}
val popupOnDismissRequest: () -> Unit = {
if (sheetState.currentValue == SheetValue.Expanded && sheetState.hasPartiallyExpandedState) {
scope.launch { sheetState.partialExpand() }
} else { // Is expanded without collapsed state or is collapsed.
scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() }
}
}
ModalBottomSheetPopup(
properties = properties,
onDismissRequest = popupOnDismissRequest,
windowInsets = windowInsets,
) {
val configuration = LocalConfiguration.current
val widthDp = configuration.screenWidthDp.dp
val height = configuration.screenHeightDp.dp
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(true) {
sheetState.hide()
onDismissRequest()
}
val predictiveModifier = Modifier.graphicsLayer {
val scale = lerp(1f, 0.90f, predictiveState.absoluteValue)
scaleX = scale
scaleY = scale
}.offset {
val ofsY = lerp(0f, height.toPx() * 0.0625f, predictiveState.absoluteValue).roundToInt()
val ofsX = lerp(0f, widthDp.toPx() * 0.025f, predictiveState).roundToInt()
IntOffset(ofsX, ofsY)
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val fullHeight = constraints.maxHeight
Scrim(
color = scrimColor,
onDismissRequest = animateToDismiss,
visible = sheetState.targetValue != SheetValue.Hidden,
)
Surface(
modifier = modifier
.widthIn(max = sheetMaxWidth)
.fillMaxWidth()
.align(Alignment.TopCenter)
.offset {
IntOffset(
0,
sheetState
.requireOffset()
.toInt(),
)
} then predictiveModifier
.nestedScroll(
remember(sheetState) {
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState = sheetState,
orientation = Orientation.Vertical,
onFling = settleToDismiss,
)
},
)
.anchoredDraggable(
state = sheetState.anchoredDraggableState,
orientation = Orientation.Vertical,
enabled = sheetState.isVisible,
startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
)
.modalBottomSheetAnchors(
sheetState = sheetState,
fullHeight = fullHeight.toFloat(),
),
shape = shape,
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
) {
Column(Modifier.fillMaxWidth()) {
if (dragHandle != null) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
dragHandle()
}
}
content()
}
}
}
}
if (sheetState.hasExpandedState) {
LaunchedEffect(sheetState) {
sheetState.show()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState: SheetState2,
orientation: Orientation,
onFling: (velocity: Float) -> Unit,
): NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
return if (source == NestedScrollSource.Drag) {
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat()
val currentOffset = sheetState.requireOffset()
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
return if (toFling < 0 && currentOffset > minAnchor) {
onFling(toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
onFling(available.toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(
x = if (orientation == Orientation.Horizontal) this else 0f,
y = if (orientation == Orientation.Vertical) this else 0f,
)
@JvmName("velocityToFloat")
private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
@JvmName("offsetToFloat")
private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
}
/**
* Properties used to customize the behavior of a [ModalBottomSheetFix].
*
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
* sheet's window.
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
* the modal bottom sheet will receive IME events and key presses, such as when
* the back button is pressed.
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
* the back button. If true, pressing the back button will call onDismissRequest.
* Note that [isFocusable] must be set to true in order to receive key events such as
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
*/
@ExperimentalMaterial3Api
class ModalBottomSheetProperties2(
val securePolicy: SecureFlagPolicy,
val isFocusable: Boolean,
val shouldDismissOnBackPress: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ModalBottomSheetProperties2) return false
if (securePolicy != other.securePolicy) return false
if (isFocusable != other.isFocusable) return false
if (shouldDismissOnBackPress != other.shouldDismissOnBackPress) return false
return true
}
override fun hashCode(): Int {
var result = securePolicy.hashCode()
result = 31 * result + isFocusable.hashCode()
result = 31 * result + shouldDismissOnBackPress.hashCode()
return result
}
}
/**
* Default values for [ModalBottomSheetFix]
*/
@Immutable
@ExperimentalMaterial3Api
object ModalBottomSheetDefaults2 {
/**
* Properties used to customize the behavior of a [ModalBottomSheetFix].
*
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
* sheet's window.
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
* the modal bottom sheet will receive IME events and key presses, such as when
* the back button is pressed.
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
* the back button. If true, pressing the back button will call onDismissRequest.
* Note that [isFocusable] must be set to true in order to receive key events such as
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
*/
fun properties(
securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
isFocusable: Boolean = true,
shouldDismissOnBackPress: Boolean = true,
) = ModalBottomSheetProperties2(securePolicy, isFocusable, shouldDismissOnBackPress)
}
/**
* Create and [remember] a [SheetState2] for [ModalBottomSheetFix].
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is tall enough,
* should be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
* [Hidden] state when hiding the sheet, either programmatically or by user interaction.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
@ExperimentalMaterial3Api
fun rememberModalBottomSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
) = rememberSheetState(skipPartiallyExpanded, confirmValueChange, SheetValue.Hidden)
@Composable
@ExperimentalMaterial3Api
internal fun rememberSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
initialValue: SheetValue = SheetValue.Hidden,
skipHiddenState: Boolean = false,
): SheetState2 {
val density = LocalDensity.current
return rememberSaveable(
skipPartiallyExpanded,
confirmValueChange,
saver = SheetState2.Saver(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmValueChange = confirmValueChange,
density = density,
),
) {
SheetState2(
skipPartiallyExpanded,
density,
initialValue,
confirmValueChange,
skipHiddenState,
)
}
}
@Composable
private fun Scrim(
color: Color,
onDismissRequest: () -> Unit,
visible: Boolean,
) {
if (color.isSpecified) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec(),
label = "scrim",
)
val dismissSheet = if (visible) {
Modifier
.pointerInput(onDismissRequest) {
detectTapGestures {
onDismissRequest()
}
}
.clearAndSetSemantics {}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissSheet),
) {
drawRect(color = color, alpha = alpha)
}
}
}
@ExperimentalMaterial3Api
private fun Modifier.modalBottomSheetAnchors(
sheetState: SheetState2,
fullHeight: Float,
) = onSizeChanged { sheetSize ->
val newAnchors = DraggableAnchors {
SheetValue.Hidden at fullHeight
if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) {
SheetValue.PartiallyExpanded at fullHeight / 2f
}
if (sheetSize.height != 0) {
SheetValue.Expanded at max(0f, fullHeight - sheetSize.height)
}
}
val newTarget = when (sheetState.anchoredDraggableState.targetValue) {
SheetValue.Hidden -> SheetValue.Hidden
SheetValue.PartiallyExpanded, SheetValue.Expanded -> {
val hasPartiallyExpandedState = newAnchors.hasAnchorFor(SheetValue.PartiallyExpanded)
val newTarget = if (hasPartiallyExpandedState) {
SheetValue.PartiallyExpanded
} else if (newAnchors.hasAnchorFor(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.Hidden
newTarget
}
}
sheetState.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
}
/**
* Popup specific for modal bottom sheet.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ModalBottomSheetPopup(
properties: ModalBottomSheetProperties2,
onDismissRequest: () -> Unit,
windowInsets: WindowInsets,
content: @Composable () -> Unit,
) {
val view = LocalView.current
val id = rememberSaveable { UUID.randomUUID() }
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
val layoutDirection = LocalLayoutDirection.current
val configuration = LocalConfiguration.current
val modalBottomSheetWindow = remember(configuration) {
ModalBottomSheetWindow2(
properties = properties,
onDismissRequest = onDismissRequest,
composeView = view,
saveId = id,
).apply {
setCustomContent(
parent = parentComposition,
content = {
Box(
Modifier
.semantics { this.popup() }
.windowInsetsPadding(windowInsets)
.then(
// TODO(b/290893168): Figure out a solution for APIs < 30.
if (Build.VERSION.SDK_INT >= 33) {
Modifier.imePadding()
} else {
Modifier
},
),
) {
currentContent()
}
},
)
}
}
DisposableEffect(modalBottomSheetWindow) {
modalBottomSheetWindow.show()
modalBottomSheetWindow.superSetLayoutDirection(layoutDirection)
onDispose {
modalBottomSheetWindow.disposeComposition()
modalBottomSheetWindow.dismiss()
}
}
}
/** Custom compose view for [ModalBottomSheetFix] */
@SuppressLint("ViewConstructor")
@OptIn(ExperimentalMaterial3Api::class)
private class ModalBottomSheetWindow2(
private val properties: ModalBottomSheetProperties2,
private var onDismissRequest: () -> Unit,
private val composeView: View,
saveId: UUID,
) : AbstractComposeView(composeView.context),
ViewTreeObserver.OnGlobalLayoutListener,
ViewRootForInspector,
OnBackPressedDispatcherOwner {
init {
id = android.R.id.content
// Set up view owners
setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId")
// Enable children to draw their shadow by not clipping them
clipChildren = false
}
override val lifecycle = composeView.findViewTreeLifecycleOwner()!!.lifecycle
override val onBackPressedDispatcher = OnBackPressedDispatcher {
onDismissRequest()
}
private val windowManager =
composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val displayWidth: Int
get() = context.resources.displayMetrics.widthPixels
private val params: WindowManager.LayoutParams =
WindowManager.LayoutParams().apply {
// Position bottom sheet from the bottom of the screen
gravity = Gravity.BOTTOM or Gravity.START
// Application panel window
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
// Fill up the entire app view
width = displayWidth
height = WindowManager.LayoutParams.MATCH_PARENT
// Format of screen pixels
format = PixelFormat.TRANSLUCENT
// Title used as fallback for a11y services
// TODO: Provide bottom sheet window resource
title = composeView.context.resources.getString(
androidx.compose.ui.R.string.default_popup_window_title,
)
// Get the Window token from the parent view
token = composeView.applicationWindowToken
// Flags specific to modal bottom sheet.
flags = flags and (
WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
).inv()
flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
// Security flag
val secureFlagEnabled =
properties.securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled())
if (secureFlagEnabled) {
flags = flags or WindowManager.LayoutParams.FLAG_SECURE
} else {
flags = flags and (WindowManager.LayoutParams.FLAG_SECURE.inv())
}
// Focusable
if (!properties.isFocusable) {
flags = flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
} else {
flags = flags and (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv())
}
}
private var content: @Composable () -> Unit by mutableStateOf({})
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
private set
@Composable
override fun Content() {
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides this) {
content()
}
}
fun setCustomContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit,
) {
parent?.let { setParentCompositionContext(it) }
this.content = content
shouldCreateCompositionOnAttachedToWindow = true
}
fun show() {
windowManager.addView(this, params)
if (isAtLeastT) {
onBackPressedDispatcher.setOnBackInvokedDispatcher(findOnBackInvokedDispatcher()!!)
}
}
fun dismiss() {
setViewTreeLifecycleOwner(null)
setViewTreeSavedStateRegistryOwner(null)
composeView.viewTreeObserver.removeOnGlobalLayoutListener(this)
windowManager.removeViewImmediate(this)
}
override fun onGlobalLayout() {
// No-op
}
override fun setLayoutDirection(layoutDirection: Int) {
// Do nothing. ViewRootImpl will call this method attempting to set the layout direction
// from the context's locale, but we have one already from the parent composition.
}
// Sets the "real" layout direction for our content that we obtain from the parent composition.
fun superSetLayoutDirection(layoutDirection: LayoutDirection) {
val direction = when (layoutDirection) {
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
}
super.setLayoutDirection(direction)
}
}
// Taken from AndroidPopup.android.kt
private fun View.isFlagSecureEnabled(): Boolean {
val windowParams = rootView.layoutParams as? WindowManager.LayoutParams
if (windowParams != null) {
return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
}
return false
}
// Taken from AndroidPopup.android.kt
private fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean {
return when (this) {
SecureFlagPolicy.SecureOff -> false
SecureFlagPolicy.SecureOn -> true
SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent
}
}
@@ -5,6 +5,7 @@ package androidx.compose.material3
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableState
@@ -23,13 +24,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlurEffect
@@ -50,7 +54,6 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.util.lerp
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
import kotlin.math.roundToInt
@@ -71,7 +74,8 @@ class DrawerState2(
val anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
animationSpec = AnimationSpec,
snapAnimationSpec = AnimationSpec,
decayAnimationSpec = exponentialDecay(7.5f),
confirmValueChange = confirmStateChange,
positionalThreshold = { distance -> distance * DrawerPositionalThreshold },
velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } },
@@ -252,7 +256,7 @@ fun ModalNavigationDrawer(
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var minValue by remember { mutableStateOf(-with(density) { ContainerWidth.toPx() }) }
var minValue by remember { mutableFloatStateOf(-with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
SideEffect {
@@ -302,9 +306,13 @@ fun ModalNavigationDrawer(
Modifier
}
Box(modifier.fillMaxSize().then(dragModifier)) {
val radius by remember {
snapshotFlow {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
lerp(0, 10, step).dp
}
}.collectAsState(0.dp)
val blurModifier = Modifier.graphicsLayer {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
val radius = lerp(0.dp, 10.dp, step)
if (radius != 0.dp) {
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
shape = RectangleShape
@@ -385,7 +393,7 @@ fun SideDrawer(
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var minValue by remember { mutableStateOf(with(density) { ContainerWidth.toPx() }) }
var minValue by remember { mutableFloatStateOf(with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
SideEffect {
@@ -436,9 +444,13 @@ fun SideDrawer(
Modifier
}
Box(modifier.fillMaxSize().then(dragModifier)) {
val radius by remember {
snapshotFlow {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
lerp(0, 10, step).dp
}
}.collectAsState(0.dp)
val blurModifier = Modifier.graphicsLayer {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
val radius = lerp(0.dp, 10.dp, step)
if (radius != 0.dp) {
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
shape = RectangleShape
@@ -0,0 +1,132 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.material3
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
/**
* <a href="https://m3.material.io/components/snackbar/overview" class="external" target="_blank">Material Design snackbar</a>.
*
* Snackbars provide brief messages about app processes at the bottom of the screen.
*
* ![Snackbar image](https://developer.android.com/images/reference/androidx/compose/material3/snackbar.png)
*
* Snackbars inform users of a process that an app has performed or will perform. They appear
* temporarily, towards the bottom of the screen. They shouldn’t interrupt the user experience,
* and they don’t require user input to disappear.
*
* A Snackbar can contain a single action. "Dismiss" or "cancel" actions are optional.
*
* Snackbars with an action should not timeout or self-dismiss until the user performs another
* action. Here, moving the keyboard focus indicator to navigate through interactive elements in a
* page is not considered an action.
*
* This version of snackbar is designed to work with [SnackbarData] provided by the
* [SnackbarHost], which is usually used inside of the [Scaffold].
*
* This components provides only the visuals of the Snackbar. If you need to show a Snackbar
* with defaults on the screen, use [SnackbarHostState.showSnackbar]:
*
* @sample androidx.compose.material3.samples.ScaffoldWithSimpleSnackbar
*
* If you want to customize appearance of the Snackbar, you can pass your own version as a child
* of the [SnackbarHost] to the [Scaffold]:
*
* @sample androidx.compose.material3.samples.ScaffoldWithCustomSnackbar
*
* When a [SnackbarData.visuals] sets the Snackbar's duration as [SnackbarDuration.Indefinite], it's
* recommended to display an additional close affordance action.
* See [SnackbarVisuals.withDismissAction]:
*
* @sample androidx.compose.material3.samples.ScaffoldWithIndefiniteSnackbar
*
* @param snackbarData data about the current snackbar showing via [SnackbarHostState]
* @param modifier the [Modifier] to be applied to this snackbar
* @param actionOnNewLine whether or not action should be put on a separate line. Recommended for
* action with long action text.
* @param shape defines the shape of this snackbar's container
* @param containerColor the color used for the background of this snackbar. Use [Color.Transparent]
* to have no color.
* @param contentColor the preferred color for content inside this snackbar
* @param actionColor the color of the snackbar's action
* @param actionContentColor the preferred content color for the optional action inside this
* snackbar. See [SnackbarVisuals.actionLabel].
* @param dismissActionContentColor the preferred content color for the optional dismiss action
* inside this snackbar. See [SnackbarVisuals.withDismissAction].
*/
@Composable
fun Snackbar2(
snackbarData: SnackbarData,
modifier: Modifier = Modifier,
actionOnNewLine: Boolean = false,
shape: Shape = SnackbarDefaults.shape,
containerColor: Color = SnackbarDefaults.color,
contentColor: Color = SnackbarDefaults.contentColor,
actionColor: Color = SnackbarDefaults.actionColor,
actionContentColor: Color = SnackbarDefaults.actionContentColor,
dismissActionContentColor: Color = SnackbarDefaults.dismissActionContentColor,
) {
val actionLabel = snackbarData.visuals.actionLabel
val actionComposable: (@Composable () -> Unit)? = if (actionLabel != null) {
@Composable {
TextButton(
colors = ButtonDefaults.textButtonColors(contentColor = actionColor),
onClick = { snackbarData.performAction() },
content = { Text(actionLabel) },
)
}
} else {
null
}
val dismissActionComposable: (@Composable () -> Unit)? =
if (snackbarData.visuals.withDismissAction) {
@Composable {
IconButton(
onClick = { snackbarData.dismiss() },
content = {
Icon(
Icons.Filled.Close,
contentDescription = null,
// Workaround for regression of https://android-review.googlesource.com/c/platform/frameworks/support/+/2881006
tint = dismissActionContentColor,
)
},
)
}
} else {
null
}
Snackbar(
modifier = modifier.padding(12.dp),
action = actionComposable,
dismissAction = dismissActionComposable,
actionOnNewLine = actionOnNewLine,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
actionContentColor = actionContentColor,
dismissActionContentColor = dismissActionContentColor,
content = { Text(snackbarData.visuals.message) },
)
}
@@ -17,19 +17,18 @@
package androidx.navigation.compose
import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.ExperimentalTransitionApi
import androidx.compose.animation.core.SeekableTransitionState
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
@@ -39,8 +38,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -54,9 +55,6 @@ import androidx.navigation.NavHostController
import androidx.navigation.Navigator
import androidx.navigation.createGraph
import androidx.navigation.get
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import kotlin.math.roundToLong
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
@@ -189,6 +187,7 @@ public fun NavHost(
* @param popEnterTransition callback to define popEnter transitions for destination in this host
* @param popExitTransition callback to define popExit transitions for destination in this host
*/
@OptIn(ExperimentalTransitionApi::class)
@SuppressLint("StateFlowValueCalledInComposition")
@Composable
public fun NavHost(
@@ -247,10 +246,35 @@ public fun NavHost(
val zIndices = remember { mutableMapOf<String, Float>() }
if (backStackEntry != null) {
val transitionState = remember { SeekableTransitionState(backStackEntry) }
val transition = rememberTransition(transitionState, label = "entry")
LaunchedEffect(backStackEntry) {
transitionState.animateTo(backStackEntry)
}
var inPredictiveBack by remember { mutableStateOf(false) }
PredictiveBackHandler(currentBackStack.size > 1) { backEvent ->
inPredictiveBack = true
try {
val prev = requireNotNull(navController.previousBackStackEntry)
backEvent.collect {
val interpolated = EaseOut.transform(it.progress)
transitionState.snapTo(prev, interpolated)
}
inPredictiveBack = false
navController.popBackStack()
} catch (e: CancellationException) {
withContext(NonCancellable) {
transitionState.animateTo(backStackEntry, snap())
inPredictiveBack = false
}
}
}
val finalEnter: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
val targetDestination = targetState.destination as ComposeNavigator.Destination
if (composeNavigator.isPop.value) {
if (composeNavigator.isPop.value || inPredictiveBack) {
targetDestination.hierarchy.firstNotNullOfOrNull { destination ->
destination.createPopEnterTransition(this)
} ?: popEnterTransition.invoke(this)
@@ -264,7 +288,7 @@ public fun NavHost(
val finalExit: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
val initialDestination = initialState.destination as ComposeNavigator.Destination
if (composeNavigator.isPop.value) {
if (composeNavigator.isPop.value || inPredictiveBack) {
initialDestination.hierarchy.firstNotNullOfOrNull { destination ->
destination.createPopExitTransition(this)
} ?: popExitTransition.invoke(this)
@@ -283,65 +307,6 @@ public fun NavHost(
}
}
val enablePredictiveBack by Settings.predictiveNavAnim.collectAsState()
val transition = if (enablePredictiveBack) {
val duration = 500000000L
val transitionState = remember { MutableTransitionState(backStackEntry) }
val transition = updateTransition(transitionState = transitionState, label = "entry")
val animatedFraction = remember { Animatable(0f).also { it.updateBounds(lowerBound = 0f, upperBound = 1f) } }
fun seekToFraction(lState: NavBackStackEntry, rState: NavBackStackEntry) {
val fraction = animatedFraction.value
val playTimeNanos = (fraction * duration).roundToLong()
transition.setPlaytimeAfterInitialAndTargetStateEstablished(lState, rState, playTimeNanos)
}
LaunchedEffect(backStackEntry) {
try {
animatedFraction.animateTo(1f, animationSpec = tween(500, easing = LinearEasing)) {
seekToFraction(transitionState.currentState, backStackEntry)
}
} finally {
withContext(NonCancellable) {
animatedFraction.snapTo(0f)
transition.setPlaytimeAfterInitialAndTargetStateEstablished(
backStackEntry,
backStackEntry,
0,
)
}
}
}
PredictiveBackHandler(currentBackStack.size > 1) { flow ->
// Prev can never be null, i.e. BackHandler should not enabled when prev is null
val prev = currentBackStack.last { it != backStackEntry }
try {
flow.collect {
val interpolated = EaseOut.transform(it.progress)
animatedFraction.snapTo(interpolated)
seekToFraction(backStackEntry, prev)
}
navController.popBackStack()
} catch (e: CancellationException) {
withContext(NonCancellable) {
animatedFraction.snapTo(0f)
transition.setPlaytimeAfterInitialAndTargetStateEstablished(
backStackEntry,
backStackEntry,
0,
)
}
}
}
transition
} else {
BackHandler(currentBackStack.size > 1) {
navController.popBackStack()
}
updateTransition(backStackEntry, label = "entry")
}
transition.AnimatedContent(
modifier,
transitionSpec = {
@@ -364,12 +329,12 @@ public fun NavHost(
},
contentAlignment,
contentKey = { it.id },
) {
) { currentEntry ->
// while in the scope of the composable, we provide the navBackStackEntry as the
// ViewModelStoreOwner and LifecycleOwner
it.LocalOwnersProvider(saveableStateHolder) {
(it.destination as ComposeNavigator.Destination)
.content(this, it)
currentEntry.LocalOwnersProvider(saveableStateHolder) {
(currentEntry.destination as ComposeNavigator.Destination)
.content(this, currentEntry)
}
}
LaunchedEffect(transition.currentState, transition.targetState) {
@@ -9,6 +9,7 @@ import android.graphics.Paint
import android.graphics.PointF
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.BitmapDrawable
import android.util.AttributeSet
import android.util.TypedValue
import android.view.GestureDetector
@@ -145,7 +146,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
// Min scale allowed (prevent infinite zoom)
private var minScale = minScale()
private val sRegion = Rect(0, 0, 0, 0)
private var srcRect = Rect(0, 0, 0, 0)
// Is two-finger zooming in progress
private var isZooming = false
@@ -230,9 +231,10 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
*
* @param bitmap Image source.
*/
fun setImage(bitmap: Bitmap) {
fun setImage(bitmap: BitmapDrawable) {
reset(true)
onImageLoaded(bitmap)
srcRect = Rect(bitmap.bounds)
onImageLoaded(bitmap.bitmap)
}
/**
@@ -267,7 +269,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
}
sWidth = 0
sHeight = 0
sRegion.setEmpty()
srcRect.setEmpty()
isReady = false
isImageLoaded = false
bitmap = null
@@ -760,6 +762,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
val xScale = scale
val yScale = scale
mtrx.reset()
mtrx.postTranslate((-srcRect.left).toFloat(), (-srcRect.top).toFloat())
mtrx.postScale(xScale, yScale)
mtrx.postTranslate(vTranslate!!.x, vTranslate!!.y)
canvas.drawBitmap(bitmap!!, mtrx, bitmapPaint)
@@ -908,7 +911,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
@Synchronized
private fun onImageLoaded(bitmap: Bitmap) {
// If actual dimensions don't match the declared size, reset everything.
if (sWidth > 0 && sHeight > 0 && (sWidth != bitmap.width || sHeight != bitmap.height)) {
if (sWidth > 0 && sHeight > 0 && (sWidth != srcRect.width() || sHeight != srcRect.height())) {
reset(false)
}
if (!this.bitmapIsCached) {
@@ -916,8 +919,8 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
}
this.bitmapIsCached = true
this.bitmap = bitmap
sWidth = bitmap.width
sHeight = bitmap.height
sWidth = srcRect.width()
sHeight = srcRect.height()
val ready = checkReady()
val imageLoaded = checkImageLoaded()
if (ready || imageLoaded) {
@@ -0,0 +1,653 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.accompanist.web
import android.content.Context
import android.graphics.Bitmap
import android.os.Bundle
import android.view.ViewGroup.LayoutParams
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.google.accompanist.web.LoadingState.Finished
import com.google.accompanist.web.LoadingState.Loading
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* A wrapper around the Android View WebView to provide a basic WebView composable.
*
* If you require more customisation you are most likely better rolling your own and using this
* wrapper as an example.
*
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it
* is incorrectly sizing, use the layoutParams composable function instead.
*
* @param state The webview state holder where the Uri to load is defined.
* @param modifier A compose modifier
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
* the WebView back.
* @param navigator An optional navigator object that can be used to control the WebView's
* navigation from outside the composable.
* @param onCreated Called when the WebView is first created, this can be used to set additional
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be
* subsequently overwritten after this lambda is called.
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved
* if you need to save and restore state in this WebView.
* @param client Provides access to WebViewClient via subclassing
* @param chromeClient Provides access to WebChromeClient via subclassing
* @param factory An optional WebView factory for using a custom subclass of WebView
*/
@Composable
fun WebView(
state: WebViewState,
modifier: Modifier = Modifier,
captureBackPresses: Boolean = true,
navigator: WebViewNavigator = rememberWebViewNavigator(),
onCreated: (WebView) -> Unit = {},
onDispose: (WebView) -> Unit = {},
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
factory: ((Context) -> WebView)? = null,
) {
BoxWithConstraints(modifier) {
// WebView changes it's layout strategy based on
// it's layoutParams. We convert from Compose Modifier to
// layout params here.
val width =
if (constraints.hasFixedWidth) {
LayoutParams.MATCH_PARENT
} else {
LayoutParams.WRAP_CONTENT
}
val height =
if (constraints.hasFixedHeight) {
LayoutParams.MATCH_PARENT
} else {
LayoutParams.WRAP_CONTENT
}
val layoutParams = FrameLayout.LayoutParams(
width,
height,
)
WebView(
state,
layoutParams,
Modifier,
captureBackPresses,
navigator,
onCreated,
onDispose,
client,
chromeClient,
factory,
)
}
}
/**
* A wrapper around the Android View WebView to provide a basic WebView composable.
*
* If you require more customisation you are most likely better rolling your own and using this
* wrapper as an example.
*
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it
* is incorrectly sizing, use the layoutParams composable function instead.
*
* @param state The webview state holder where the Uri to load is defined.
* @param layoutParams A FrameLayout.LayoutParams object to custom size the underlying WebView.
* @param modifier A compose modifier
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
* the WebView back.
* @param navigator An optional navigator object that can be used to control the WebView's
* navigation from outside the composable.
* @param onCreated Called when the WebView is first created, this can be used to set additional
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be
* subsequently overwritten after this lambda is called.
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved
* if you need to save and restore state in this WebView.
* @param client Provides access to WebViewClient via subclassing
* @param chromeClient Provides access to WebChromeClient via subclassing
* @param factory An optional WebView factory for using a custom subclass of WebView
*/
@Composable
fun WebView(
state: WebViewState,
layoutParams: FrameLayout.LayoutParams,
modifier: Modifier = Modifier,
captureBackPresses: Boolean = true,
navigator: WebViewNavigator = rememberWebViewNavigator(),
onCreated: (WebView) -> Unit = {},
onDispose: (WebView) -> Unit = {},
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
factory: ((Context) -> WebView)? = null,
) {
val webView = state.webView
BackHandler(captureBackPresses && navigator.canGoBack) {
webView?.goBack()
}
webView?.let { wv ->
LaunchedEffect(wv, navigator) {
with(navigator) {
wv.handleNavigationEvents()
}
}
LaunchedEffect(wv, state) {
snapshotFlow { state.content }.collect { content ->
when (content) {
is WebContent.Url -> {
wv.loadUrl(content.url, content.additionalHttpHeaders)
}
is WebContent.Data -> {
wv.loadDataWithBaseURL(
content.baseUrl,
content.data,
content.mimeType,
content.encoding,
content.historyUrl,
)
}
is WebContent.Post -> {
wv.postUrl(
content.url,
content.postData,
)
}
is WebContent.NavigatorOnly -> {
// NO-OP
}
}
}
}
}
// Set the state of the client and chrome client
// This is done internally to ensure they always are the same instance as the
// parent Web composable
client.state = state
client.navigator = navigator
chromeClient.state = state
AndroidView(
factory = { context ->
(factory?.invoke(context) ?: WebView(context)).apply {
onCreated(this)
this.layoutParams = layoutParams
state.viewState?.let {
this.restoreState(it)
}
webChromeClient = chromeClient
webViewClient = client
}.also { state.webView = it }
},
modifier = modifier,
onRelease = {
onDispose(it)
},
)
}
/**
* AccompanistWebViewClient
*
* A parent class implementation of WebViewClient that can be subclassed to add custom behaviour.
*
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
* class that can be overriden if further custom behaviour is required.
*/
open class AccompanistWebViewClient : WebViewClient() {
open lateinit var state: WebViewState
internal set
open lateinit var navigator: WebViewNavigator
internal set
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
state.loadingState = Loading(0.0f)
state.errorsForCurrentRequest.clear()
state.pageTitle = null
state.pageIcon = null
state.lastLoadedUrl = url
}
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
state.loadingState = Finished
}
override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) {
super.doUpdateVisitedHistory(view, url, isReload)
navigator.canGoBack = view.canGoBack()
navigator.canGoForward = view.canGoForward()
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest?,
error: WebResourceError?,
) {
super.onReceivedError(view, request, error)
if (error != null) {
state.errorsForCurrentRequest.add(WebViewError(request, error))
}
}
}
/**
* AccompanistWebChromeClient
*
* A parent class implementation of WebChromeClient that can be subclassed to add custom behaviour.
*
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
* class that can be overriden if further custom behaviour is required.
*/
open class AccompanistWebChromeClient : WebChromeClient() {
open lateinit var state: WebViewState
internal set
override fun onReceivedTitle(view: WebView, title: String?) {
super.onReceivedTitle(view, title)
state.pageTitle = title
}
override fun onReceivedIcon(view: WebView, icon: Bitmap?) {
super.onReceivedIcon(view, icon)
state.pageIcon = icon
}
override fun onProgressChanged(view: WebView, newProgress: Int) {
super.onProgressChanged(view, newProgress)
if (state.loadingState is Finished) return
state.loadingState = Loading(newProgress / 100.0f)
}
}
sealed class WebContent {
data class Url(
val url: String,
val additionalHttpHeaders: Map<String, String> = emptyMap(),
) : WebContent()
data class Data(
val data: String,
val baseUrl: String? = null,
val encoding: String = "utf-8",
val mimeType: String? = null,
val historyUrl: String? = null,
) : WebContent()
data class Post(
val url: String,
val postData: ByteArray,
) : WebContent() {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Post
if (url != other.url) return false
if (!postData.contentEquals(other.postData)) return false
return true
}
override fun hashCode(): Int {
var result = url.hashCode()
result = 31 * result + postData.contentHashCode()
return result
}
}
data object NavigatorOnly : WebContent()
}
/**
* Sealed class for constraining possible loading states.
* See [Loading] and [Finished].
*/
sealed class LoadingState {
/**
* Describes a WebView that has not yet loaded for the first time.
*/
data object Initializing : LoadingState()
/**
* Describes a webview between `onPageStarted` and `onPageFinished` events, contains a
* [progress] property which is updated by the webview.
*/
data class Loading(val progress: Float) : LoadingState()
/**
* Describes a webview that has finished loading content.
*/
data object Finished : LoadingState()
}
/**
* A state holder to hold the state for the WebView. In most cases this will be remembered
* using the rememberWebViewState(uri) function.
*/
@Stable
class WebViewState(webContent: WebContent) {
var lastLoadedUrl: String? by mutableStateOf(null)
internal set
/**
* The content being loaded by the WebView
*/
var content: WebContent by mutableStateOf(webContent)
/**
* Whether the WebView is currently [LoadingState.Loading] data in its main frame (along with
* progress) or the data loading has [LoadingState.Finished]. See [LoadingState]
*/
var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing)
internal set
/**
* Whether the webview is currently loading data in its main frame
*/
val isLoading: Boolean
get() = loadingState !is Finished
/**
* The title received from the loaded content of the current page
*/
var pageTitle: String? by mutableStateOf(null)
internal set
/**
* the favicon received from the loaded content of the current page
*/
var pageIcon: Bitmap? by mutableStateOf(null)
internal set
/**
* A list for errors captured in the last load. Reset when a new page is loaded.
* Errors could be from any resource (iframe, image, etc.), not just for the main page.
* For more fine grained control use the OnError callback of the WebView.
*/
val errorsForCurrentRequest: SnapshotStateList<WebViewError> = mutableStateListOf()
/**
* The saved view state from when the view was destroyed last. To restore state,
* use the navigator and only call loadUrl if the bundle is null.
* See WebViewSaveStateSample.
*/
var viewState: Bundle? = null
internal set
// We need access to this in the state saver. An internal DisposableEffect or AndroidView
// onDestroy is called after the state saver and so can't be used.
internal var webView by mutableStateOf<WebView?>(null)
}
/**
* Allows control over the navigation of a WebView from outside the composable. E.g. for performing
* a back navigation in response to the user clicking the "up" button in a TopAppBar.
*
* @see [rememberWebViewNavigator]
*/
@Stable
class WebViewNavigator(private val coroutineScope: CoroutineScope) {
private sealed interface NavigationEvent {
data object Back : NavigationEvent
data object Forward : NavigationEvent
data object Reload : NavigationEvent
data object StopLoading : NavigationEvent
data class LoadUrl(
val url: String,
val additionalHttpHeaders: Map<String, String> = emptyMap(),
) : NavigationEvent
data class LoadHtml(
val html: String,
val baseUrl: String? = null,
val mimeType: String? = null,
val encoding: String? = "utf-8",
val historyUrl: String? = null,
) : NavigationEvent
data class PostUrl(
val url: String,
val postData: ByteArray,
) : NavigationEvent {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PostUrl
if (url != other.url) return false
if (!postData.contentEquals(other.postData)) return false
return true
}
override fun hashCode(): Int {
var result = url.hashCode()
result = 31 * result + postData.contentHashCode()
return result
}
}
}
private val navigationEvents: MutableSharedFlow<NavigationEvent> = MutableSharedFlow(replay = 1)
// Use Dispatchers.Main to ensure that the webview methods are called on UI thread
internal suspend fun WebView.handleNavigationEvents(): Nothing = withContext(Dispatchers.Main) {
navigationEvents.collect { event ->
when (event) {
is NavigationEvent.Back -> goBack()
is NavigationEvent.Forward -> goForward()
is NavigationEvent.Reload -> reload()
is NavigationEvent.StopLoading -> stopLoading()
is NavigationEvent.LoadHtml -> loadDataWithBaseURL(
event.baseUrl,
event.html,
event.mimeType,
event.encoding,
event.historyUrl,
)
is NavigationEvent.LoadUrl -> {
loadUrl(event.url, event.additionalHttpHeaders)
}
is NavigationEvent.PostUrl -> {
postUrl(event.url, event.postData)
}
}
}
}
/**
* True when the web view is able to navigate backwards, false otherwise.
*/
var canGoBack: Boolean by mutableStateOf(false)
internal set
/**
* True when the web view is able to navigate forwards, false otherwise.
*/
var canGoForward: Boolean by mutableStateOf(false)
internal set
fun loadUrl(url: String, additionalHttpHeaders: Map<String, String> = emptyMap()) {
coroutineScope.launch {
navigationEvents.emit(
NavigationEvent.LoadUrl(
url,
additionalHttpHeaders,
),
)
}
}
fun loadHtml(
html: String,
baseUrl: String? = null,
mimeType: String? = null,
encoding: String? = "utf-8",
historyUrl: String? = null,
) {
coroutineScope.launch {
navigationEvents.emit(
NavigationEvent.LoadHtml(
html,
baseUrl,
mimeType,
encoding,
historyUrl,
),
)
}
}
fun postUrl(
url: String,
postData: ByteArray,
) {
coroutineScope.launch {
navigationEvents.emit(
NavigationEvent.PostUrl(
url,
postData,
),
)
}
}
/**
* Navigates the webview back to the previous page.
*/
fun navigateBack() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) }
}
/**
* Navigates the webview forward after going back from a page.
*/
fun navigateForward() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) }
}
/**
* Reloads the current page in the webview.
*/
fun reload() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) }
}
/**
* Stops the current page load (if one is loading).
*/
fun stopLoading() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.StopLoading) }
}
}
/**
* Creates and remembers a [WebViewNavigator] using the default [CoroutineScope] or a provided
* override.
*/
@Composable
fun rememberWebViewNavigator(
coroutineScope: CoroutineScope = rememberCoroutineScope(),
): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope) }
/**
* A wrapper class to hold errors from the WebView.
*/
@Immutable
data class WebViewError(
/**
* The request the error came from.
*/
val request: WebResourceRequest?,
/**
* The error that was reported.
*/
val error: WebResourceError,
)
/**
* Creates a WebView state that is remembered across Compositions.
*
* @param url The url to load in the WebView
* @param additionalHttpHeaders Optional, additional HTTP headers that are passed to [WebView.loadUrl].
* Note that these headers are used for all subsequent requests of the WebView.
*/
@Composable
fun rememberWebViewState(
url: String,
additionalHttpHeaders: Map<String, String> = emptyMap(),
): WebViewState =
// Rather than using .apply {} here we will recreate the state, this prevents
// a recomposition loop when the webview updates the url itself.
remember {
WebViewState(
WebContent.Url(
url = url,
additionalHttpHeaders = additionalHttpHeaders,
),
)
}.apply {
this.content = WebContent.Url(
url = url,
additionalHttpHeaders = additionalHttpHeaders,
)
}
@@ -18,29 +18,33 @@ package com.hippo.ehviewer
import android.app.Application
import android.content.ComponentCallbacks2
import android.content.Context
import android.os.StrictMode
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.content.res.AppCompatResources
import androidx.collection.LruCache
import androidx.compose.runtime.snapshots.Snapshot
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil.ImageLoaderFactory
import coil.decode.ImageDecoderDecoder
import coil.util.DebugLogger
import coil3.SingletonImageLoader
import coil3.asCoilImage
import coil3.decode.ImageDecoderDecoder
import coil3.fetch.NetworkFetcher
import coil3.request.crossfade
import coil3.util.DebugLogger
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
import com.hippo.ehviewer.coil.MergeInterceptor
import com.hippo.ehviewer.coil.installCronetHttpUriFetcher
import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.ktbuilder.cache
import com.hippo.ehviewer.ktbuilder.chunker
import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.ktbuilder.httpClient
import com.hippo.ehviewer.ktbuilder.imageLoader
import com.hippo.ehviewer.ktor.CronetEngine
import com.hippo.ehviewer.legacy.cleanObsoleteCache
import com.hippo.ehviewer.legacy.migrateCookies
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.lockObserver
import com.hippo.ehviewer.util.AppConfig
@@ -51,14 +55,20 @@ import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastS
import com.hippo.ehviewer.util.isCronetSupported
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
import com.hippo.ehviewer.util.isCronetAvailable
import com.hippo.ehviewer.util.resettableLazy
import com.hippo.ehviewer.util.unsafeLazy
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.cookies.HttpCookies
import java.io.File
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import moe.tarsin.kt.unreachable
import moe.tarsin.coroutines.runSuspendCatching
import okhttp3.AsyncDns
import okhttp3.android.AndroidAsyncDns
import okio.Path.Companion.toOkioPath
@@ -68,7 +78,7 @@ import splitties.init.appCtx
private val lifecycle = ProcessLifecycleOwner.get().lifecycle
private val lifecycleScope = lifecycle.coroutineScope
class EhApplication : Application(), ImageLoaderFactory {
class EhApplication : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
// Initialize Settings on first access
lifecycleScope.launchIO {
@@ -97,9 +107,6 @@ class EhApplication : Application(), ImageLoaderFactory {
System.loadLibrary("ehviewer_rust")
ReadableTime.initialize(this)
lifecycleScope.launchIO {
launchIO {
migrateCookies()
}
launchIO {
EhTagDatabase
}
@@ -109,6 +116,22 @@ class EhApplication : Application(), ImageLoaderFactory {
launchIO {
DownloadManager.isIdle
}
launchIO {
runSuspendCatching {
val files = mutableListOf<File>()
AppConfig.externalCrashDir?.listFiles()?.let { files.addAll(it) }
AppConfig.externalParseErrorDir?.listFiles()?.let { files.addAll(it) }
files.sortByDescending { it.lastModified() }
files.forEachIndexed { index, file ->
ensureActive()
if (index > 9) {
file.delete()
}
}
}.onFailure {
it.printStackTrace()
}
}
launchIO {
cleanupDownload()
}
@@ -120,8 +143,9 @@ class EhApplication : Application(), ImageLoaderFactory {
}
cleanObsoleteCache(this)
if (BuildConfig.DEBUG) {
StrictMode.enableDefaults()
Snapshot.registerApplyObserver { anies, _ ->
logcat { anies.toString() }
logcat(Log.VERBOSE) { anies.toString() }
}
}
}
@@ -157,52 +181,53 @@ class EhApplication : Application(), ImageLoaderFactory {
}
}
override fun newImageLoader() = imageLoader {
override fun newImageLoader(context: Context) = context.imageLoader {
components {
if (isCronetSupported) {
callFactory { unreachable() }
installCronetHttpUriFetcher()
} else {
callFactory(baseOkHttpClient)
}
add(NetworkFetcher.Factory(unsafeLazy { ktorClient }))
if (isAtLeastP) {
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
}
add(MergeInterceptor)
add(DownloadThumbInterceptor)
}
diskCache(imageCache)
crossfade(300)
error(R.drawable.image_failed)
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
if (drawable != null) error(drawable.asCoilImage(true))
if (BuildConfig.DEBUG) logger(DebugLogger())
respectCacheHeaders(false)
}
companion object {
val baseOkHttpClient by lazy {
httpClient {
cookieJar(EhCookieStore)
if (isAtLeastQ) {
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
var ktorClient by resettableLazy {
if (Settings.enableCronet && isCronetAvailable) {
HttpClient(CronetEngine) {
install(HttpCookies) {
storage = EhCookieStore
}
}
} else {
HttpClient(OkHttp) {
engine {
config {
if (isAtLeastQ) {
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
}
addInterceptor(UncaughtExceptionInterceptor())
}
}
install(HttpCookies) {
storage = EhCookieStore
}
}
chunker { alwaysReadResponseBody(false) }
addInterceptor(UncaughtExceptionInterceptor())
addInterceptor(CloudflareInterceptor(appCtx))
}
}
val noRedirectOkHttpClient by lazy {
httpClient(baseOkHttpClient) {
followRedirects(false)
}
}
// Never use this okhttp client to download large blobs!!!
val okHttpClient by lazy {
httpClient(baseOkHttpClient) {
cache(
appCtx.cacheDir.toOkioPath() / "http_cache",
20L * 1024L * 1024L,
)
var noRedirectKtorClient by resettableLazy {
HttpClient(ktorClient.engine) {
followRedirects = false
install(HttpCookies) {
storage = EhCookieStore
}
}
}
@@ -17,8 +17,6 @@ package com.hippo.ehviewer
import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.os.ParcelFileDescriptor.MODE_READ_ONLY
import arrow.fx.coroutines.release
import arrow.fx.coroutines.resource
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@@ -32,6 +30,7 @@ import com.hippo.ehviewer.dao.LocalFavoriteInfo
import com.hippo.ehviewer.dao.QuickSearch
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.asUniFile
import kotlinx.coroutines.flow.Flow
import splitties.arch.room.roomDb
@@ -261,11 +260,7 @@ object EhDB {
db.filterDao().list().let { newDb.filterDao().insert(it) }
newDb.close()
val dbFile = context.getDatabasePath(ehExportName)
context.contentResolver.openFileDescriptor(uri, "rw")!!.use { toFd ->
ParcelFileDescriptor.open(dbFile, MODE_READ_ONLY).use { fromFd ->
fromFd sendTo toFd
}
}
dbFile.asUniFile() sendTo uri.asUniFile()
}
}
@@ -12,6 +12,8 @@ import androidx.compose.runtime.remember
import com.hippo.ehviewer.client.CHROME_USER_AGENT
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import java.util.Locale
import kotlin.reflect.KProperty
import kotlinx.coroutines.flow.Flow
@@ -105,10 +107,10 @@ object Settings : DataStorePreferences(null) {
val detailSize = intPref("detail_size_2", 0)
val thumbColumns = intPref("thumb_columns", 3)
val listThumbSize = intPref("list_tile_size", 40)
val languageFilter = intPref("language_filter", -1)
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
val predictiveNavAnim = boolPref("predictive_nav_anim", true)
val blackDarkTheme = boolPref("black_dark_theme", false)
val needSignInFlow: Flow<Boolean>
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", null)
var downloadPath by stringOrNullPref("image_path", null)
@@ -142,8 +144,7 @@ object Settings : DataStorePreferences(null) {
var saveCrashLog by boolPref("save_crash_log", true)
var security by boolPref("require_unlock", false)
var removeImageFiles by boolPref("include_pic", true)
var needSignIn by boolPref("need_sign_in", true).also { needSignInFlow = it.valueFlow() }
var blackDarkTheme by boolPref("black_dark_theme", false).observed { updateWhenAmoledModeChanges() }
var 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)
@@ -155,7 +156,7 @@ object Settings : DataStorePreferences(null) {
var searchCategory by intPref("search_pref", EhUtils.ALL_CATEGORY)
var requestNewsTimerHour by intPref("request_news_timer_hour", -1)
var requestNewsTimerMinute by intPref("request_news_timer_minute", -1)
var updateIntervalDays by intPref("update_interval_days", 0)
var updateIntervalDays by intPref("update_interval_days", 7)
var recentToplist by stringPref("recent_toplist", "11")
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
var recentDownloadLabel by stringOrNullPref("recent_download_label", null)
@@ -163,13 +164,24 @@ object Settings : DataStorePreferences(null) {
var displayName by stringOrNullPref("display_name", null)
var avatar by stringOrNullPref("avatar", null)
var language by stringPref("app_language", "system").observed { updateWhenLocaleChanges() }
var lastDawnDay by longPref("last_dawn_day", 0)
var lastUpdateDay by longPref("last_update_day", 0)
var lastDawnTime by longPref("last_dawn_time", 0)
var lastUpdateTime by longPref("last_update_time", 0)
var favDialogTheta by floatPref("fav_select_dialog_delta", 0F)
// TODO: Remove this after swipe gestures are correctly handled in compose
var touchSlopFactor by intPref("touch_slop", 3)
// Tachiyomi Reader
val cropBorder = boolPref("crop_borders", false)
val colorFilter = boolPref("pref_color_filter_key", false)
val colorFilterValue = intPref("color_filter_value", 0)
val colorFilterMode = intPref("color_filter_mode", 0)
val customBrightness = boolPref("pref_custom_brightness_key", false)
val customBrightnessValue = intPref("custom_brightness_value", 0)
val readingMode = intPref("pref_default_reading_mode_key", ReadingModeType.LEFT_TO_RIGHT.flagValue)
val orientationMode = intPref("pref_default_orientation_type_key", OrientationType.FREE.flagValue)
val showReaderSeekbar = boolPref("pref_show_reader_seekbar", true)
init {
if ("CN" == Locale.getDefault().country) {
edit {
@@ -2,7 +2,6 @@ package com.hippo.ehviewer
import android.app.UiModeManager
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
import androidx.core.os.LocaleListCompat
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
@@ -59,14 +58,6 @@ fun updateWhenThemeChanges() {
}
}
fun updateWhenAmoledModeChanges() {
collectScope.launch {
delay(100) // Avoid recompose being cancelled
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_AUTO_BATTERY) // Trigger onNightModeChanged
AppCompatDelegate.setDefaultNightMode(Settings.theme)
}
}
fun updateWhenRequestNewsChanges() {
updateDailyCheckWork(appCtx)
}
@@ -1,50 +1,22 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.client
import android.webkit.CookieManager
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastFilter
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastJoinToString
import androidx.compose.ui.util.fastMapNotNull
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import io.ktor.client.plugins.cookies.CookiesStorage
import io.ktor.http.Cookie
import io.ktor.http.Url
import io.ktor.http.parseClientCookiesHeader
import io.ktor.http.renderSetCookieHeader
object EhCookieStore : CookieJar {
object EhCookieStore : CookiesStorage {
private val manager = CookieManager.getInstance()
fun signOut() = manager.removeAllCookies(null)
fun contains(url: HttpUrl, name: String) = get(url).fastAny { it.name == name }
fun get(url: HttpUrl): List<Cookie> {
val cookies = manager.getCookie(url.toString())
return if (!cookies.isNullOrEmpty()) {
cookies.split(";").fastMapNotNull { setCookie ->
Cookie.parse(url, setCookie)?.takeUnless { it.name == KEY_UTMP_NAME }
}
} else {
emptyList()
}
}
fun contains(url: String, name: String) = load(Url(url)).fastAny { it.name == name }
fun hasSignedIn(): Boolean {
val url = EhUrl.HOST_E.toHttpUrl()
val url = EhUrl.HOST_E
return contains(url, KEY_IPB_MEMBER_ID) && contains(url, KEY_IPB_PASS_HASH)
}
@@ -55,16 +27,13 @@ object EhCookieStore : CookieJar {
private const val KEY_CONTENT_WARNING = "nw"
private const val CONTENT_WARNING_NOT_SHOW = "1"
private const val KEY_UTMP_NAME = "__utmp"
private val sTipsCookie = Cookie.Builder().apply {
name(KEY_CONTENT_WARNING)
value(CONTENT_WARNING_NOT_SHOW)
domain(EhUrl.DOMAIN_E)
path("/")
expiresAt(Long.MAX_VALUE)
}.build()
private val sTipsCookie = Cookie(
name = KEY_CONTENT_WARNING,
value = CONTENT_WARNING_NOT_SHOW,
)
fun copyNecessaryCookies() {
val cookies = get(EhUrl.HOST_E.toHttpUrl())
val cookies = load(Url(EhUrl.HOST_E))
cookies.fastForEach {
if (it.name == KEY_STAR || it.name == KEY_IPB_MEMBER_ID || it.name == KEY_IPB_PASS_HASH) {
manager.setCookie(EhUrl.HOST_EX, it.toString())
@@ -72,37 +41,43 @@ object EhCookieStore : CookieJar {
}
}
fun deleteCookie(url: HttpUrl, name: String) {
manager.setCookie(url.toString(), "$name=;Max-Age=0")
}
fun addCookie(cookie: Cookie) {
manager.setCookie(if (EhUrl.DOMAIN_E == cookie.domain) EhUrl.HOST_E else EhUrl.HOST_EX, cookie.toString())
fun addCookie(k: String, v: String, domain: String) {
val cookie = Cookie(name = k, value = v, domain = domain, maxAge = Int.MAX_VALUE)
val url = if (EhUrl.DOMAIN_E == cookie.domain) EhUrl.HOST_E else EhUrl.HOST_EX
manager.setCookie(url, renderSetCookieHeader(cookie))
}
fun flush() = manager.flush()
fun getCookieHeader(url: HttpUrl): String {
return loadForRequest(url).fastJoinToString("; ") {
fun getCookieHeader(url: String): String {
return load(Url(url)).fastJoinToString("; ") {
"${it.name}=${it.value}"
}
}
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = cookies.fastForEach {
if (it.name != KEY_UTMP_NAME) {
manager.setCookie(url.toString(), it.toString())
override suspend fun addCookie(requestUrl: Url, cookie: Cookie) {
if (cookie.name != KEY_UTMP_NAME) {
manager.setCookie(requestUrl.toString(), renderSetCookieHeader(cookie))
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
override fun close() = Unit
fun load(url: Url): List<Cookie> {
val cookies = manager.getCookie(url.toString())
val checkTips = EhUrl.DOMAIN_E in url.host
return get(url).run {
if (checkTips) {
fastFilter { it.name != KEY_CONTENT_WARNING }.toMutableList().apply { add(sTipsCookie) }
} else {
this
}
}
return cookies?.let {
parseClientCookiesHeader(cookies)
.map { Cookie(it.key, it.value, maxAge = Int.MAX_VALUE) }
.filterTo(mutableListOf()) { it.name != KEY_UTMP_NAME }.apply {
if (checkTips) {
removeAll { it.name == KEY_CONTENT_WARNING }
add(sTipsCookie)
}
}
} ?: if (checkTips) listOf(sTipsCookie) else emptyList()
}
override suspend fun get(requestUrl: Url) = load(requestUrl)
}
@@ -15,7 +15,9 @@
*/
package com.hippo.ehviewer.client
import android.util.Log
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import arrow.fx.coroutines.parMap
import arrow.fx.coroutines.parZip
import com.hippo.ehviewer.R
@@ -29,11 +31,13 @@ import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.parser.ArchiveParser
import com.hippo.ehviewer.client.parser.EventPaneParser
import com.hippo.ehviewer.client.parser.FavParserResult
import com.hippo.ehviewer.client.parser.FavoritesParser
import com.hippo.ehviewer.client.parser.ForumsParser
import com.hippo.ehviewer.client.parser.GalleryApiParser
import com.hippo.ehviewer.client.parser.GalleryDetailParser
import com.hippo.ehviewer.client.parser.GalleryListParser
import com.hippo.ehviewer.client.parser.GalleryListResult
import com.hippo.ehviewer.client.parser.GalleryNotAvailableParser
import com.hippo.ehviewer.client.parser.GalleryPageParser
import com.hippo.ehviewer.client.parser.GalleryTokenApiParser
@@ -45,48 +49,64 @@ import com.hippo.ehviewer.client.parser.TorrentParser
import com.hippo.ehviewer.client.parser.TorrentResult
import com.hippo.ehviewer.client.parser.VoteCommentResult
import com.hippo.ehviewer.client.parser.VoteTagParser
import com.hippo.ehviewer.cronet.awaitBodyFully
import com.hippo.ehviewer.cronet.cronetRequest
import com.hippo.ehviewer.cronet.execute
import com.hippo.ehviewer.cronet.withRequestBody
import com.hippo.ehviewer.dailycheck.showEventNotification
import com.hippo.ehviewer.dailycheck.today
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.isCronetSupported
import io.ktor.client.request.forms.append
import io.ktor.client.statement.HttpStatement
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.utils.io.core.writeFully
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
import kotlin.math.ceil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.datetime.Clock
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonArray
import kotlinx.serialization.json.put
import moe.tarsin.coroutines.removeAllSuspend
import moe.tarsin.coroutines.runSuspendCatching
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import org.jsoup.Jsoup
import splitties.init.appCtx
private const val TAG = "EhEngine"
// https://ehwiki.org/wiki/API#Basics
private const val MAX_REQUEST_SIZE = 25
private const val U_CONFIG_TEXT = "Selected Profile"
private val MEDIA_TYPE_JPEG: MediaType = "image/jpeg".toMediaType()
private const val MAX_SEQUENTIAL_REQUESTS = 5
private const val REQUEST_INTERVAL = 5000L
private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
private const val U_CONFIG_TEXT = "Selected Profile"
fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
val dir = AppConfig.externalParseErrorDir ?: return
val file = File(dir, ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".txt")
RandomAccessFile(file, "rw").use { randomAccessFile ->
with(randomAccessFile) {
writeUTF(e.message + "\n")
onLeft { writeUTF(it) }
onRight { channel.write(it) }
}
}
}
fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
// Don't translate coroutine cancellation
if (e is CancellationException) throw e
// Check sad panda(without panda)
if (body.isEmpty()) {
val empty = body.fold(
{ it.isEmpty() },
{ !it.hasRemaining() },
)
if (empty) {
if (EhUtils.isExHentai) {
throw EhException("Sad Panda\n(without panda)")
} else {
@@ -95,10 +115,12 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
}
// Check Gallery Not Available
if (body.contains("Gallery Not Available - ")) {
val error = GalleryNotAvailableParser.parse(body)
if (!error.isNullOrBlank()) {
throw EhException(error)
body.onLeft {
if ("Gallery Not Available - " in it) {
val error = GalleryNotAvailableParser.parse(it)
if (!error.isNullOrBlank()) {
throw EhException(error)
}
}
}
@@ -108,12 +130,9 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
}
if (e is ParseException || e is SerializationException) {
if ("<" !in body) {
throw EhException(body)
} else {
if (Settings.saveParseErrorBody) AppConfig.saveParseErrorBody(e, body)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
}
body.onLeft { if ("<" !in it) throw EhException(it) }
if (Settings.saveParseErrorBody) body.saveParseError(e)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
}
// We can't translate it, rethrow it anyway
@@ -122,145 +141,94 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
val httpContentPool = DirectByteBufferPool(8, 0x80000)
suspend fun <T> fetchCompat(
url: String,
referer: String? = null,
origin: String? = null,
reqBody: RequestBody? = null,
parser: suspend (ByteBuffer) -> T,
): T {
Log.d(TAG, url)
fun commonChecks(body: ByteBuffer) {
// Check sad panda(without panda)
if (!body.hasRemaining()) {
if (EhUtils.isExHentai) {
throw EhException("Sad Panda\n(without panda)")
} else {
throw EhException("IP banned")
}
}
}
return if (isCronetSupported) {
cronetRequest(url, referer, origin) {
reqBody?.let { withRequestBody(it) }
}.execute {
@Suppress("NewApi")
val code = it.httpStatusCode
if (code >= 400) {
throw StatusCodeException(code)
}
httpContentPool.useInstance { buffer ->
awaitBodyFully(buffer)
buffer.flip()
commonChecks(buffer)
parser(buffer)
}
}
} else {
ehRequest(url, referer, origin) {
reqBody?.let { post(it) }
}.execute {
httpContentPool.useInstance { buffer ->
while (true) {
if (body.source().read(buffer) == -1) break
}
buffer.flip()
commonChecks(buffer)
parser(buffer)
}
}
}
suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend String.() -> T) = execute { response ->
val body = response.bodyAsText()
runSuspendCatching {
block(body)
}.onFailure {
rethrowExactly(response.status.value, body.left(), it)
}.getOrThrow()
}
@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE")
private suspend inline fun <T> Request.executeAndParsingWith(block: suspend String.() -> T): T {
Log.d(TAG, url.toString())
return execute {
val body = body.string()
try {
block(body)
} catch (e: Exception) {
rethrowExactly(code, body, e)
}
suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = execute { response ->
httpContentPool.useInstance { buffer ->
with(response.bodyAsChannel()) { while (!isClosedForRead) readAvailable(buffer) }
buffer.flip()
runSuspendCatching {
block(buffer)
}.onFailure {
rethrowExactly(response.status.value, buffer.right(), it)
}.getOrThrow()
}
}
object EhEngine {
suspend fun getOriginalImageUrl(url: String, referer: String?): String {
Log.d(TAG, url)
return ehRequest(url, referer).executeNoRedirect {
header("Location")?.apply {
if (contains("bounce_login")) {
throw NotLoggedInException()
}
} ?: throw InsufficientFundsException()
}
suspend fun getOriginalImageUrl(url: String, referer: String?) = noRedirectEhRequest(url, referer).execute { response ->
val location = response.headers["Location"] ?: throw InsufficientFundsException()
location.takeIf { "bounce_login" !in it } ?: throw NotLoggedInException()
}
suspend fun getTorrentList(url: String, gid: Long, token: String?): TorrentResult {
val referer = EhUrl.getGalleryDetailUrl(gid, token)
return fetchCompat(url, referer, parser = TorrentParser::parse)
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
}
suspend fun getArchiveList(url: String, gid: Long, token: String?) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
.executeAndParsingWith(ArchiveParser::parse)
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) }
.fetchUsingAsText(ArchiveParser::parse)
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) }
suspend fun getImageLimits() = parZip(
{ fetchCompat(EhUrl.URL_HOME, parser = HomeParser::parse) },
{ ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) },
{ ehRequest(EhUrl.URL_HOME).fetchUsingAsByteBuffer(HomeParser::parse) },
{ ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) },
{ limits, funds -> HomeParser.Result(limits, funds) },
)
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
.executeAndParsingWith { if (parse) EventPaneParser.parse(this) else null }
.fetchUsingAsText { if (parse) EventPaneParser.parse(this) else null }
suspend fun getProfile(): ProfileParser.Result {
val url = ehRequest(EhUrl.URL_FORUMS).executeAndParsingWith(ForumsParser::parse)
return ehRequest(url, EhUrl.URL_FORUMS).executeAndParsingWith(ProfileParser::parse)
val url = ehRequest(EhUrl.URL_FORUMS).fetchUsingAsText(ForumsParser::parse)
return ehRequest(url, EhUrl.URL_FORUMS).fetchUsingAsText(ProfileParser::parse)
}
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
runSuspendCatching {
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
}.onFailure {
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
}.onFailure { throwable ->
// It may get redirected when accessing ex for the first time
if (url == EhUrl.URL_UCONFIG_EX) {
it.printStackTrace()
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
throwable.printStackTrace()
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
} else {
throw it
throw throwable
}
}
}
suspend fun getGalleryPage(
url: String,
gid: Long,
token: String?,
): GalleryPageParser.Result {
suspend fun getGalleryPage(url: String, gid: Long, token: String?): GalleryPageParser.Result {
val referer = EhUrl.getGalleryDetailUrl(gid, token)
return ehRequest(url, referer).executeAndParsingWith(GalleryPageParser::parse)
return ehRequest(url, referer).fetchUsingAsText(GalleryPageParser::parse)
}
suspend fun getGalleryList(url: String) = fetchCompat(url, EhUrl.referer, parser = GalleryListParser::parse)
.apply { fillGalleryList(galleryInfoList, url, true) }
suspend fun getGalleryList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(GalleryListParser::parse)
.apply { galleryInfoList.fillInfo(url, true) }
.takeUnless { it.galleryInfoList.isEmpty() } ?: GalleryListParser.emptyResult
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
EventPaneParser.parse(this)?.let {
Settings.lastDawnDay = today
showEventNotification(it)
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
val eventPane = EventPaneParser.parse(this)
if (eventPane != null) {
Settings.lastDawnTime = Clock.System.now().epochSeconds
showEventNotification(eventPane)
}
GalleryDetailParser.parse(this)
}
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
GalleryDetailParser.parsePreviewList(this) to GalleryDetailParser.parsePreviewPages(this)
}
suspend fun getFavorites(url: String) = fetchCompat(url, EhUrl.referer, parser = FavoritesParser::parse)
.apply { fillGalleryList(galleryInfoList, url) }
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(FavoritesParser::parse)
.apply { galleryInfoList.fillInfo(url) }
suspend fun signIn(username: String, password: String): String {
val referer = "https://forums.e-hentai.org/index.php?act=Login&CODE=00"
@@ -268,32 +236,37 @@ object EhEngine {
val origin = "https://forums.e-hentai.org"
return ehRequest(url, referer, origin) {
formBody {
add("referer", referer)
add("b", "")
add("bt", "")
add("UserName", username)
add("PassWord", password)
add("CookieDate", "1")
append("referer", referer)
append("b", "")
append("bt", "")
append("UserName", username)
append("PassWord", password)
append("CookieDate", "1")
}
}.executeAndParsingWith(SignInParser::parse)
}.fetchUsingAsText(SignInParser::parse)
}
suspend fun commentGallery(url: String, comment: String, id: Long = -1) = ehRequest(url, url, EhUrl.origin) {
formBody {
if (id == -1L) {
add("commenttext_new", comment)
append("commenttext_new", comment)
} else {
add("commenttext_edit", comment)
add("edit_comment", id.toString())
append("commenttext_edit", comment)
append("edit_comment", id.toString())
}
}
}.executeAndParsingWith {
val document = Jsoup.parse(this)
val elements = document.select("#chd + p")
if (elements.size > 0) {
throw EhException(elements[0].text())
}.execute { response ->
// Ktor does not handle POST redirect, we need to do it manually
// https://youtrack.jetbrains.com/issue/KTOR-478
val location = response.headers["Location"] ?: url
ehRequest(location, url).fetchUsingAsText {
val document = Jsoup.parse(this)
val elements = document.select("#chd + p")
if (elements.size > 0) {
throw EhException(elements[0].text())
}
GalleryDetailParser.parseComments(document)
}
GalleryDetailParser.parseComments(document)
}
suspend fun modifyFavorites(gid: Long, token: String?, dstCat: Int = -1, note: String = "") {
@@ -303,54 +276,43 @@ object EhEngine {
else -> throw EhException("Invalid dstCat: $dstCat")
}
val url = EhUrl.getAddFavorites(gid, token)
val body = formBody {
add("favcat", catStr)
add("favnote", note)
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
add("apply", "Apply Changes")
add("update", "1")
}
fetchCompat(url, url, EhUrl.origin, body) { }
ehRequest(url, url, EhUrl.origin) {
formBody {
append("favcat", catStr)
append("favnote", note)
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
append("apply", "Apply Changes")
append("update", "1")
}
}.execute { }
}
suspend fun downloadArchive(
gid: Long,
token: String?,
or: String?,
res: String?,
isHAtH: Boolean,
): String? {
if (or.isNullOrEmpty()) {
throw EhException("Invalid form param or: $or")
}
if (res.isNullOrEmpty()) {
throw EhException("Invalid res: $res")
}
suspend fun downloadArchive(gid: Long, token: String?, or: String?, res: String?, isHAtH: Boolean): String? {
if (or.isNullOrEmpty()) throw EhException("Invalid form param or: $or")
if (res.isNullOrEmpty()) throw EhException("Invalid res: $res")
val url = EhUrl.getDownloadArchive(gid, token, or)
val referer = EhUrl.getGalleryDetailUrl(gid, token)
val request = ehRequest(url, referer, EhUrl.origin) {
formBody {
if (isHAtH) {
add("hathdl_xres", res)
append("hathdl_xres", res)
} else {
add("dltype", res)
append("dltype", res)
if (res == "org") {
add("dlcheck", "Download Original Archive")
append("dlcheck", "Download Original Archive")
} else {
add("dlcheck", "Download Resample Archive")
append("dlcheck", "Download Resample Archive")
}
}
}
}
var result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
var result = request.fetchUsingAsText(ArchiveParser::parseArchiveUrl)
if (!isHAtH) {
if (result == null) {
// Wait for the server to prepare archives
delay(1000)
result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
if (result == null) {
throw EhException("Archive unavailable")
}
result = request.fetchUsingAsText(ArchiveParser::parseArchiveUrl)
if (result == null) throw EhException("Archive unavailable")
}
return result
}
@@ -359,37 +321,28 @@ object EhEngine {
suspend fun resetImageLimits() = ehRequest(EhUrl.URL_HOME) {
formBody {
add("reset_imagelimit", "Reset Limit")
append("reset_imagelimit", "Reset Limit")
}
}.executeAndParsingWith(HomeParser::parseResetLimits)
}.fetchUsingAsText(HomeParser::parseResetLimits)
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavoritesParser.Result {
val url = ehUrl {
addPathSegments(EhUrl.FAV_PATH)
if (FavListUrlBuilder.isValidFavCat(srcCat)) {
addQueryParameter("favcat", srcCat.toString())
}
}.toString()
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavParserResult {
val url = ehUrl(EhUrl.FAV_PATH) {
if (FavListUrlBuilder.isValidFavCat(srcCat)) addQueryParameter("favcat", srcCat.toString())
}.buildString()
val catStr: String = when (dstCat) {
-1 -> "delete"
in 0..9 -> "fav$dstCat"
else -> throw EhException("Invalid dstCat: $dstCat")
}
val body = formBody {
add("ddact", catStr)
gidArray.forEach { add("modifygids[]", it.toString()) }
}
return fetchCompat(url, url, EhUrl.origin, body, FavoritesParser::parse)
.apply { fillGalleryList(galleryInfoList, url) }
return ehRequest(url, url, EhUrl.origin) {
formBody {
append("ddact", catStr)
gidArray.forEach { append("modifygids[]", it.toString()) }
}
}.fetchUsingAsByteBuffer(FavoritesParser::parse).apply { galleryInfoList.fillInfo(url) }
}
suspend fun getGalleryPageApi(
gid: Long,
index: Int,
pToken: String,
showKey: String?,
previousPToken: String?,
): GalleryPageParser.Result {
suspend fun getGalleryPageApi(gid: Long, index: Int, pToken: String, showKey: String?, previousPToken: String?): GalleryPageParser.Result {
val referer = if (index > 0 && previousPToken != null) EhUrl.getPageUrl(gid, index - 1, previousPToken) else null
return ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
@@ -399,16 +352,10 @@ object EhEngine {
put("imgkey", pToken)
put("showkey", showKey)
}
}.executeAndParsingWith { GalleryPageParser.parse(filterNot { it == '\\' }) }
}.fetchUsingAsText { GalleryPageParser.parse(filterNot { it == '\\' }) }
}
suspend fun rateGallery(
apiUid: Long,
apiKey: String?,
gid: Long,
token: String?,
rating: Float,
): RateGalleryResult = ehRequest(EhUrl.apiUrl, EhUrl.getGalleryDetailUrl(gid, token), EhUrl.origin) {
suspend fun rateGallery(apiUid: Long, apiKey: String?, gid: Long, token: String?, rating: Float): RateGalleryResult = ehRequest(EhUrl.apiUrl, EhUrl.getGalleryDetailUrl(gid, token), EhUrl.origin) {
jsonBody {
put("method", "rategallery")
put("apiuid", apiUid)
@@ -417,63 +364,56 @@ object EhEngine {
put("token", token)
put("rating", ceil((rating * 2).toDouble()).toInt())
}
}.executeAndParsingWith(String::parseAs)
}.fetchUsingAsText(String::parseAs)
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String) =
galleryInfoList.chunked(MAX_REQUEST_SIZE).parMap(concurrency = Settings.multiThreadDownload) {
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
put("method", "gdata")
array("gidlist") {
it.forEach {
addJsonArray {
add(it.gid)
add(it.token)
galleryInfoList.chunked(MAX_REQUEST_SIZE).chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
if (index != 0) {
delay(REQUEST_INTERVAL)
}
chunk.parMap {
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
put("method", "gdata")
array("gidlist") {
it.forEach {
addJsonArray {
add(it.gid)
add(it.token)
}
}
}
put("namespace", 1)
}
put("namespace", 1)
}
}.executeAndParsingWith { GalleryApiParser.parse(this, it) }
}.fetchUsingAsText { GalleryApiParser.parse(this, it) }
}
}
suspend fun voteComment(
apiUid: Long,
apiKey: String?,
gid: Long,
token: String?,
commentId: Long,
commentVote: Int,
): VoteCommentResult = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "votecomment")
put("apiuid", apiUid)
put("apikey", apiKey)
put("gid", gid)
put("token", token)
put("comment_id", commentId)
put("comment_vote", commentVote)
}
}.executeAndParsingWith(String::parseAs)
suspend fun voteComment(apiUid: Long, apiKey: String?, gid: Long, token: String?, commentId: Long, commentVote: Int): VoteCommentResult =
ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "votecomment")
put("apiuid", apiUid)
put("apikey", apiKey)
put("gid", gid)
put("token", token)
put("comment_id", commentId)
put("comment_vote", commentVote)
}
}.fetchUsingAsText(String::parseAs)
suspend fun voteTag(
apiUid: Long,
apiKey: String?,
gid: Long,
token: String?,
tags: String?,
vote: Int,
) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "taggallery")
put("apiuid", apiUid)
put("apikey", apiKey)
put("gid", gid)
put("token", token)
put("tags", tags)
put("vote", vote)
}
}.executeAndParsingWith(VoteTagParser::parse)
suspend fun voteTag(apiUid: Long, apiKey: String?, gid: Long, token: String?, tags: String?, vote: Int) =
ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "taggallery")
put("apiuid", apiUid)
put("apikey", apiKey)
put("gid", gid)
put("token", token)
put("tags", tags)
put("vote", vote)
}
}.fetchUsingAsText(VoteTagParser::parse)
suspend fun getGalleryToken(gid: Long, gtoken: String?, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
@@ -486,51 +426,44 @@ object EhEngine {
}
}
}
}.executeAndParsingWith(GalleryTokenApiParser::parse)
}.fetchUsingAsText(GalleryTokenApiParser::parse)
/**
* @param image Must be jpeg
*/
suspend fun imageSearch(image: File, uss: Boolean, osc: Boolean) = fetchCompat(
EhUrl.imageSearchUrl,
EhUrl.referer,
EhUrl.origin,
multipartBody {
setType(MultipartBody.FORM)
addFormDataPart("sfile", "a.jpg", image.asRequestBody(MEDIA_TYPE_JPEG))
if (uss) addFormDataPart("fs_similar", "on")
if (osc) addFormDataPart("fs_covers", "on")
addFormDataPart("f_sfile", "File Search")
},
parser = GalleryListParser::parse,
).apply { fillGalleryList(galleryInfoList, EhUrl.imageSearchUrl) }
private suspend fun fillGalleryList(list: MutableList<BaseGalleryInfo>, url: String, filter: Boolean = false) {
// Filter title and uploader
if (filter) list.removeAllSuspend { EhFilter.filterTitle(it) || EhFilter.filterUploader(it) }
var hasTags = false
var hasPages = false
var hasRated = false
for (gi in list) {
if (!gi.simpleTags.isNullOrEmpty()) {
hasTags = true
}
if (gi.pages != 0) {
hasPages = true
}
if (gi.rated) {
hasRated = true
suspend fun imageSearch(jpeg: File, uss: Boolean, osc: Boolean): GalleryListResult {
val location = noRedirectEhRequest(EhUrl.imageSearchUrl, EhUrl.referer, EhUrl.origin) {
multipartBody {
append("sfile", "a.jpg", ContentType.Image.JPEG, jpeg.length()) {
RandomAccessFile(jpeg, "r").use {
writeFully(it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.length()))
}
}
if (uss) append("fs_similar", "on")
if (osc) append("fs_covers", "on")
append("f_sfile", "File Search")
}
}.execute { it.headers["Location"] ?: error("Failed to search image!!!") }
return ehRequest(location).fetchUsingAsByteBuffer(GalleryListParser::parse).apply {
galleryInfoList.fillInfo(EhUrl.imageSearchUrl)
}
val needApi = filter && EhFilter.needTags() && !hasTags || Settings.showGalleryPages && !hasPages || hasRated
if (needApi) fillGalleryListByApi(list, url)
}
// Filter tag, thumbnail mode need filter uploader again
if (filter) list.removeAllSuspend { EhFilter.filterUploader(it) || EhFilter.filterTag(it) || EhFilter.filterTagNamespace(it) }
private suspend fun MutableList<BaseGalleryInfo>.fillInfo(url: String, filter: Boolean = false) = with(EhFilter) {
if (filter) removeAllSuspend { filterTitle(it) || filterUploader(it) }
val hasTags = any { !it.simpleTags.isNullOrEmpty() }
val hasPages = any { it.pages != 0 }
val hasRated = any { it.rated }
val needApi = filter && needTags() && !hasTags || Settings.showGalleryPages && !hasPages || hasRated
if (needApi) fillGalleryListByApi(this@fillInfo, url)
if (filter) removeAllSuspend { filterUploader(it) || filterTag(it) || filterTagNamespace(it) }
}
suspend fun addFavorites(galleryList: List<Pair<Long, String>>, dstCat: Int) {
galleryList.parMap(concurrency = Settings.multiThreadDownload) { (gid, token) -> modifyFavorites(gid, token, dstCat) }
galleryList.chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
if (index != 0) {
delay(REQUEST_INTERVAL)
}
chunk.parMap { (gid, token) ->
modifyFavorites(gid, token, dstCat)
}
}
}
}
@@ -15,111 +15,37 @@
*/
package com.hippo.ehviewer.client
import android.util.Log
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectOkHttpClient
import com.hippo.ehviewer.EhApplication.Companion.okHttpClient
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
import com.hippo.ehviewer.Settings
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.forms.FormBuilder
import io.ktor.client.request.forms.FormDataContent
import io.ktor.client.request.forms.MultiPartFormDataContent
import io.ktor.client.request.forms.formData
import io.ktor.client.request.header
import io.ktor.client.request.prepareRequest
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpStatement
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.ParametersBuilder
import io.ktor.http.content.TextContent
import io.ktor.http.userAgent
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArrayBuilder
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.okio.decodeFromBufferedSource
import okhttp3.Call
import okhttp3.FormBody
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.executeAsync
import okio.BufferedSource
inline fun ehRequest(url: String, referer: String? = null, origin: String? = null, builder: Request.Builder.() -> Unit = {}) = Request.Builder().url(url).apply {
addHeader("User-Agent", Settings.userAgent)
addHeader("Accept", CHROME_ACCEPT)
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
referer?.let { addHeader("Referer", it) }
origin?.let { addHeader("Origin", it) }
}.apply(builder).build()
inline fun formBody(builder: FormBody.Builder.() -> Unit) = FormBody.Builder().apply(builder).build()
inline fun Request.Builder.formBody(builder: FormBody.Builder.() -> Unit) = post(FormBody.Builder().apply(builder).build())
inline fun multipartBody(builder: MultipartBody.Builder.() -> Unit) = MultipartBody.Builder().apply(builder).build()
val MEDIA_TYPE_JSON: MediaType = "application/json; charset=utf-8".toMediaType()
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
inline fun Request.Builder.jsonBody(builder: JsonObjectBuilder.() -> Unit) = post(buildJsonObject(builder).toString().toRequestBody(MEDIA_TYPE_JSON))
const val TAG_CALL = "CancellableCallReadingScope"
// This scoped response reading scope suspend function aimed at cancelling the call immediately once coroutine is cancelled.
// i.e., bind a call with a suspended coroutine, and launch another coroutine to execute reading
// Once origin coroutine is cancelled, then call cancelled, socket is closed, the reading coroutine will encounter [IOException]
// Since Okio is not suspendable/cancellable, also okhttp, [executeAsync] only make [Request -> Response] cancellable, reading a Response is still not cancellable
// Considering interrupting is not safe and performant, See https://github.com/Kotlin/kotlinx.coroutines/issues/3551#issuecomment-1353245978, we have such [usingCancellable]
// TODO: Remove logging after this function stable
suspend inline fun <R> Call.usingCancellable(crossinline block: suspend Response.() -> R): R = executeAsync().use {
val call = this
Log.d(TAG_CALL, "Got response of call$call")
coroutineScope {
suspendCancellableCoroutine<R> { cont ->
launch {
val r = runCatching {
Log.d(TAG_CALL, "Reading response of call$call")
block(it)
}
if (!isCanceled() && cont.isActive) {
r.exceptionOrNull()?.let {
Log.e(TAG_CALL, "Reading response of call$call failed!")
cont.resumeWithException(it)
}
r.getOrNull()?.let {
Log.d(TAG_CALL, "Reading response of call$call succeed!")
cont.resume(it)
}
} else if (isCanceled() && cont.isActive) {
Log.e(TAG_CALL, "call$call cancelled but coroutine is Active!")
} else if (!isCanceled() && !cont.isActive) {
Log.e(TAG_CALL, "call$call not cancelled but coroutine is Dead!")
} else {
Log.d(TAG_CALL, "Reading response of call$call cancelled")
}
}
cont.invokeOnCancellation {
Log.d(TAG_CALL, "Cancelling call$call")
cancel()
}
}
}.apply {
Log.d(TAG_CALL, "Reading response of call$call finished!")
}
}
suspend inline fun <R> Request.execute(block: Response.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return okHttpClient.newCall(this).executeAsync().use(block)
}
suspend inline fun <R> Request.executeNonCache(crossinline block: suspend Response.() -> R) = baseOkHttpClient.newCall(this).usingCancellable(block)
suspend inline fun <R> Request.executeNoRedirect(block: Response.() -> R) = noRedirectOkHttpClient.newCall(this).executeAsync().use(block)
suspend inline fun <reified T> Request.executeAndParseAs() = execute { parseAs<T>() }
inline fun <reified T> Response.parseAs(): T = body.source().parseAs()
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = execute { it.bodyAsText().parseAs<T>() }
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
@@ -128,3 +54,50 @@ val json = Json { ignoreUnknownKeys = true }
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36"
const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
fun HttpRequestBuilder.applyEhConfig(referer: String?, origin: String?) {
method = HttpMethod.Get
header("Referer", referer)
header("Origin", origin)
userAgent(Settings.userAgent)
header(HttpHeaders.Accept, CHROME_ACCEPT)
header(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
}
suspend inline fun ehRequest(
url: String,
referer: String? = null,
origin: String? = null,
verbose: Boolean = true,
builder: HttpRequestBuilder.() -> Unit = {},
) = ktorClient.prepareRequest(url) {
applyEhConfig(referer, origin)
apply(builder)
}.also { if (verbose) logcat("EhRequest") { url } }
suspend inline fun noRedirectEhRequest(
url: String,
referer: String? = null,
origin: String? = null,
builder: HttpRequestBuilder.() -> Unit = {},
) = noRedirectKtorClient.prepareRequest(url) {
applyEhConfig(referer, origin)
apply(builder)
}.also { logcat("EhRequest") { url } }
inline fun HttpRequestBuilder.formBody(builder: ParametersBuilder.() -> Unit) {
method = HttpMethod.Post
val parameters = ParametersBuilder().apply(builder).build()
setBody(FormDataContent(parameters))
}
inline fun HttpRequestBuilder.jsonBody(builder: JsonObjectBuilder.() -> Unit) {
method = HttpMethod.Post
val json = buildJsonObject(builder).toString()
setBody(TextContent(text = json, contentType = ContentType.Application.Json))
}
fun HttpRequestBuilder.multipartBody(builder: FormBuilder.() -> Unit) {
method = HttpMethod.Post
setBody(MultiPartFormDataContent(formData(builder)))
}
@@ -19,11 +19,16 @@ import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.R
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.copyToFile
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.copyTo
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.isSuccess
import java.io.File
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.CoroutineScope
@@ -35,9 +40,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import moe.tarsin.coroutines.runSuspendCatching
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.executeAsync
import okio.BufferedSource
import okio.HashingSink.Companion.sha1
import okio.blackholeSink
@@ -175,24 +177,18 @@ object EhTagDatabase : CoroutineScope {
return sha1 != null && sha1 == getFileSha1(data)
}
private suspend fun save(client: OkHttpClient, url: String, file: File): Boolean {
val request: Request = Request.Builder().url(url).build()
val call = client.newCall(request)
private suspend fun save(client: HttpClient, url: String, file: File) {
runCatching {
call.executeAsync().use { response ->
if (!response.isSuccessful) {
return false
client.prepareGet(url).execute {
if (it.status.isSuccess()) {
it.bodyAsChannel().copyTo(file)
} else {
throw StatusCodeException(it.status.value)
}
response.body.use {
it.copyToFile(file)
}
return true
}
}.onFailure {
file.delete()
it.printStackTrace()
}
return false
}.getOrThrow()
}
suspend fun update() {
@@ -246,7 +242,7 @@ object EhTagDatabase : CoroutineScope {
// Save new sha1
val tempSha1File = File(dir, "$sha1Name.tmp")
check(save(baseOkHttpClient, sha1Url, tempSha1File))
save(ktorClient, sha1Url, tempSha1File)
val tempSha1 = getFileContent(tempSha1File)
// Check new sha1 and current sha1
@@ -258,7 +254,7 @@ object EhTagDatabase : CoroutineScope {
// Save new data
val tempDataFile = File(dir, "$dataName.tmp")
check(save(baseOkHttpClient, dataUrl, tempDataFile))
save(ktorClient, dataUrl, tempDataFile)
// Check new sha1 and new data
if (!checkData(tempSha1, tempDataFile)) {
@@ -16,7 +16,10 @@
package com.hippo.ehviewer.client
import com.hippo.ehviewer.Settings
import okhttp3.HttpUrl
import io.ktor.http.Parameters
import io.ktor.http.ParametersBuilder
import io.ktor.http.URLBuilder
import io.ktor.http.URLProtocol
object EhUrl {
const val SITE_E = 0
@@ -120,35 +123,41 @@ object EhUrl {
return getGalleryDetailUrl(gid, token, 0, false)
}
fun getGalleryDetailUrl(gid: Long, token: String?, index: Int, allComment: Boolean) = ehUrl {
addPathSegments("g/$gid/$token/")
fun getGalleryDetailUrl(gid: Long, token: String?, index: Int, allComment: Boolean) = ehUrl(listOf("g", gid.toString(), token.orEmpty())) {
if (index != 0) addQueryParameter("p", "$index")
if (allComment) addQueryParameter("hc", "1")
}.toString()
}.buildString()
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl {
addPathSegments("mpv/$gid/$token/")
}.toString()
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl(listOf("mpv", gid.toString(), token)).buildString()
fun getPageUrl(gid: Long, index: Int, pToken: String): String {
return host + "s/" + pToken + '/' + gid + '-' + (index + 1)
}
fun getPageUrl(gid: Long, index: Int, pToken: String) = host + "s/" + pToken + '/' + gid + '-' + (index + 1)
fun getAddFavorites(gid: Long, token: String?): String {
return host + "gallerypopups.php?gid=" + gid + "&t=" + token + "&act=addfav"
}
fun getAddFavorites(gid: Long, token: String?) = host + "gallerypopups.php?gid=" + gid + "&t=" + token + "&act=addfav"
fun getDownloadArchive(gid: Long, token: String?, or: String): String {
return host + "archiver.php?gid=" + gid + "&token=" + token + "&or=" + or
}
fun getDownloadArchive(gid: Long, token: String?, or: String) = host + "archiver.php?gid=" + gid + "&token=" + token + "&or=" + or
fun getTagDefinitionUrl(tag: String): String {
return "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
}
fun getTagDefinitionUrl(tag: String) = "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
}
inline fun httpsUrl(builder: HttpUrl.Builder.() -> Unit) = HttpUrl.Builder().apply(builder).scheme("https").build()
inline fun ehUrl(builder: HttpUrl.Builder.() -> Unit) = httpsUrl { apply(builder).host(EhUrl.domain) }
fun HttpUrl.Builder.addQueryParameterIfNotBlank(name: String, value: String?) = apply {
if (!value.isNullOrBlank()) addQueryParameter(name, value)
inline fun ehUrl(path: String?, builder: ParametersBuilder.() -> Unit) = ehUrl(
path = if (path.isNullOrBlank()) emptyList() else listOf(path),
parameters = Parameters.build(builder),
)
inline fun ehUrl(path: List<String>, builder: ParametersBuilder.() -> Unit) = ehUrl(
path = path,
parameters = Parameters.build(builder),
)
fun ehUrl(path: List<String>, parameters: Parameters = Parameters.Empty) = URLBuilder(
protocol = URLProtocol.HTTPS,
host = EhUrl.domain,
pathSegments = path,
parameters = parameters,
)
fun ParametersBuilder.addQueryParameterIfNotBlank(name: String, value: String?) = apply {
if (!value.isNullOrBlank()) append(name, value)
}
fun ParametersBuilder.addQueryParameter(name: String, value: String) = append(name, value)
@@ -16,17 +16,18 @@
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import androidx.annotation.Keep
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Parcelize
@Serializable
@Entity(tableName = "GALLERIES")
data class BaseGalleryInfo @Keep constructor(
data class BaseGalleryInfo(
@PrimaryKey
@ColumnInfo(name = "GID")
override var gid: Long = 0,
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.client.data
import android.os.Parcelable
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.addQueryParameter
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
import com.hippo.ehviewer.client.ehUrl
import kotlinx.parcelize.Parcelize
@@ -34,24 +35,20 @@ data class FavListUrlBuilder(
prev = index.takeUnless { isNext }
}
fun build() = ehUrl {
addPathSegment(EhUrl.FAV_PATH)
if (isValidFavCat(favCat)) {
addQueryParameter("favcat", "$favCat")
} else if (favCat == FAV_CAT_ALL) {
addQueryParameter("favcat", "all")
fun build() = ehUrl(EhUrl.FAV_PATH) {
when {
isValidFavCat(favCat) -> addQueryParameter("favcat", "$favCat")
favCat == FAV_CAT_ALL -> addQueryParameter("favcat", "all")
}
addQueryParameterIfNotBlank("f_search", keyword)
addQueryParameterIfNotBlank("prev", prev)
addQueryParameterIfNotBlank("next", next)
addQueryParameterIfNotBlank("seek", jumpTo)
}.toString()
}.buildString()
companion object {
const val FAV_CAT_ALL = -1
const val FAV_CAT_LOCAL = -2
fun isValidFavCat(favCat: Int): Boolean {
return favCat in 0..9
}
fun isValidFavCat(favCat: Int) = favCat in 0..9
}
}
@@ -93,7 +93,7 @@ interface GalleryInfo {
Pattern.compile("[(\\[]hun(?:garian)?[)\\]]|ハンガリー翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]dutch[)\\]]|オランダ翻訳", Pattern.CASE_INSENSITIVE),
)
private val S_LANG_TAGS = arrayOf(
val S_LANG_TAGS = arrayOf(
"language:english",
"language:chinese",
"language:spanish",
@@ -17,8 +17,10 @@ package com.hippo.ehviewer.client.data
import android.os.Parcelable
import androidx.annotation.IntDef
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.addQueryParameter
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
import com.hippo.ehviewer.client.ehUrl
import com.hippo.ehviewer.dao.QuickSearch
@@ -253,31 +255,23 @@ data class ListUrlBuilder(
fun build(): String {
return when (mode) {
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl {
if (mode == MODE_SUBSCRIPTION) addPathSegment(EhUrl.WATCHED_PATH)
if (category != EhUtils.NONE) {
addEncodedQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
if (category > 0) {
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
}
val query = mKeyword?.let {
val index = Settings.languageFilter.value
GalleryInfo.S_LANG_TAGS.getOrNull(index)?.let { lang ->
"$it $lang"
} ?: it
}
addQueryParameterIfNotBlank("f_search", query)
addQueryParameterIfNotBlank("f_shash", hash)
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
// Search key
// the settings of ub:UrlBuilder may be overwritten by following Advance search
mKeyword?.split('|')?.forEachIndexed { idx, kwd ->
val keyword = kwd.trim { it <= ' ' }
when (idx) {
0 -> addQueryParameterIfNotBlank("f_search", keyword)
else -> keyword.indexOf(':').takeIf { it >= 0 }?.run {
val key = keyword.substring(0, this).trim { it <= ' ' }
val value = keyword.substring(this + 1).trim { it <= ' ' }
addQueryParameterIfNotBlank(key, value)
}
}
}
// Advance search
if (advanceSearch != -1) {
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
addQueryParameter("advsearch", "1")
if (advanceSearch and AdvanceTable.SH != 0) {
addQueryParameter("f_sh", "on")
@@ -295,20 +289,20 @@ data class ListUrlBuilder(
addQueryParameter("f_sft", "on")
}
// Set min star
if (minRating != -1) {
if (minRating > 0) {
addQueryParameter("f_sr", "on")
addQueryParameter("f_srdd", "$minRating")
}
// Pages
if (pageFrom != -1 || pageTo != -1) {
if (pageFrom > 0 || pageTo > 0) {
addQueryParameter("f_sp", "on")
addQueryParameterIfNotBlank(
"f_spf",
pageFrom.takeUnless { it == -1 }?.toString(),
pageFrom.takeIf { it > 0 }?.toString(),
)
addQueryParameterIfNotBlank(
"f_spt",
pageTo.takeUnless { it == -1 }?.toString(),
pageTo.takeIf { it > 0 }?.toString(),
)
}
}
@@ -1,5 +0,0 @@
package com.hippo.ehviewer.client.exception
import com.hippo.ehviewer.R
class CloudflareBypassException : EhException(R.string.cloudflare_bypass_failed)
@@ -0,0 +1,13 @@
package com.hippo.ehviewer.client.parser
import io.ktor.util.moveToByteArray
import java.nio.ByteBuffer
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
inline fun <reified T> unmarshalParsingAs(body: ByteBuffer, parser: (ByteBuffer, Int) -> Int): T {
val cborBytes = parser(body, body.limit())
body.limit(cborBytes)
val array = body.moveToByteArray()
return Cbor.decodeFromByteArray<T>(array)
}
@@ -3,30 +3,29 @@ package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import moe.tarsin.coroutines.runSuspendCatching
@Serializable
data class FavParserResult(
val catArray: List<String>,
val countArray: List<Int>,
val galleryListResult: GalleryListResult,
) {
val prev = galleryListResult.prev
val next = galleryListResult.next
val galleryInfoList = galleryListResult.galleryInfoList
}
object FavoritesParser {
suspend fun parse(body: ByteBuffer): Result {
val catArray = arrayOfNulls<String>(10)
val countArray = runSuspendCatching {
parseFav(body, favCat = catArray).also { check(it.isNotEmpty()) }
fun parse(body: ByteBuffer): FavParserResult {
return runSuspendCatching {
unmarshalParsingAs<FavParserResult>(body, ::parseFav)
}.getOrElse {
if (it is RuntimeException && it.message == "Not logged in!") throw NotLoggedInException()
throw ParseException("Parse favorites error", it)
}
val result = GalleryListParser.parse(body)
return Result(catArray.requireNoNulls(), countArray, result)
}
class Result(
val catArray: Array<String>,
val countArray: IntArray,
galleryListResult: GalleryListResult,
) {
val prev = galleryListResult.prev
val next = galleryListResult.next
val galleryInfoList = galleryListResult.galleryInfoList
}
}
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit(), favCat: Array<String?>): IntArray
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit()): Int
@@ -93,8 +93,7 @@ object GalleryDetailParser {
// Error info
PATTERN_ERROR.find(body)?.run { throw EhException(groupValues[1]) }
// Temporary workaround, see https://github.com/jhy/jsoup/issues/1850
val document = Jsoup.parse(body.replace("del>", "s>"))
val document = Jsoup.parse(body)
val galleryDetail = GalleryDetail(
tags = parseTagGroups(document),
comments = parseComments(document),
@@ -412,6 +411,8 @@ object GalleryDetailParser {
* Parse comments with html parser
*/
suspend fun parseComments(document: Document): GalleryCommentList {
// 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")
@@ -1,6 +1,5 @@
package com.hippo.ehviewer.client.parser
import androidx.annotation.Keep
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@@ -9,11 +8,12 @@ import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import moe.tarsin.coroutines.runSuspendCatching
object GalleryListParser {
suspend fun parse(body: ByteBuffer) = runSuspendCatching {
parseGalleryInfoList(body)
unmarshalParsingAs<GalleryListResult>(body, ::parseGalleryInfoList)
}.onFailure {
if (it is RuntimeException) {
if (it.message?.startsWith("Your IP address") == true) {
@@ -39,10 +39,11 @@ object GalleryListParser {
val emptyResult = GalleryListResult(null, null, arrayListOf())
}
class GalleryListResult @Keep constructor(
@Serializable
data class GalleryListResult(
val prev: String?,
val next: String?,
val galleryInfoList: ArrayList<BaseGalleryInfo>,
)
private external fun parseGalleryInfoList(body: ByteBuffer, size: Int = body.limit()): GalleryListResult
private external fun parseGalleryInfoList(body: ByteBuffer, size: Int = body.limit()): Int
@@ -1,18 +1,18 @@
package com.hippo.ehviewer.client.parser
import android.os.Parcelable
import androidx.annotation.Keep
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
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 const val INSUFFICIENT_FUNDS = "Insufficient funds."
fun parse(body: ByteBuffer) = runCatching {
parseLimit(body)
unmarshalParsingAs<Limits>(body, ::parseLimit)
}.getOrElse { throw ParseException("Parse image limits error", it) }
fun parseResetLimits(body: String) {
@@ -36,6 +36,7 @@ object HomeParser {
}
@Parcelize
data class Limits @Keep constructor(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
@Serializable
data class Limits(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
private external fun parseLimit(body: ByteBuffer, limit: Int = body.limit()): Limits
private external fun parseLimit(body: ByteBuffer, limit: Int = body.limit()): Int
@@ -31,7 +31,7 @@ object ProfileParser {
val avatar = runCatching {
val avatar =
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
if (avatar.isNullOrEmpty()) {
if (avatar.isEmpty()) {
null
} else if (!avatar.startsWith("http")) {
EhUrl.URL_FORUMS + avatar
@@ -1,16 +1,19 @@
package com.hippo.ehviewer.client.parser
import androidx.annotation.Keep
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
object TorrentParser {
fun parse(body: ByteBuffer) = runCatching {
parseTorrent(body)
}.getOrElse { throw ParseException("Can't parse torrent list", it) }
unmarshalParsingAs<TorrentResult>(body, ::parseTorrent)
}.getOrElse {
throw ParseException("Can't parse torrent list", it)
}
}
class Torrent @Keep constructor(
@Serializable
data class Torrent(
val posted: String,
val size: String,
val seeds: Int,
@@ -24,4 +27,4 @@ fun Torrent.format() = "[$posted] $name [$size] [↑$seeds ↓$peers ✓$downloa
typealias TorrentResult = List<Torrent>
private external fun parseTorrent(body: ByteBuffer, size: Int = body.limit()): ArrayList<Torrent>
private external fun parseTorrent(body: ByteBuffer, size: Int = body.limit()): Int
@@ -1,66 +0,0 @@
@file:Suppress("BlockingMethodInNonBlockingContext")
package com.hippo.ehviewer.coil
import android.net.Uri
import android.webkit.MimeTypeMap
import coil.ComponentRegistry
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.Options
import com.hippo.ehviewer.cronet.copyToChannel
import com.hippo.ehviewer.cronet.cronetRequest
import com.hippo.ehviewer.cronet.execute
import com.hippo.ehviewer.cronet.noCache
import java.io.RandomAccessFile
/**
* A HttpUriFetcher impl use Cronet for jvmheapless IO
* cacheHeader is never respected | recorded since thumb is immutable
*/
class CronetHttpUriFetcher(private val data: String, private val options: Options, private val imageLoader: ImageLoader) : Fetcher {
override suspend fun fetch(): FetchResult {
val diskCacheKey = options.diskCacheKey ?: data
val diskCache = requireNotNull(imageLoader.diskCache)
val snapshot = diskCache.openSnapshot(diskCacheKey) ?: run {
val success = cronetRequest(data) {
noCache()
}.execute {
diskCache.suspendEdit(diskCacheKey) {
RandomAccessFile(data.toFile(), "rw").use {
copyToChannel(it.channel)
}
}
}
check(success)
// diskcache snapshot MUST exist here
requireNotNull(diskCache.openSnapshot(diskCacheKey))
}
return SourceResult(
source = ImageSource(snapshot.data, diskCache.fileSystem, diskCacheKey, snapshot),
mimeType = getMimeType(data),
dataSource = DataSource.DISK,
)
}
}
fun ComponentRegistry.Builder.installCronetHttpUriFetcher() = add { data: Uri, options, loader ->
if (data.scheme != "http" && data.scheme != "https") return@add null
CronetHttpUriFetcher(data.toString(), options, loader)
}
private fun getMimeType(url: String) = MimeTypeMap.getSingleton().getMimeTypeFromUrl(url)
private fun MimeTypeMap.getMimeTypeFromUrl(url: String?): String? {
if (url.isNullOrBlank()) return null
val extension = url
.substringBeforeLast('#') // Strip the fragment.
.substringBeforeLast('?') // Strip the query.
.substringAfterLast('/') // Get the last path segment.
.substringAfterLast('.', missingDelimiterValue = "") // Get the file extension.
return getMimeTypeFromExtension(extension)
}
@@ -1,6 +1,6 @@
package com.hippo.ehviewer.coil
import coil.disk.DiskCache
import coil3.disk.DiskCache
inline fun DiskCache.edit(key: String, crossinline block: DiskCache.Editor.() -> Unit): Boolean {
val editor = openEditor(key) ?: return false
@@ -0,0 +1,45 @@
package com.hippo.ehviewer.coil
import coil3.intercept.Interceptor
import coil3.request.ErrorResult
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.unifile.asUniFile
import eu.kanade.tachiyomi.util.lang.withIOContext
object DownloadThumbInterceptor : Interceptor {
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" }
if (withIOContext { thumb.isFile }) {
val new = chain.request.newBuilder().data(thumb.uri.toString()).build()
when (val result = chain.withRequest(new).proceed()) {
is SuccessResult -> return result
is ErrorResult -> withIOContext { thumb.delete() }
}
}
val new = chain.request.newBuilder().data(url).build()
return chain.withRequest(new).proceed().also {
if (it is SuccessResult) {
withIOContext {
if (thumb.ensureFile()) {
val key = requireNotNull(chain.request.memoryCacheKey)
imageCache.read(key) {
data.asUniFile() sendTo thumb
}
}
}
}
}
}
}
return chain.proceed()
}
}
@@ -2,22 +2,36 @@ package com.hippo.ehviewer.coil
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import coil.decode.DecodeResult
import coil.decode.Decoder
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.size.Size
import coil3.asCoilImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.httpHeaders
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.thumbUrl
import com.hippo.ehviewer.client.url
import com.hippo.ehviewer.spider.DownloadInfoMagics.encodeMagicRequestOrUrl
import io.ktor.http.HttpHeaders
import io.ktor.http.headers
private val header = headers {
append(HttpHeaders.UserAgent, Settings.userAgent)
append(HttpHeaders.Accept, CHROME_ACCEPT)
append(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
}
fun ImageRequest.Builder.ehUrl(info: GalleryInfo) = apply {
val key = info.thumbKey!!
data(info.thumbUrl)
data(encodeMagicRequestOrUrl(info))
memoryCacheKey(key)
diskCacheKey(key)
httpHeaders(header)
}
fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
@@ -25,9 +39,11 @@ fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
memoryCacheKey(preview.imageKey)
diskCacheKey(preview.imageKey)
if (preview is NormalGalleryPreview) size(Size.ORIGINAL)
httpHeaders(header)
}
private val stubResult = DecodeResult(ColorDrawable(Color.BLACK), false)
private val stubImage = ColorDrawable(Color.BLACK).asCoilImage(true)
private val stubResult = DecodeResult(stubImage, false)
private val stubFactory = Decoder { stubResult }
fun ImageRequest.Builder.justDownload() = apply {
@@ -1,7 +1,7 @@
package com.hippo.ehviewer.coil
import coil.intercept.Interceptor
import coil.request.ImageResult
import coil3.intercept.Interceptor
import coil3.request.ImageResult
import com.hippo.ehviewer.Settings
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -41,7 +41,7 @@ object MergeInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val req = chain.request
val key = req.memoryCacheKey?.key?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed(req) }
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed() }
suspendCancellableCoroutine { continuation ->
synchronized(pendingContinuationMapLock) {
@@ -62,7 +62,7 @@ object MergeInterceptor : Interceptor {
}
try {
return withContext(req.interceptorDispatcher) { chain.proceed(req) }.apply {
return withContext(req.interceptorDispatcher) { chain.proceed() }.apply {
// Wake all pending continuations shared with the same memory key since we have written it to memory cache
notifyScope.launch {
synchronized(pendingContinuationMapLock) {
@@ -1,49 +1,11 @@
package com.hippo.ehviewer.cronet
import com.hippo.ehviewer.EhApplication
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
val pool = DirectByteBufferPool(32)
val cronetHttpClientExecutor = EhApplication.baseOkHttpClient.dispatcher.executorService
@Suppress("NewApi")
suspend inline fun CronetRequest.awaitBodyFully(crossinline callback: (ByteBuffer) -> Unit) {
return pool.useInstance { buffer ->
suspendCancellableCoroutine { cont ->
consumer = {
check(it === buffer)
buffer.flip()
callback(it)
buffer.clear()
request.read(buffer)
}
onError = { readerCont.resumeWithException(it) }
readerCont = cont
request.read(buffer)
}
}
}
@Suppress("NewApi")
suspend inline fun CronetRequest.awaitBodyFully(buffer: ByteBuffer) {
return suspendCancellableCoroutine { cont ->
consumer = {
request.read(buffer)
check(buffer.hasRemaining())
}
onError = { readerCont.resumeWithException(it) }
readerCont = cont
request.read(buffer)
}
}
suspend inline fun CronetRequest.copyToChannel(chan: FileChannel, crossinline listener: ((Int) -> Unit) = {}) = awaitBodyFully {
val bytes = chan.write(it)
listener(bytes)
}
// Limit thread to 1 since we are async & non-blocking
val cronetDispatcher = Dispatchers.Default.limitedParallelism(1)
val cronetHttpClientExecutor = cronetDispatcher.asExecutor()
@@ -25,15 +25,17 @@ import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import eu.kanade.tachiyomi.util.lang.withIOContext
import java.time.Duration
import java.time.Instant
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.days
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import splitties.init.appCtx
private val signedIn
get() = EhCookieStore.hasSignedIn()
private const val CHANNEL_ID = "DailyCheckNotification"
class DailyCheckWork(context: Context, workerParams: WorkerParameters) :
@@ -52,22 +54,20 @@ val schedHour
val schedMinute
get() = Settings.requestNewsTimerMinute.takeUnless { it == -1 } ?: 0
private val whenToWork
get() = LocalDateTime.now()
.run { withHour(schedHour) }
.run { withMinute(schedMinute) }
private val initialDelay
get() = Duration.between(LocalDateTime.now(), whenToWork)
.run { if (isNegative) plusDays(1) else this }
private fun getDailyCheckWorkRequest(): PeriodicWorkRequest {
val now = Clock.System.now()
val timeZone = TimeZone.currentSystemDefault()
val whenToWork = LocalDateTime(
now.toLocalDateTime(timeZone).date,
LocalTime(schedHour, schedMinute),
).toInstant(timeZone)
val initialDelay = (whenToWork - now).run { if (isNegative()) plus(1.days) else this }
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
return PeriodicWorkRequestBuilder<DailyCheckWork>(Duration.ofDays(1))
.setConstraints(constraints)
.setInitialDelay(initialDelay.seconds, TimeUnit.SECONDS)
.setInitialDelay(initialDelay.inWholeSeconds, TimeUnit.SECONDS)
.build()
}
@@ -85,13 +85,12 @@ fun updateDailyCheckWork(context: Context) {
}
}
val today
get() = Instant.now().truncatedTo(ChronoUnit.DAYS).epochSecond
suspend fun checkDawn() = runCatching {
if (signedIn && Settings.lastDawnDay != today) {
val now = Clock.System.now()
val last = Instant.fromEpochSeconds(Settings.lastDawnTime)
if (EhCookieStore.hasSignedIn() && now > last + 1.days) {
EhEngine.getNews(true)?.let {
Settings.lastDawnDay = today
Settings.lastDawnTime = now.epochSeconds
showEventNotification(it)
}
}
@@ -1,30 +0,0 @@
package com.hippo.ehviewer.dao
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "OK_HTTP_3_COOKIE")
data class Cookie(
@ColumnInfo(name = "NAME")
var name: String,
@ColumnInfo(name = "VALUE")
var value: String,
@ColumnInfo(name = "EXPIRES_AT")
var expiresAt: Long,
@ColumnInfo(name = "DOMAIN")
var domain: String,
@ColumnInfo(name = "PATH")
var path: String,
@ColumnInfo(name = "SECURE")
var secure: Boolean,
@ColumnInfo(name = "HTTP_ONLY")
var httpOnly: Boolean,
@ColumnInfo(name = "PERSISTENT")
var persistent: Boolean,
@ColumnInfo(name = "HOST_ONLY")
var hostOnly: Boolean,
@PrimaryKey
@ColumnInfo(name = "_id")
var id: Long?,
)
@@ -1,10 +0,0 @@
package com.hippo.ehviewer.dao
import androidx.room.Dao
import androidx.room.Query
@Dao
interface CookiesDao {
@Query("SELECT * FROM OK_HTTP_3_COOKIE")
suspend fun list(): List<Cookie>
}
@@ -181,21 +181,6 @@ abstract class EhDatabase : RoomDatabase() {
abstract fun quickSearchDao(): QuickSearchDao
}
// 1 -> 2 some nullability changes
@Database(
entities = [Cookie::class],
version = 2,
autoMigrations = [
AutoMigration(
from = 1,
to = 2,
),
],
)
abstract class CookiesDatabase : RoomDatabase() {
abstract fun cookiesDao(): CookiesDao
}
@Database(
entities = [Search::class],
version = 2,
@@ -10,7 +10,7 @@ import androidx.room.Relation
import com.hippo.ehviewer.client.data.AbstractDownloadInfo
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo
import java.time.Instant
import kotlinx.datetime.Clock
@Entity(
tableName = "DOWNLOADS",
@@ -37,7 +37,7 @@ data class DownloadEntity(
override var legacy: Int = 0,
@ColumnInfo(name = "TIME")
override var time: Long = Instant.now().toEpochMilli(),
override var time: Long = Clock.System.now().toEpochMilliseconds(),
@ColumnInfo(name = "LABEL", index = true)
override var label: String? = null,
@@ -3,7 +3,7 @@ package com.hippo.ehviewer.dao
import androidx.room.Entity
import androidx.room.ForeignKey
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import java.time.Instant
import kotlinx.datetime.Clock
@Entity(tableName = "HISTORY", foreignKeys = [ForeignKey(BaseGalleryInfo::class, ["GID"], ["GID"])])
class HistoryInfo(gid: Long, time: Long = Instant.now().toEpochMilli()) : TimeInfo(gid, time)
class HistoryInfo(gid: Long, time: Long = Clock.System.now().toEpochMilliseconds()) : TimeInfo(gid, time)
@@ -3,7 +3,7 @@ package com.hippo.ehviewer.dao
import androidx.room.Entity
import androidx.room.ForeignKey
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import java.time.Instant
import kotlinx.datetime.Clock
@Entity(tableName = "LOCAL_FAVORITES", foreignKeys = [ForeignKey(BaseGalleryInfo::class, ["GID"], ["GID"])])
class LocalFavoriteInfo(gid: Long = 0, time: Long = Instant.now().toEpochMilli()) : TimeInfo(gid, time)
class LocalFavoriteInfo(gid: Long = 0, time: Long = Clock.System.now().toEpochMilliseconds()) : TimeInfo(gid, time)
@@ -46,14 +46,14 @@ import com.hippo.ehviewer.util.assertNotMainThread
import com.hippo.ehviewer.util.mapNotNull
import com.hippo.ehviewer.util.runAssertingNotMainThread
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import com.hippo.unifile.asUniFileOrNull
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import splitties.init.appCtx
import splitties.preferences.edit
object DownloadManager : OnSpiderListener {
@@ -595,17 +595,6 @@ object DownloadManager : OnSpiderListener {
map[label] = mutableStateListOf()
}
suspend fun moveLabel(fromPosition: Int, toPosition: Int) {
val item = labelList.removeAt(fromPosition)
labelList.add(toPosition, item)
withIOContext {
val range = if (fromPosition < toPosition) fromPosition..toPosition else toPosition..fromPosition
val list = labelList.slice(range)
list.zip(range).forEach { it.first.position = it.second }
EhDB.updateDownloadLabel(list)
}
}
suspend fun renameLabel(from: String, to: String) {
val index = labelList.indexOfFirst { it.label == from }
if (index != -1) {
@@ -1017,16 +1006,14 @@ object DownloadManager : OnSpiderListener {
var downloadLocation: UniFile
get() = with(Settings) {
UniFile.fromUri(
appCtx,
Uri.Builder().apply {
scheme(downloadScheme)
encodedAuthority(downloadAuthority)
encodedPath(downloadPath)
encodedQuery(downloadQuery)
encodedFragment(downloadFragment)
}.build(),
) ?: UniFile.fromFile(AppConfig.defaultDownloadDir)!!
val uri = Uri.Builder().apply {
scheme(downloadScheme)
encodedAuthority(downloadAuthority)
encodedPath(downloadPath)
encodedQuery(downloadQuery)
encodedFragment(downloadFragment)
}.build()
uri.asUniFileOrNull() ?: requireNotNull(AppConfig.defaultDownloadDir).asUniFile()
}
set(value) = with(value.uri) {
Settings.edit {
@@ -1038,4 +1025,4 @@ var downloadLocation: UniFile
}
}
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation.subFile(it) }
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation / it }
@@ -169,11 +169,7 @@ class ArchivePageLoader(context: Context, private val uri: Uri, passwdProvider:
return true
}
override fun save(index: Int, dir: UniFile, filename: String): UniFile {
val dst = dir.subFile(filename)
save(index, dst!!)
return dst
}
override fun save(index: Int, dir: UniFile, filename: String) = (dir / filename).apply { save(index, this) }
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {}
}
@@ -40,9 +40,3 @@ abstract class PageLoader2 : PageLoader() {
*/
abstract fun save(index: Int, dir: UniFile, filename: String): UniFile?
}
val SUPPORT_IMAGE_EXTENSIONS = arrayOf(
".jpg",
".png",
".gif",
)
@@ -0,0 +1,39 @@
package com.hippo.ehviewer.icons.filled
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Filled.Crop by unsafeLazy {
materialIcon(name = "Crop") {
materialPath {
moveTo(17.0F, 15.0F)
horizontalLineToRelative(2.0F)
verticalLineTo(7.0F)
curveToRelative(0.0F, -1.1F, -0.9F, -2.0F, -2.0F, -2.0F)
horizontalLineTo(9.0F)
verticalLineToRelative(2.0F)
horizontalLineToRelative(8.0F)
verticalLineToRelative(8.0F)
close()
moveTo(7.0F, 17.0F)
verticalLineTo(1.0F)
horizontalLineTo(5.0F)
verticalLineToRelative(4.0F)
horizontalLineTo(1.0F)
verticalLineToRelative(2.0F)
horizontalLineToRelative(4.0F)
verticalLineToRelative(10.0F)
curveToRelative(0.0F, 1.1F, 0.9F, 2.0F, 2.0F, 2.0F)
horizontalLineToRelative(10.0F)
verticalLineToRelative(4.0F)
horizontalLineToRelative(2.0F)
verticalLineToRelative(-4.0F)
horizontalLineToRelative(4.0F)
verticalLineToRelative(-2.0F)
horizontalLineTo(7.0F)
close()
}
}
}
@@ -0,0 +1,60 @@
package com.hippo.ehviewer.icons.filled
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Filled.CropOff by unsafeLazy {
materialIcon(name = "CropOff") {
materialPath {
moveTo(7.0F, 1.0F)
lineToRelative(-2.0F, 0.0F)
lineToRelative(0.0F, 1.18F)
lineToRelative(2.0F, 2.0F)
lineToRelative(0.0F, -3.18F)
close()
moveTo(17.0F, 7.0F)
verticalLineToRelative(7.18F)
lineToRelative(0.82F, 0.82F)
horizontalLineTo(19.0F)
verticalLineTo(7.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, -2.0F, -2.0F)
horizontalLineTo(9.0F)
verticalLineTo(6.18F)
lineTo(9.82F, 7.0F)
close()
moveTo(19.0001F, 19.0F)
lineToRelative(-2.0F, -2.0F)
horizontalLineToRelative(0.0F)
lineTo(2.1F, 2.1F)
lineTo(0.68F, 3.51F)
lineTo(2.1707F, 5.0F)
horizontalLineTo(1.0F)
verticalLineTo(7.0F)
horizontalLineTo(4.1718F)
lineTo(5.0F, 7.8278F)
verticalLineTo(17.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, 2.0F)
horizontalLineToRelative(9.1778F)
lineTo(17.0F, 19.8218F)
verticalLineTo(23.0F)
horizontalLineToRelative(2.0F)
verticalLineTo(21.8207F)
lineTo(20.49F, 23.31F)
lineTo(21.9F, 21.9F)
lineToRelative(-2.9F, -2.9F)
close()
moveTo(7.0F, 17.0F)
verticalLineTo(9.8268F)
lineTo(14.1768F, 17.0F)
close()
moveTo(19.82F, 17.0F)
lineToRelative(2.0F, 2.0F)
lineToRelative(1.18F, 0.0F)
lineToRelative(0.0F, -2.0F)
lineToRelative(-3.18F, 0.0F)
close()
}
}
}
@@ -24,39 +24,71 @@ import android.graphics.ColorSpace
import android.graphics.ImageDecoder
import android.graphics.ImageDecoder.ImageInfo
import android.graphics.ImageDecoder.Source
import android.graphics.Rect
import android.graphics.drawable.Animatable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import coil.decode.BitmapFactoryDecoder
import coil.decode.DecodeUtils
import coil.decode.ImageSource
import coil.request.Options
import coil.size.Scale
import coil.size.Size
import androidx.core.graphics.drawable.toDrawable
import coil3.decode.BitmapFactoryDecoder
import coil3.decode.DecodeUtils
import coil3.decode.ImageSource
import coil3.request.Options
import coil3.size.Scale
import coil3.size.Size
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.jni.isGif
import com.hippo.ehviewer.jni.mmap
import com.hippo.ehviewer.jni.munmap
import com.hippo.ehviewer.jni.rewriteGifSource
import com.hippo.ehviewer.util.isAtLeastO
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastU
import com.hippo.unifile.UniFile
import com.hippo.unifile.openInputStream
import java.nio.ByteBuffer
import kotlin.math.min
import okio.FileSystem
import okio.buffer
import okio.source
import splitties.init.appCtx
class Image private constructor(drawable: Drawable, private val src: AutoCloseable) {
var mObtainedDrawable: Drawable? = drawable
private set
private val CropAspect = 0.1..100.0
class Image private constructor(drawable: Drawable, private val src: AutoCloseable) {
val size = drawable.run { intrinsicHeight * intrinsicWidth * 4 * if (this is Animatable) 4 else 1 }
private val intrinsicRect = drawable.run { Rect(0, 0, intrinsicWidth, intrinsicHeight) }
var mObtainedDrawable: Drawable? = if (drawable is Animatable) {
// Cannot crop animated image's border
drawable
} else {
if (Settings.cropBorder.value) {
val bitmap = (drawable as BitmapDrawable).bitmap
val array = detectBorder(bitmap)
val rect = with(bitmap) {
val r = Rect(array[0], array[1], array[2], array[3])
val aspectAfterCrop = r.height().toFloat() / r.width()
if (aspectAfterCrop in CropAspect) r else intrinsicRect
}
val final = if (isAtLeastQ) {
// Upload to Graphical Buffer to accelerate render
bitmap.copy(Bitmap.Config.HARDWARE, false).apply {
bitmap.recycle()
}
} else {
bitmap
}
final.toDrawable(appCtx.resources).apply { bounds = rect }
} else {
drawable.apply { bounds = intrinsicRect }
}
}
@Synchronized
fun recycle() {
@@ -86,8 +118,8 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
)
}
}
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 2
private val targetHeight = appCtx.resources.displayMetrics.heightPixels * 2
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3 / 2
private val targetHeight = appCtx.resources.displayMetrics.heightPixels * 3 / 2
@delegate:RequiresApi(Build.VERSION_CODES.O)
val isWideColorGamut by lazy { appCtx.resources.configuration.isScreenWideColorGamut }
@@ -116,19 +148,21 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
}
}
}
val drawable = decodeDrawable(src.source.imageSource)
val drawable = decodeDrawable(src.source.asImageSource())
if (drawable !is Animatable) src.close()
Image(drawable, src)
} else {
val options = Options(
appCtx,
colorSpace = if (isAtLeastO) colorSpace else null,
size = Size(targetWidth, targetHeight),
scale = Scale.FILL,
allowInexactSize = true,
)
val drawable = ImageSource(src.source.openInputStream().source().buffer(), appCtx).use {
BitmapFactoryDecoder(it, options).decode().drawable
val drawable = ImageSource(
src.source.openInputStream().source().buffer(),
FileSystem.SYSTEM,
).use {
BitmapFactoryDecoder(it, options).decode().image.asDrawable(appCtx.resources)
}
src.close()
Image(drawable, src)
@@ -159,8 +193,7 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
@RequiresApi(Build.VERSION_CODES.P)
private fun decodeDrawable(src: Source) = ImageDecoder.decodeDrawable(src) { decoder, info, _ ->
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
// Allocating hardware bitmap may cause a crash on framework versions prior to Android Q
if (!isAtLeastQ || Settings.cropBorder.value) {
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
}
decoder.setTargetColorSpace(colorSpace)
@@ -202,3 +235,5 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
val source: ByteBuffer
}
}
private external fun detectBorder(bitmap: Bitmap): IntArray
@@ -1,9 +1,9 @@
package com.hippo.ehviewer.ktbuilder
import android.content.Context
import coil.ImageLoader
import coil.disk.DiskCache
import coil.request.ImageRequest
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.request.ImageRequest
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.coil.ehPreview
@@ -1,13 +0,0 @@
package com.hippo.ehviewer.ktbuilder
import com.chuckerteam.chucker.api.ChuckerInterceptor
import okhttp3.Cache
import okhttp3.OkHttpClient
import okio.FileSystem
import okio.Path
import splitties.init.appCtx
inline fun httpClient(builder: OkHttpClient.Builder.() -> Unit): OkHttpClient = OkHttpClient.Builder().apply(builder).build()
inline fun httpClient(client: OkHttpClient, builder: OkHttpClient.Builder.() -> Unit): OkHttpClient = client.newBuilder().apply(builder).build()
fun OkHttpClient.Builder.cache(directory: Path, maxSize: Long, fileSystem: FileSystem = FileSystem.SYSTEM) = cache(Cache(directory, maxSize, fileSystem))
inline fun OkHttpClient.Builder.chunker(builder: ChuckerInterceptor.Builder.() -> Unit) = addInterceptor(ChuckerInterceptor.Builder(appCtx).apply(builder).build())
@@ -1,38 +0,0 @@
package com.hippo.ehviewer.legacy
import arrow.fx.coroutines.release
import arrow.fx.coroutines.resource
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.dao.Cookie
import com.hippo.ehviewer.dao.CookiesDatabase
import splitties.arch.room.roomDb
import splitties.init.appCtx
private const val LEGACY_COOKIES_DB = "okhttp3-cookie.db"
private fun Cookie.toOkHttp3Cookie(): okhttp3.Cookie {
return okhttp3.Cookie.Builder().apply {
name(name)
value(value)
expiresAt(expiresAt)
if (hostOnly) hostOnlyDomain(domain) else domain(domain)
path(path)
if (secure) secure()
if (httpOnly) httpOnly()
}.build()
}
suspend fun migrateCookies() {
if (LEGACY_COOKIES_DB !in appCtx.databaseList()) return
resource {
roomDb<CookiesDatabase>(LEGACY_COOKIES_DB)
} release {
it.close()
appCtx.deleteDatabase(LEGACY_COOKIES_DB)
} use { database ->
database.cookiesDao().list().forEach {
EhCookieStore.addCookie(it.toOkHttp3Cookie())
}
EhCookieStore.flush()
}
}
@@ -0,0 +1,29 @@
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?> {
return 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
}
}
}
@@ -15,37 +15,29 @@
*/
package com.hippo.ehviewer.spider
import android.os.ParcelFileDescriptor
import android.os.ParcelFileDescriptor.MODE_READ_WRITE
import com.hippo.ehviewer.EhApplication.Companion.imageCache as sCache
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.client.EhUtils.getSuitableTitle
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.client.executeNonCache
import com.hippo.ehviewer.client.getImageKey
import com.hippo.ehviewer.coil.read
import com.hippo.ehviewer.coil.suspendEdit
import com.hippo.ehviewer.cronet.copyToChannel
import com.hippo.ehviewer.cronet.cronetRequest
import com.hippo.ehviewer.cronet.execute
import com.hippo.ehviewer.cronet.getHeadersMap
import com.hippo.ehviewer.cronet.noCache
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.gallery.SUPPORT_IMAGE_EXTENSIONS
import com.hippo.ehviewer.image.Image.UniFileSource
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.isCronetSupported
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import com.hippo.unifile.openOutputStream
import io.ktor.client.plugins.onDownload
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.utils.io.jvm.nio.copyTo
import java.util.Locale
import kotlin.io.path.readText
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Response
import okio.Buffer
import okio.ForwardingSink
import okio.sink
class SpiderDen(mGalleryInfo: GalleryInfo) {
private val mGid = mGalleryInfo.gid
@@ -78,13 +70,9 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
val key = getImageKey(mGid, index)
return runCatching {
sCache.read(key) {
val extension = fixExtension("." + metadata.toFile().readText())
val extension = "." + metadata.toFile().readText()
val file = dir.createFile(perFilename(index, extension)) ?: return false
file.openFileDescriptor("w").use { outFd ->
ParcelFileDescriptor.open(data.toFile(), MODE_READ_WRITE).use {
it sendTo outFd
}
}
data.asUniFile() sendTo file
true
} ?: false
}.onFailure {
@@ -123,46 +111,34 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
private fun findDownloadFileForIndex(index: Int, extension: String): UniFile? {
val dir = downloadDir ?: return null
val ext = fixExtension(".$extension")
val ext = ".$extension"
return dir.createFile(perFilename(index, ext))
}
@Suppress("NewApi")
suspend fun makeHttpCallAndSaveImage(
index: Int,
url: String,
referer: String?,
notifyProgress: (Long, Long, Int) -> Unit,
): Boolean {
return if (isCronetSupported) {
cronetRequest(url, referer) {
noCache()
}.execute { info ->
val headers = info.getHeadersMap()
val type = headers["Content-Type"]?.first()?.toMediaType()?.subtype ?: "jpg"
val length = headers["Content-Length"]!!.first().toLong()
saveResponseMeta(index, type, length) { file ->
file.openOutputStream().use { os ->
val chan = os.channel
chan.truncate(0)
copyToChannel(chan) {
notifyProgress(length, info.receivedByteCount, it)
}
}
}
}
) = ehRequest(url, referer) {
var prev = 0L
onDownload { done, total ->
notifyProgress(total!!, done, (done - prev).toInt())
prev = done
}
}.execute {
if (it.status.isSuccess()) {
saveFromHttpResponse(index, it)
} else {
ehRequest(url, referer).executeNonCache {
if (code >= 400) {
false
} else {
saveFromHttpResponse(index, this, notifyProgress)
}
}
false
}
}
private suspend fun saveResponseMeta(index: Int, ext: String, length: Long, fops: suspend (UniFile) -> Unit): Boolean {
private suspend inline fun saveResponseMeta(
index: Int,
ext: String,
crossinline fops: suspend (UniFile) -> Unit,
): Boolean {
findDownloadFileForIndex(index, ext)?.run {
fops(this)
return true
@@ -173,57 +149,46 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
val key = getImageKey(mGid, index)
return sCache.suspendEdit(key) {
metadata.toFile().writeText(ext)
fops(UniFile.fromFile(data.toFile())!!)
fops(data.asUniFile())
}
}
return false
}
private suspend fun saveFromHttpResponse(index: Int, response: Response, notifyProgress: (Long, Long, Int) -> Unit): Boolean {
val contentType = response.body.contentType()
val extension = contentType?.subtype ?: "jpg"
val length = response.body.contentLength()
return saveResponseMeta(index, extension, length) { outFile ->
object : ForwardingSink(outFile.openOutputStream().also { it.channel.truncate(0) }.sink()) {
var totalBytesRead = 0L
override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
totalBytesRead += byteCount
notifyProgress(length, totalBytesRead, byteCount.toInt())
}
}.use { sink -> response.body.source().use { source -> source.readAll(sink) } }
private suspend fun saveFromHttpResponse(index: Int, response: HttpResponse): Boolean {
val contentType = response.contentType()
val extension = contentType?.contentSubtype ?: "jpg"
return saveResponseMeta(index, extension) { outFile ->
outFile.openOutputStream().use {
val chan = it.channel.apply { truncate(0) }
response.bodyAsChannel().copyTo(chan)
}
}
}
fun saveToUniFile(index: Int, file: UniFile): Boolean {
file.openFileDescriptor("w").use { toFd ->
val key = getImageKey(mGid, index)
val key = getImageKey(mGid, index)
// Read from diskCache first
sCache.read(key) {
runCatching {
UniFile.fromFile(data.toFile())!!.openFileDescriptor("r").use {
it sendTo toFd
}
return true
}.onFailure {
it.printStackTrace()
return false
}
// Read from diskCache first
sCache.read(key) {
runCatching {
data.asUniFile() sendTo file
return true
}.onFailure {
it.printStackTrace()
return false
}
}
// Read from download dir
downloadDir?.let { uniFile ->
runCatching {
findImageFile(uniFile, index)?.openFileDescriptor("r")?.use {
it sendTo toFd
}
}.onFailure {
it.printStackTrace()
return false
}.onSuccess {
return true
}
// Read from download dir
downloadDir?.let { uniFile ->
runCatching {
requireNotNull(findImageFile(uniFile, index)) sendTo file
}.onFailure {
it.printStackTrace()
return false
}.onSuccess {
return true
}
}
return false
@@ -240,9 +205,8 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
val key = getImageKey(mGid, index)
val snapshot = sCache.openSnapshot(key)
if (snapshot != null) {
val source = UniFile.fromFile(snapshot.data.toFile())!!
return object : UniFileSource, AutoCloseable by snapshot {
override val source = source
override val source = snapshot.data.asUniFile()
}
}
}
@@ -256,8 +220,6 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
}
}
private val COMPAT_IMAGE_EXTENSIONS = SUPPORT_IMAGE_EXTENSIONS + ".jpeg"
/**
* @param extension with dot
*/
@@ -265,15 +227,9 @@ fun perFilename(index: Int, extension: String?): String {
return String.format(Locale.US, "%08d%s", index + 1, extension)
}
/**
* @param extension with dot
*/
private fun fixExtension(extension: String): String {
return extension.takeIf { SUPPORT_IMAGE_EXTENSIONS.contains(it) } ?: SUPPORT_IMAGE_EXTENSIONS[0]
}
private fun findImageFile(dir: UniFile, index: Int): UniFile? {
return COMPAT_IMAGE_EXTENSIONS.firstNotNullOfOrNull { dir.findFile(perFilename(index, it)) }
val head = perFilename(index, ".")
return dir.findFirst { name -> name.startsWith(head) }
}
suspend fun GalleryInfo.putToDownloadDir(): String {
@@ -285,5 +241,5 @@ suspend fun GalleryInfo.putToDownloadDir(): String {
suspend fun getGalleryDownloadDir(gid: Long): UniFile? {
val dirname = EhDB.getDownloadDirname(gid) ?: return null
return downloadLocation.subFile(dirname)
return downloadLocation / dirname
}
@@ -7,12 +7,12 @@ import com.hippo.ehviewer.legacy.readLegacySpiderInfo
import com.hippo.unifile.UniFile
import com.hippo.unifile.openInputStream
import com.hippo.unifile.openOutputStream
import java.io.File
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path.Companion.toOkioPath
import splitties.init.appCtx
@Serializable
@@ -50,7 +50,7 @@ fun SpiderInfo.saveToCache() {
private val spiderInfoCache by lazy {
diskCache {
directory(File(appCtx.cacheDir, "spider_info_v2_1"))
directory(appCtx.cacheDir.toOkioPath() / "spider_info_v2_1")
maxSizeBytes(20 * 1024 * 1024)
}
}
@@ -31,7 +31,7 @@ import com.hippo.ehviewer.client.EhUrl.referer
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.client.exception.QuotaExceededException
import com.hippo.ehviewer.client.execute
import com.hippo.ehviewer.client.fetchUsingAsText
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePages
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePreviewList
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePreviewPages
@@ -41,9 +41,10 @@ import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.launchIO
import java.time.Instant
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -309,11 +310,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
fun save(index: Int, dir: UniFile, filename: String): UniFile? {
val state = getPageState(index)
if (STATE_FINISHED != state) {
return null
}
val dst = dir.subFile(filename) ?: return null
return if (!mSpiderDen.saveToUniFile(index, dst)) null else dst
if (STATE_FINISHED != state) return null
return (dir / filename).takeIf { mSpiderDen.saveToUniFile(index, it) }
}
fun getExtension(index: Int): String? {
@@ -366,12 +364,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
ehRequest(
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
referer,
).execute {
val bodyStr = body.string()
val pages = parsePages(bodyStr)
val spiderInfo = SpiderInfo(galleryInfo.gid, pages)
spiderInfo.token = galleryInfo.token
readPreviews(bodyStr, 0, spiderInfo)
).fetchUsingAsText {
val pages = parsePages(this)
val spiderInfo = SpiderInfo(galleryInfo.gid, pages, token = galleryInfo.token)
readPreviews(this, 0, spiderInfo)
spiderInfo
}
}.onFailure {
@@ -385,17 +381,16 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
galleryInfo.gid,
galleryInfo.token!!,
)
return try {
ehRequest(url, referer).execute {
val bodyStr = body.string()
GalleryMultiPageViewerPTokenParser.parse(bodyStr).forEachIndexed { index, s ->
return runSuspendCatching {
ehRequest(url, referer).fetchUsingAsText {
GalleryMultiPageViewerPTokenParser.parse(this).forEachIndexed { index, s ->
spiderInfo.pTokenMap[index] = s
}
spiderInfo.pTokenMap[index]
}
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
return null
}.getOrElse {
it.printStackTrace()
null
}
}
@@ -418,13 +413,13 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
previewIndex,
false,
)
return try {
ehRequest(url, referer).execute {
readPreviews(body.string(), previewIndex, spiderInfo)
return runSuspendCatching {
ehRequest(url, referer).fetchUsingAsText {
readPreviews(this, previewIndex, spiderInfo)
spiderInfo.pTokenMap[index]
}
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
}.getOrElse {
it.printStackTrace()
null
}
}
@@ -521,10 +516,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private val pTokenLock = Mutex()
private var showKey: String? = null
private val showKeyLock = Mutex()
private val mDownloadDelay = Settings.downloadDelay.toLong()
private val mDownloadDelay = Settings.downloadDelay.milliseconds
private val downloadTimeout = Settings.downloadTimeout.seconds
private val delayLock = Mutex()
private var delayedTime = 0L
private var lastRequestTime = TimeSource.Monotonic.markNow()
private var isDownloadMode = false
fun cancelDecode(index: Int) {
@@ -558,11 +553,22 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
val state = mPageStateArray[index]
if (!force && state == STATE_FINISHED) return
val currentJob = mFetcherJobMap[index]
if (force) currentJob?.cancel()
if (force) currentJob?.cancel(CancellationException(FORCE_RETRY))
if (currentJob?.isActive != true) {
mFetcherJobMap[index] = launch {
mSemaphore.withPermit {
doInJob(index, force, orgImg)
runCatching {
mSemaphore.withPermit {
doInJob(index, force, orgImg)
}
}.onFailure {
if (it is CancellationException) {
Log.d(WORKER_DEBUG_TAG, "Download image $index cancelled")
if (it.message != FORCE_RETRY) {
updatePageState(index, STATE_FAILED, "Cancelled")
}
throw it
}
updatePageState(index, STATE_FAILED, ExceptionUtils.getReadableString(it))
}
}
}
@@ -596,23 +602,23 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}
val previousPToken: String?
val pToken: String
val pToken: String?
pTokenLock.withLock {
pToken = getPToken(index) ?: return updatePageState(
index,
STATE_FAILED,
PTOKEN_FAILED_MESSAGE,
).also {
// Non-local return will not release the lock
// https://github.com/Kotlin/kotlinx.coroutines/issues/3985
pToken = getPToken(index)
if (pToken == null) {
updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE)
mSpiderInfo.pTokenMap[index] = TOKEN_FAILED
}
previousPToken = getPToken(index - 1)
}
pToken ?: return
delayLock.withLock {
val curTime = Instant.now().toEpochMilli()
delayedTime = (delayedTime + mDownloadDelay).coerceAtLeast(curTime)
delay(delayedTime - curTime)
delay(mDownloadDelay - lastRequestTime.elapsedNow())
lastRequestTime = TimeSource.Monotonic.markNow()
}
var skipHathKey: String? = null
@@ -620,7 +626,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
var error: String? = null
var forceHtml = false
val original = Settings.downloadOriginImage || orgImg
runCatching {
runSuspendCatching {
repeat(2) { retries ->
var imageUrl: String? = null
var localShowKey: String?
@@ -682,7 +688,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
referer = null
}
checkNotNull(targetImageUrl)
Log.d(WORKER_DEBUG_TAG, targetImageUrl)
repeat(3) { times ->
runCatching {
@@ -713,14 +718,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}.onFailure {
when (it) {
is CancellationException -> {
Log.d(WORKER_DEBUG_TAG, "Download image $index cancelled")
error = "Cancelled"
updatePageState(index, STATE_FAILED, error)
throw it
}
is QuotaExceededException -> notifyGet509(index)
// TODO: Check IP ban
}
error = ExceptionUtils.getReadableString(it)
}
@@ -750,18 +749,22 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private suspend fun doInJob(index: Int) {
mFetcherJobMap[index]?.takeIf { it.isActive }?.join()
val src = mSpiderDen.getImageSource(index) ?: return
val image = mSemaphore.withPermit { Image.decode(src) }
runCatching {
currentCoroutineContext().ensureActive()
}.onFailure {
image?.recycle()
throw it
}
if (image == null) {
notifyGetImageFailure(index, DECODE_ERROR)
} else {
val src = mSpiderDen.getImageSource(index) ?: return
val image = mSemaphore.withPermit { Image.decode(src) }
checkNotNull(image)
runCatching {
currentCoroutineContext().ensureActive()
}.onFailure {
image.recycle()
throw it
}
notifyGetImageSuccess(index, image)
}.onFailure {
notifyGetImageFailure(index, DECODE_ERROR)
if (it is CancellationException) {
throw it
}
}
}
}
@@ -772,6 +775,7 @@ private val PTOKEN_FAILED_MESSAGE = appCtx.getString(R.string.error_get_ptoken_e
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"
private fun check509(url: String) {
@@ -81,7 +81,7 @@ import moe.tarsin.coroutines.runSuspendCatching
import splitties.init.appCtx
private fun removeNoMediaFile(downloadDir: UniFile) {
val noMedia = downloadDir.subFile(".nomedia") ?: return
val noMedia = downloadDir / ".nomedia"
noMedia.delete()
}
@@ -1,51 +0,0 @@
package com.hippo.ehviewer.ui
import android.app.Activity
import android.os.Bundle
import androidx.compose.foundation.layout.imePadding
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.ui.destinations.BaseScreenDestination
import com.hippo.ehviewer.ui.destinations.SelectSiteScreenDestination
import com.hippo.ehviewer.ui.destinations.SignInScreenDestination
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.util.findActivity
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.rememberNavHostEngine
class ConfigureActivity : EhActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setMD3Content {
val windowSizeClass = calculateWindowSizeClass(this)
CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) {
DestinationsNavHost(
navGraph = NavGraphs.root,
modifier = Modifier.imePadding(),
startRoute = if (Settings.needSignIn) {
if (EhCookieStore.hasSignedIn()) SelectSiteScreenDestination else SignInScreenDestination
} else {
BaseScreenDestination
},
engine = rememberNavHostEngine(rootDefaultAnimations = ehNavAnim),
)
}
}
}
}
@Destination
@Composable
fun Finish() {
val context = LocalContext.current
SideEffect {
context.findActivity<Activity>().finish()
}
}
@@ -15,32 +15,14 @@
*/
package com.hippo.ehviewer.ui
import android.content.res.Resources.Theme
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.ActivityNavigator
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import eu.kanade.tachiyomi.util.system.isNightMode
import eu.kanade.tachiyomi.util.view.setSecureScreen
abstract class EhActivity : AppCompatActivity() {
@StyleRes
fun getThemeStyleRes(): Int {
return if (Settings.blackDarkTheme && isNightMode()) R.style.ThemeOverlay_Black else R.style.ThemeOverlay
}
override fun onApplyThemeResource(theme: Theme, resid: Int, first: Boolean) {
super.onApplyThemeResource(theme, resid, first)
theme.applyStyle(getThemeStyleRes(), true)
}
override fun onNightModeChanged(mode: Int) {
theme.applyStyle(getThemeStyleRes(), true)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -17,24 +17,22 @@ package com.hippo.ehviewer.ui
import android.annotation.SuppressLint
import android.app.assist.AssistContent
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.verify.domain.DomainVerificationManager
import android.content.pm.verify.domain.DomainVerificationUserState.DOMAIN_STATE_NONE
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
@@ -54,17 +52,20 @@ import androidx.compose.material.icons.filled.Whatshot
import androidx.compose.material3.DrawerState2
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.SideDrawer
import androidx.compose.material3.Snackbar2
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -73,9 +74,11 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.currentCompositeKeyHash
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@@ -89,6 +92,7 @@ import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.navigation.compose.rememberNavController
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
@@ -105,17 +109,21 @@ import com.hippo.ehviewer.ui.destinations.GalleryListScreenDestination
import com.hippo.ehviewer.ui.destinations.HistoryScreenDestination
import com.hippo.ehviewer.ui.destinations.HomePageScreenDestination
import com.hippo.ehviewer.ui.destinations.ProgressScreenDestination
import com.hippo.ehviewer.ui.destinations.SelectSiteScreenDestination
import com.hippo.ehviewer.ui.destinations.SettingsScreenDestination
import com.hippo.ehviewer.ui.destinations.SignInScreenDestination
import com.hippo.ehviewer.ui.destinations.SubscriptionScreenDestination
import com.hippo.ehviewer.ui.destinations.ToplistScreenDestination
import com.hippo.ehviewer.ui.destinations.WhatshotScreenDestination
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
import com.hippo.ehviewer.ui.screen.TokenArgs
import com.hippo.ehviewer.ui.screen.navWithUrl
import com.hippo.ehviewer.ui.screen.navigate
import com.hippo.ehviewer.ui.settings.showNewVersion
import com.hippo.ehviewer.ui.tools.IconFix
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalTouchSlopProvider
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.updater.AppUpdater
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ExceptionUtils
@@ -123,15 +131,19 @@ import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.getUrlFromClipboard
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastS
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.rememberNavHostEngine
import com.ramcosta.composedestinations.utils.currentDestinationAsState
import eu.kanade.tachiyomi.util.lang.withIOContext
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import splitties.systemservices.clipboardManager
@@ -145,8 +157,12 @@ private val navItems = arrayOf(
Triple(FavouritesScreenDestination, R.string.favourite, Icons.Default.Favorite),
Triple(HistoryScreenDestination, R.string.history, Icons.Default.History),
Triple(DownloadsScreenDestination, R.string.downloads, Icons.Default.Download),
Triple(SettingsScreenDestination, R.string.settings, Icons.Default.Settings),
)
val StartDestination
get() = navItems[Settings.launchPage].first
class MainActivity : EhActivity() {
private val sideSheet = mutableStateListOf<@Composable ColumnScope.(DrawerState2) -> Unit>()
@@ -187,11 +203,12 @@ class MainActivity : EhActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intentFlow.tryEmit(intent)
intentChannel.trySend(intent)
}
private val tipFlow = MutableSharedFlow<String>(extraBufferCapacity = 1)
private val intentFlow = MutableSharedFlow<Intent>(extraBufferCapacity = 4)
private val intentChannel = Channel<Intent>(capacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val intentFlow = intentChannel.receiveAsFlow()
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
override fun onCreate(savedInstanceState: Bundle?) {
@@ -209,10 +226,15 @@ class MainActivity : EhActivity() {
callback()
}
LaunchedEffect(Unit) {
launch { dialogState.checkDownloadLocation() }.join()
launch { dialogState.checkAppLinkVerify() }
}
val cannotParse = stringResource(R.string.error_cannot_parse_the_url)
LaunchedEffect(Unit) {
intentFlow.collect {
when (intent?.action) {
intentFlow.collect { intent ->
when (intent.action) {
Intent.ACTION_VIEW -> {
val url = intent.data?.toString()
if (url != null && !navController.navWithUrl(url)) {
@@ -272,7 +294,7 @@ class MainActivity : EhActivity() {
LaunchedEffect(Unit) {
if (connectivityManager.isActiveNetworkMetered) {
if (isAtLeastQ) {
val ret = snackbarState.showSnackbar(warning, settings)
val ret = snackbarState.showSnackbar(warning, settings, true)
if (ret == SnackbarResult.ActionPerformed) {
val panelIntent = Intent(android.provider.Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
startActivity(panelIntent)
@@ -286,7 +308,7 @@ class MainActivity : EhActivity() {
val snackMessage = stringResource(R.string.clipboard_gallery_url_snack_message)
val snackAction = stringResource(R.string.clipboard_gallery_url_snack_action)
LifecycleResumeEffect {
scope.launch {
val job = scope.launch {
delay(300)
val text = clipboardManager.getUrlFromClipboard(applicationContext)
val hashCode = text?.hashCode() ?: 0
@@ -301,13 +323,13 @@ class MainActivity : EhActivity() {
launch = { navController.navigate(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
}
launch?.let {
val ret = snackbarState.showSnackbar(snackMessage, snackAction)
val ret = snackbarState.showSnackbar(snackMessage, snackAction, true)
if (ret == SnackbarResult.ActionPerformed) it()
}
}
Settings.clipboardTextHashCode = hashCode
}
onPauseOrDispose { }
onPauseOrDispose { job.cancel() }
}
val currentDestination by navController.currentDestinationAsState()
val lockDrawerHandle = remember { mutableStateListOf<Int>() }
@@ -317,8 +339,9 @@ class MainActivity : EhActivity() {
LocalNavDrawerState provides navDrawerState,
LocalSideSheetState provides sideSheetState,
LocalDrawerLockHandle provides lockDrawerHandle,
LocalSnackbarHostState provides snackbarState,
) {
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) {
Scaffold(snackbarHost = { SnackbarHost(snackbarState) { Snackbar2(it) } }) {
LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) {
ModalNavigationDrawer(
drawerContent = {
@@ -349,23 +372,10 @@ class MainActivity : EhActivity() {
},
modifier = Modifier.padding(horizontal = 12.dp),
icon = {
IconFix(imageVector = icon, contentDescription = null)
Icon(imageVector = icon, contentDescription = null)
},
)
}
NavigationDrawerItem(
label = {
Text(text = stringResource(id = R.string.settings))
},
selected = false,
onClick = {
closeDrawer { startActivity(Intent(applicationContext, ConfigureActivity::class.java)) }
},
modifier = Modifier.padding(horizontal = 12.dp),
icon = {
Icon(imageVector = Icons.Default.Settings, contentDescription = null)
},
)
}
}
},
@@ -390,11 +400,19 @@ class MainActivity : EhActivity() {
drawerState = sideSheetState,
gesturesEnabled = sheet != null && !drawerLocked,
) {
CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
val windowSizeClass = calculateWindowSizeClass(this)
CompositionLocalProvider(
LocalViewConfiguration provides viewConfiguration,
LocalWindowSizeClass provides windowSizeClass,
) {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = navItems[Settings.launchPage].first,
engine = rememberNavHostEngine(rootDefaultAnimations = ehNavAnim),
startRoute = if (Settings.needSignIn) {
if (EhCookieStore.hasSignedIn()) SelectSiteScreenDestination else SignInScreenDestination
} else {
StartDestination
},
engine = rememberNavHostEngine(rootDefaultAnimations = rememberEhNavAnim()),
navController = navController,
)
}
@@ -409,70 +427,75 @@ class MainActivity : EhActivity() {
if (intent.action != Intent.ACTION_MAIN) {
onNewIntent(intent)
}
checkDownloadLocation()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (!Settings.appLinkVerifyTip) {
try {
checkAppLinkVerify()
} catch (ignored: PackageManager.NameNotFoundException) {
}
}
private suspend fun DialogState.checkAppLinkVerify() {
if (isAtLeastS && !Settings.appLinkVerifyTip) {
val manager = getSystemService(DomainVerificationManager::class.java)
val packageName = packageName
val userState = manager.getDomainVerificationUserState(packageName) ?: return
val hasUnverified = userState.hostToStateMap.values.any { it == DOMAIN_STATE_NONE }
if (hasUnverified) {
var checked by mutableStateOf(false)
awaitPermissionOrCancel(
confirmText = R.string.open_settings,
title = R.string.app_link_not_verified_title,
onDismiss = {
if (checked) Settings.appLinkVerifyTip = true
},
) {
Column {
Text(
text = stringResource(id = R.string.app_link_not_verified_message),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(16.dp))
LabeledCheckbox(
modifier = Modifier.fillMaxWidth(),
checked = checked,
onCheckedChange = { checked = it },
label = stringResource(id = R.string.dont_show_again),
indication = null,
)
}
}
if (checked) {
Settings.appLinkVerifyTip = true
}
try {
val intent = Intent(
android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS,
Uri.parse("package:$packageName"),
)
startActivity(intent)
} catch (t: Throwable) {
val intent = Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName"),
)
startActivity(intent)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.S)
private fun checkAppLinkVerify() {
val manager = getSystemService(DomainVerificationManager::class.java)
val userState = manager.getDomainVerificationUserState(packageName) ?: return
val hasUnverified = userState.hostToStateMap.values.any { it == DOMAIN_STATE_NONE }
if (hasUnverified) {
BaseDialogBuilder(this)
.setTitle(R.string.app_link_not_verified_title)
.setMessage(R.string.app_link_not_verified_message)
.setPositiveButton(R.string.open_settings) { _: DialogInterface?, _: Int ->
try {
val intent = Intent(
android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS,
Uri.parse("package:$packageName"),
)
startActivity(intent)
} catch (t: Throwable) {
val intent = Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName"),
)
startActivity(intent)
}
}
.setNegativeButton(android.R.string.cancel, null)
.setNeutralButton(R.string.dont_show_again) { _: DialogInterface?, _: Int ->
Settings.appLinkVerifyTip = true
}
.show()
private suspend fun DialogState.checkDownloadLocation() {
val valid = withIOContext { downloadLocation.ensureDir() }
if (!valid) {
awaitPermissionOrCancel(
confirmText = R.string.get_it,
showCancelButton = false,
title = R.string.waring,
) {
Text(
text = stringResource(id = R.string.invalid_download_location),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
private fun checkDownloadLocation() {
val uniFile = downloadLocation
// null == uniFile for first start
if (uniFile.ensureDir()) {
return
}
BaseDialogBuilder(this)
.setTitle(R.string.waring)
.setMessage(R.string.invalid_download_location)
.setPositiveButton(R.string.get_it, null)
.show()
}
override fun onResume() {
if (Settings.needSignIn) {
startActivity(Intent(this, ConfigureActivity::class.java))
}
super.onResume()
}
fun showTip(@StringRes id: Int, useToast: Boolean = false) {
showTip(getString(id), useToast)
}
@@ -492,6 +515,7 @@ class MainActivity : EhActivity() {
val LocalNavDrawerState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalNavDrawerState not present!") }
val LocalSideSheetState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalSideSheetState not present!") }
val LocalDrawerLockHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalSideSheetState not present!") }
val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> { error("CompositionLocal LocalSnackbarHostState not present!") }
@Composable
fun LockDrawer(value: Boolean) {
@@ -2,43 +2,58 @@ package com.hippo.ehviewer.ui
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.ComposeView
import com.google.accompanist.themeadapter.material3.Mdc3Theme
import com.hippo.ehviewer.ui.theme.EhTheme
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.ProvideVectorPainterCache
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
// To make sure compose theme and legacy view theme are in sync, we only use Mdc3Theme
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 {
Mdc3Theme {
EhTheme {
val dialogState = remember { DialogState() }
dialogState.Intercept()
CompositionLocalProvider(LocalDialogState provides dialogState) {
CompositionLocalProvider(
LocalDialogState provides dialogState,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
content()
}
}
}
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
Mdc3Theme {
val dialogState = remember { DialogState() }
dialogState.Intercept()
CompositionLocalProvider(LocalDialogState provides dialogState) {
content()
EhTheme {
ProvideVectorPainterCache {
val dialogState = remember { DialogState() }
dialogState.Intercept()
CompositionLocalProvider(
LocalDialogState provides dialogState,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
content()
}
}
}
}
val ehNavAnim by lazy {
RootNavGraphDefaultAnimations(
enterTransition = { fadeIn(animationSpec = tween(500)) },
exitTransition = { fadeOut(animationSpec = tween(500)) },
)
@Composable
fun rememberEhNavAnim(): RootNavGraphDefaultAnimations {
val slideDistance = rememberSlideDistance()
return remember {
RootNavGraphDefaultAnimations(
enterTransition = { materialSharedAxisXIn(true, slideDistance) },
exitTransition = { materialSharedAxisXOut(true, slideDistance) },
popEnterTransition = { materialSharedAxisXIn(false, slideDistance) },
popExitTransition = { materialSharedAxisXOut(false, slideDistance) },
)
}
}
@@ -1,18 +0,0 @@
package com.hippo.ehviewer.ui
import android.os.Bundle
import com.hippo.ehviewer.ui.webview.WebViewScreen
class WebViewActivity : EhActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val url = intent.extras?.getString(KEY_URL) ?: return
setMD3Content {
WebViewScreen(url = url) { finish() }
}
}
companion object {
const val KEY_URL = "url"
}
}
@@ -4,7 +4,8 @@ import android.graphics.drawable.Animatable
import android.graphics.drawable.DrawableWrapper
import android.text.Html
import android.widget.TextView
import coil.imageLoader
import coil3.imageLoader
import coil3.request.crossfade
import com.hippo.ehviewer.ktbuilder.imageRequest
class CoilImageGetter(
@@ -16,7 +17,7 @@ class CoilImageGetter(
data(source)
crossfade(false)
target { drawable ->
setDrawable(drawable)
setDrawable(drawable.asDrawable(textView.resources))
if (drawable is Animatable) drawable.start()
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
textView.text = textView.text
@@ -55,8 +55,9 @@ import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.destinations.FinishDestination
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.destinations.SelectSiteScreenDestination
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.util.ExceptionUtils
@@ -68,7 +69,6 @@ import eu.kanade.tachiyomi.util.lang.withUIContext
import java.util.Locale
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import okhttp3.Cookie
@Destination
@Composable
@@ -93,15 +93,13 @@ fun CookieSignInScene(navigator: DestinationsNavigator) {
val noCookies = stringResource(R.string.from_clipboard_error)
fun newCookie(name: String, value: String, domain: String) = Cookie.Builder().name(name).value(value).domain(domain).expiresAt(Long.MAX_VALUE).build()
fun storeCookie(id: String, hash: String, igneous: String) {
EhUtils.signOut()
EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_E))
EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_EX))
EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_E))
EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_EX))
if (igneous.isNotBlank()) EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IGNEOUS, igneous, EhUrl.DOMAIN_EX))
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_E)
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_EX)
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_E)
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_EX)
if (igneous.isNotBlank()) EhCookieStore.addCookie(EhCookieStore.KEY_IGNEOUS, igneous, EhUrl.DOMAIN_EX)
}
fun login() {
@@ -126,7 +124,7 @@ fun CookieSignInScene(navigator: DestinationsNavigator) {
EhEngine.getProfile()
}.onSuccess {
val canEx = withNonCancellableContext { postLogin() }
withUIContext { navigator.navigate(if (canEx) SelectSiteScreenDestination else FinishDestination) }
withUIContext { navigator.popNavigate(if (canEx) SelectSiteScreenDestination else StartDestination) }
}.onFailure {
EhCookieStore.signOut()
dialogState.awaitPermissionOrCancel(
@@ -1,7 +1,5 @@
package com.hippo.ehviewer.ui.login
import android.app.Activity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -22,14 +20,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.ui.destinations.FinishDestination
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.screen.popNavigate
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -37,11 +35,6 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@Composable
fun SelectSiteScreen(navigator: DestinationsNavigator) {
var siteEx by remember { mutableStateOf(true) }
val context = LocalContext.current
BackHandler {
(context as Activity).moveTaskToBack(true)
}
Column(
modifier = Modifier.systemBarsPadding().padding(horizontal = dimensionResource(R.dimen.keyline_margin)).padding(top = dimensionResource(R.dimen.keyline_margin)).fillMaxSize(),
@@ -71,7 +64,7 @@ fun SelectSiteScreen(navigator: DestinationsNavigator) {
// Gallery site was set to ex in sad panda check
if (!siteEx) Settings.gallerySite = EhUrl.SITE_E
Settings.needSignIn = false
navigator.navigate(FinishDestination)
navigator.popNavigate(StartDestination)
},
modifier = Modifier.fillMaxWidth(),
) {
@@ -1,7 +1,5 @@
package com.hippo.ehviewer.ui.login
import android.app.Activity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -61,21 +59,24 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.destinations.CookieSignInSceneDestination
import com.hippo.ehviewer.ui.destinations.FinishDestination
import com.hippo.ehviewer.ui.destinations.SelectSiteScreenDestination
import com.hippo.ehviewer.ui.destinations.WebViewSignInScreenDestination
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.util.ExceptionUtils
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.Job
@RootNavGraph(start = true)
@Destination
@Composable
fun SignInScreen(navigator: DestinationsNavigator) {
@@ -92,10 +93,6 @@ fun SignInScreen(navigator: DestinationsNavigator) {
var signInJob by remember { mutableStateOf<Job?>(null) }
val dialogState = LocalDialogState.current
BackHandler {
(context as Activity).moveTaskToBack(true)
}
fun signIn() {
if (signInJob?.isActive == true) return
if (username.isEmpty()) {
@@ -137,7 +134,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}
}.onSuccess {
val canEx = withNonCancellableContext { postLogin() }
withUIContext { navigator.navigate(if (canEx) SelectSiteScreenDestination else FinishDestination) }
withUIContext { navigator.popNavigate(if (canEx) SelectSiteScreenDestination else StartDestination) }
}
}
}
@@ -251,7 +248,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
onClick = {
Settings.needSignIn = false
Settings.gallerySite = EhUrl.SITE_E
navigator.navigate(FinishDestination)
navigator.popNavigate(StartDestination)
},
) {
Text(
@@ -259,7 +256,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
withStyle(
style = SpanStyle(textDecoration = TextDecoration.Underline),
) {
append(stringResource(id = R.string.tourist_mode))
append(stringResource(id = R.string.guest_mode))
}
},
)
@@ -347,7 +344,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
onClick = {
Settings.needSignIn = false
Settings.gallerySite = EhUrl.SITE_E
navigator.navigate(FinishDestination)
navigator.popNavigate(StartDestination)
},
modifier = Modifier.padding(horizontal = 4.dp),
) {
@@ -356,7 +353,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
withStyle(
style = SpanStyle(textDecoration = TextDecoration.Underline),
) {
append(stringResource(id = R.string.tourist_mode))
append(stringResource(id = R.string.guest_mode))
}
},
)
@@ -14,15 +14,16 @@ import com.google.accompanist.web.rememberWebViewState
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.destinations.FinishDestination
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.destinations.SelectSiteScreenDestination
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.util.setDefaultSettings
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import io.ktor.http.Url
@Destination
@Composable
@@ -39,7 +40,7 @@ fun WebViewSignInScreen(navigator: DestinationsNavigator) {
}
var getId = false
var getHash = false
EhCookieStore.loadForRequest(EhUrl.HOST_E.toHttpUrl()).forEach {
EhCookieStore.load(Url(EhUrl.HOST_E)).forEach {
if (EhCookieStore.KEY_IPB_MEMBER_ID == it.name) {
getId = true
} else if (EhCookieStore.KEY_IPB_PASS_HASH == it.name) {
@@ -50,7 +51,7 @@ fun WebViewSignInScreen(navigator: DestinationsNavigator) {
present = true
coroutineScope.launchIO {
val canEx = withNonCancellableContext { postLogin() }
withUIContext { navigator.navigate(if (canEx) SelectSiteScreenDestination else FinishDestination) }
withUIContext { navigator.popNavigate(if (canEx) SelectSiteScreenDestination else StartDestination) }
}
}
}
@@ -14,7 +14,7 @@ object AdvanceTable {
@Parcelize
data class AdvancedSearchOption(
val advanceSearch: Int = 0,
val minRating: Int = -1,
val fromPage: Int = -1,
val toPage: Int = -1,
val minRating: Int = 0,
val fromPage: Int = 0,
val toPage: Int = 0,
) : Parcelable
@@ -1,136 +0,0 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.util.toIntOrDefault
@Composable
fun SearchAdvanced(
modifier: Modifier = Modifier,
state: AdvancedSearchOption,
onStateChanged: (AdvancedSearchOption) -> Unit,
) = FlowRow(modifier = modifier, horizontalArrangement = Arrangement.Center, maxItemsInEachRow = 2) {
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
val adv = state.advanceSearch
fun checked(bit: Int) = adv and bit != 0
fun AdvancedSearchOption.inv(bit: Int) = onStateChanged(copy(advanceSearch = advanceSearch xor bit))
LabeledCheckbox(
checked = checked(AdvanceTable.SH),
onCheckedChange = { state.inv(AdvanceTable.SH) },
label = stringResource(id = R.string.search_sh),
)
LabeledCheckbox(
checked = checked(AdvanceTable.STO),
onCheckedChange = { state.inv(AdvanceTable.STO) },
label = stringResource(id = R.string.search_sto),
)
val minRatingItems = stringArrayResource(id = R.array.search_min_rating)
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier.align(Alignment.CenterVertically).padding(12.dp),
) {
val softwareKeyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
modifier = Modifier.menuAnchor(),
readOnly = true,
value = minRatingItems[if (state.minRating in 2..5) state.minRating - 1 else 0],
onValueChange = {},
label = { Text(stringResource(id = R.string.search_sr)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
SideEffect {
softwareKeyboardController?.hide()
}
minRatingItems.forEach { selectionOption ->
DropdownMenuItem(
text = { Text(selectionOption) },
onClick = {
expanded = false
val index = minRatingItems.indexOf(selectionOption)
onStateChanged(state.copy(minRating = if (index > 0) index + 1 else -1))
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
var enabled by rememberSaveable { mutableStateOf(false) }
LabeledCheckbox(
checked = enabled,
onCheckedChange = { enabled = it },
label = stringResource(id = R.string.search_sp),
)
OutlinedTextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
value = if (enabled && state.fromPage != -1) state.fromPage.toString() else "",
onValueChange = { onStateChanged(state.copy(fromPage = it.toIntOrDefault(-1))) },
modifier = Modifier.width(96.dp).padding(16.dp),
singleLine = true,
enabled = enabled,
)
Text(text = stringResource(id = R.string.search_sp_to))
OutlinedTextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
value = if (enabled && state.toPage != -1) state.toPage.toString() else "",
onValueChange = { onStateChanged(state.copy(toPage = it.toIntOrDefault(-1))) },
modifier = Modifier.width(96.dp).padding(16.dp),
singleLine = true,
enabled = enabled,
)
Text(text = stringResource(id = R.string.search_sp_suffix))
}
Text(text = stringResource(id = R.string.search_sf), modifier = Modifier.align(Alignment.CenterVertically))
FlowRow {
LabeledCheckbox(
checked = checked(AdvanceTable.SFL),
onCheckedChange = { state.inv(AdvanceTable.SFL) },
label = stringResource(id = R.string.search_sfl),
)
LabeledCheckbox(
checked = checked(AdvanceTable.SFU),
onCheckedChange = { state.inv(AdvanceTable.SFU) },
label = stringResource(id = R.string.search_sfu),
)
LabeledCheckbox(
checked = checked(AdvanceTable.SFT),
onCheckedChange = { state.inv(AdvanceTable.SFT) },
label = stringResource(id = R.string.search_sft),
)
}
}
}
@@ -19,106 +19,27 @@ import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import com.hippo.ehviewer.EhApplication
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.coil.read
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.downloadDir
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ui.tools.CropDefaults
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.DragHandle
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import sh.calvin.reorderable.ReorderableItemScope
@Composable
private fun AsyncThumb(
info: DownloadInfo,
modifier: Modifier = Modifier,
) {
val path = remember(info.gid) { info.downloadDir?.subFile(".thumb") }
var contentScale by remember(info.gid) { mutableStateOf(ContentScale.Fit) }
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
var localReq by remember(info.gid) {
path?.takeIf { it.isFile }?.uri?.let {
context.imageRequest {
data(it.toString())
memoryCacheKey(info.thumbKey)
}
}.let { mutableStateOf(it) }
}
AsyncImage(
model = localReq ?: requestOf(info),
contentDescription = null,
modifier = modifier,
onState = { state ->
if (state is AsyncImagePainter.State.Success) {
state.result.drawable.run {
if (CropDefaults.shouldCrop(intrinsicWidth, intrinsicHeight)) {
contentScale = ContentScale.Crop
}
}
path?.let {
coroutineScope.launch {
runCatching {
if (!path.exists() && path.ensureFile()) {
val key = info.thumbKey!!
EhApplication.imageCache.read(key) {
UniFile.fromFile(data.toFile())!!.openFileDescriptor("r").use { src ->
path.openFileDescriptor("w").use { dst ->
src sendTo dst
}
}
}
}
}.onFailure {
it.printStackTrace()
}
}
}
}
if (state is AsyncImagePainter.State.Error) {
path?.let {
coroutineScope.launch {
if (path.exists()) {
path.delete()
localReq = null
}
}
}
}
},
contentScale = contentScale,
)
}
@Composable
fun ReorderableItemScope.DownloadCard(
onClick: () -> Unit,
@@ -126,6 +47,8 @@ fun ReorderableItemScope.DownloadCard(
onLongClick: () -> Unit,
onStart: () -> Unit,
onStop: () -> Unit,
onDragStarted: () -> Unit,
onDragStopped: () -> Unit,
info: DownloadInfo,
modifier: Modifier = Modifier,
) {
@@ -136,8 +59,8 @@ fun ReorderableItemScope.DownloadCard(
) {
Row {
Card(onClick = onThumbClick) {
AsyncThumb(
info = info,
EhAsyncCropThumb(
key = info,
modifier = Modifier.aspectRatio(DEFAULT_ASPECT).fillMaxSize(),
)
}
@@ -242,7 +165,7 @@ fun ReorderableItemScope.DownloadCard(
bottom.linkTo(parent.bottom)
},
) {
DragHandle()
DragHandle(onDragStarted = onDragStarted, onDragStopped = onDragStopped)
if (downloadState == DownloadInfo.STATE_WAIT || downloadState == DownloadInfo.STATE_DOWNLOAD) {
IconButton(onClick = onStop) {
Icon(imageVector = Icons.Default.Pause, contentDescription = null)
@@ -9,12 +9,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.request.ImageRequest
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.request.ImageRequest
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ui.tools.CropDefaults
import com.hippo.ehviewer.ui.tools.shouldCrop
@Composable
@ReadOnlyComposable
@@ -46,10 +46,8 @@ fun EhAsyncCropThumb(
modifier = modifier,
onState = {
if (it is AsyncImagePainter.State.Success) {
it.result.drawable.run {
if (CropDefaults.shouldCrop(intrinsicWidth, intrinsicHeight)) {
contentScale = ContentScale.Crop
}
if (it.result.image.shouldCrop) {
contentScale = ContentScale.Crop
}
}
},
@@ -38,8 +38,8 @@ import androidx.compose.ui.util.lerp
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.onEachLatest
import moe.tarsin.coroutines.runSuspendCatching
enum class FabLayoutValue {
@@ -128,18 +128,9 @@ fun FabLayout(
}
val builder by rememberUpdatedState(fabBuilder)
fun buildFab(builder: FabBuilder.() -> Unit) = buildList {
builder { icon, action ->
add(icon to action)
}
}
val secondaryFab by remember {
snapshotFlow { builder }.map { one ->
state.waitCollapse()
buildFab(one)
}
}.collectAsState(remember { buildFab(fabBuilder) })
snapshotFlow { buildFab(builder) }.onEachLatest { state.waitCollapse() }
}.collectAsState(emptyList())
val coroutineScope = rememberCoroutineScope()
if (updatedExpanded && autoCancel) {
Spacer(
@@ -219,4 +210,10 @@ fun FabLayout(
}
}
private fun buildFab(builder: FabBuilder.() -> Unit) = buildList {
builder { icon, action ->
add(icon to action)
}
}
const val FAB_ANIMATE_TIME = 300
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.NoAccounts
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
@@ -35,7 +36,6 @@ import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.SadAndroid
import com.hippo.ehviewer.ui.tools.IconFix
@Composable
fun GalleryDetailHeaderInfoCard(
@@ -131,7 +131,7 @@ fun GalleryDetailHeaderCard(
label = { Text(text = categoryText, maxLines = 1) },
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)),
leadingIcon = {
IconFix(
Icon(
imageVector = Icons.AutoMirrored.Default.Label,
contentDescription = null,
)
@@ -143,7 +143,7 @@ fun GalleryDetailHeaderCard(
label = { Text(text = uploaderText, maxLines = 1) },
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)),
leadingIcon = {
IconFix(
Icon(
imageVector = Icons.Default.NoAccounts,
contentDescription = null,
modifier = Modifier.clickable(onClick = onBlockUploaderIconClick),
@@ -160,7 +160,7 @@ fun GalleryDetailErrorTip(error: String, onClick: () -> Unit) = Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
IconFix(
Icon(
imageVector = EhIcons.Big.Default.SadAndroid,
contentDescription = null,
modifier = Modifier.clickable(onClick = onClick),
@@ -30,11 +30,14 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.ConstraintSet
import arrow.core.Tuple7
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryInfo
@@ -45,6 +48,41 @@ import com.hippo.ehviewer.ui.tools.ElevatedCard
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
import com.hippo.ehviewer.util.FavouriteStatusRouter
private val ids = Tuple7(1, 2, 3, 4, 5, 6, 7)
private val constraintSet = ConstraintSet {
val (titleRef, uploaderRef, ratingRef, categoryRef, postedRef, favRef, iconsRef) =
createRefsFor(1, 2, 3, 4, 5, 6, 7)
constrain(titleRef) {
top.linkTo(parent.top)
start.linkTo(parent.start)
}
constrain(uploaderRef) {
start.linkTo(parent.start)
bottom.linkTo(ratingRef.top)
}
constrain(ratingRef) {
start.linkTo(parent.start)
bottom.linkTo(categoryRef.top)
}
constrain(categoryRef) {
start.linkTo(parent.start)
bottom.linkTo(parent.bottom)
}
constrain(iconsRef) {
end.linkTo(parent.end)
bottom.linkTo(postedRef.top)
}
constrain(favRef) {
end.linkTo(parent.end)
bottom.linkTo(iconsRef.top)
}
constrain(postedRef) {
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
}
}
@Composable
fun GalleryInfoListItem(
onClick: () -> Unit,
@@ -66,15 +104,12 @@ fun GalleryInfoListItem(
modifier = Modifier.aspectRatio(DEFAULT_ASPECT).fillMaxSize(),
)
}
ConstraintLayout(modifier = Modifier.padding(8.dp, 4.dp).fillMaxSize()) {
val (titleRef, uploaderRef, ratingRef, categoryRef, postedRef, favRef, iconsRef) = createRefs()
ConstraintLayout(modifier = Modifier.padding(8.dp, 4.dp).fillMaxSize(), constraintSet = constraintSet) {
val (titleRef, uploaderRef, ratingRef, categoryRef, postedRef, favRef, iconsRef) = ids
Text(
text = EhUtils.getSuitableTitle(info),
maxLines = 2,
modifier = Modifier.constrainAs(titleRef) {
top.linkTo(parent.top)
start.linkTo(parent.start)
}.fillMaxWidth(),
modifier = Modifier.layoutId(titleRef).fillMaxWidth(),
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
@@ -82,39 +117,27 @@ fun GalleryInfoListItem(
info.uploader?.let {
Text(
text = it,
modifier = Modifier.constrainAs(uploaderRef) {
start.linkTo(parent.start)
bottom.linkTo(ratingRef.top)
}.alpha(if (info.disowned) 0.5f else 1f),
modifier = Modifier.layoutId(uploaderRef).alpha(if (info.disowned) 0.5f else 1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
GalleryListCardRating(
rating = info.rating,
modifier = Modifier.constrainAs(ratingRef) {
start.linkTo(parent.start)
bottom.linkTo(categoryRef.top)
},
modifier = Modifier.layoutId(ratingRef),
)
val categoryColor = EhUtils.getCategoryColor(info.category)
val categoryText = EhUtils.getCategory(info.category).uppercase()
Text(
text = categoryText,
modifier = Modifier.constrainAs(categoryRef) {
start.linkTo(parent.start)
bottom.linkTo(parent.bottom)
}.clip(ShapeDefaults.Small).background(categoryColor).padding(vertical = 2.dp, horizontal = 8.dp),
modifier = Modifier.layoutId(categoryRef).clip(ShapeDefaults.Small).background(categoryColor).padding(vertical = 2.dp, horizontal = 8.dp),
color = if (Settings.harmonizeCategoryColor) Color.Unspecified else EhUtils.categoryTextColor,
textAlign = TextAlign.Center,
)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.constrainAs(iconsRef) {
end.linkTo(parent.end)
bottom.linkTo(postedRef.top)
},
modifier = Modifier.layoutId(iconsRef),
) {
val download by DownloadManager.collectContainDownloadInfo(info.gid)
if (download) {
@@ -132,10 +155,7 @@ fun GalleryInfoListItem(
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.constrainAs(favRef) {
end.linkTo(parent.end)
bottom.linkTo(iconsRef.top)
},
modifier = Modifier.layoutId(favRef),
) {
if (isInFavScene) {
Text(
@@ -156,10 +176,7 @@ fun GalleryInfoListItem(
}
Text(
text = info.posted.orEmpty(),
modifier = Modifier.constrainAs(postedRef) {
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
},
modifier = Modifier.layoutId(postedRef),
)
}
}
@@ -1,6 +1,5 @@
package com.hippo.ehviewer.ui.main
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -39,7 +38,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
@@ -51,17 +49,13 @@ import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.exception.CloudflareBypassException
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.SadAndroid
import com.hippo.ehviewer.ui.WebViewActivity
import com.hippo.ehviewer.ui.screen.collectDetailSizeAsState
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.util.ExceptionUtils
import kotlinx.coroutines.CoroutineScope
@@ -77,10 +71,10 @@ fun GalleryList(
thumbListState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
thumbItemContent: @Composable (LazyStaggeredGridItemScope.(BaseGalleryInfo) -> Unit),
refreshState: PullToRefreshState,
scrollToTopOnRefresh: Boolean = true,
onRefresh: suspend CoroutineScope.() -> Unit,
onLoading: suspend CoroutineScope.() -> Unit,
) {
val context = LocalContext.current
val layoutDirection = LocalLayoutDirection.current
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v)
@@ -174,9 +168,8 @@ fun GalleryList(
}
}
val dialogState = LocalDialogState.current
when (val state = data.loadState.refresh) {
is LoadState.Loading -> if (!refreshState.isRefreshing) {
is LoadState.Loading -> if (!refreshState.isRefreshing && scrollToTopOnRefresh) {
LaunchedEffect(Unit) {
onLoading()
}
@@ -199,18 +192,6 @@ fun GalleryList(
is LoadState.Error -> {
Surface {
LaunchedEffect(state) {
if (state.error.cause is CloudflareBypassException) {
dialogState.awaitPermissionOrCancel(title = R.string.cloudflare_bypass_failed) {
Text(text = stringResource(id = R.string.open_in_webview))
}
context.startActivity(
Intent(context, WebViewActivity::class.java).apply {
putExtra(WebViewActivity.KEY_URL, EhUrl.host)
},
)
}
}
ErrorTip(
modifier = Modifier.widthIn(max = 228.dp)
.clip(ShapeDefaults.Small).clickable { data.retry() },
@@ -1,6 +1,5 @@
package com.hippo.ehviewer.ui.main
import android.graphics.drawable.BitmapDrawable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
@@ -21,14 +20,15 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.request.ImageRequest
import coil3.BitmapImage
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.request.ImageRequest
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ui.tools.CropDefaults
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.shouldCrop
@Composable
@ReadOnlyComposable
@@ -44,6 +44,7 @@ fun EhAsyncPreview(
model: GalleryPreview,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var contentScale by remember(model.imageKey) { mutableStateOf(ContentScale.Fit) }
AsyncImage(
model = requestOf(model),
@@ -54,7 +55,7 @@ fun EhAsyncPreview(
if (it is AsyncImagePainter.State.Success && this is NormalGalleryPreview) {
it.copy(
painter = BitmapPainter(
(it.result.drawable as BitmapDrawable).bitmap.asImageBitmap(),
(it.result.image as BitmapImage).bitmap.asImageBitmap(),
IntOffset(offsetX, 0),
IntSize(clipWidth - 1, clipHeight - 1),
),
@@ -68,14 +69,12 @@ fun EhAsyncPreview(
if (it is AsyncImagePainter.State.Success) {
model.run {
if (this is NormalGalleryPreview) {
if (CropDefaults.shouldCrop(clipWidth, clipHeight)) {
if (shouldCrop) {
contentScale = ContentScale.Crop
}
} else {
it.result.drawable.run {
if (CropDefaults.shouldCrop(intrinsicWidth, intrinsicHeight)) {
contentScale = ContentScale.Crop
}
if (it.result.image.shouldCrop) {
contentScale = ContentScale.Crop
}
}
}
@@ -3,8 +3,10 @@ package com.hippo.ehviewer.ui.main
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -84,7 +86,7 @@ private fun BaseRoundText(
) {
Text(
text = text,
modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp),
modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp).width(IntrinsicSize.Max),
color = MaterialTheme.colorScheme.onSurface.let { if (weak) it.copy(0.5F) else it },
style = MaterialTheme.typography.labelLarge.includeFontPadding,
)
@@ -17,7 +17,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import com.hippo.ehviewer.R
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
@@ -1,111 +0,0 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
private val categoryTable = arrayOf(
EhUtils.DOUJINSHI to R.string.doujinshi,
EhUtils.MANGA to R.string.manga,
EhUtils.ARTIST_CG to R.string.artist_cg,
EhUtils.GAME_CG to R.string.game_cg,
EhUtils.WESTERN to R.string.western,
EhUtils.NON_H to R.string.non_h,
EhUtils.IMAGE_SET to R.string.image_set,
EhUtils.COSPLAY to R.string.cosplay,
EhUtils.ASIAN_PORN to R.string.asian_porn,
EhUtils.MISC to R.string.misc,
)
private val modeTable = arrayOf(
1 to R.string.search_normal_search,
2 to R.string.search_subscription_search,
3 to R.string.search_specify_uploader,
4 to R.string.search_specify_tag,
)
@Composable
fun NormalSearch(
modifier: Modifier = Modifier,
category: Int,
onCategoryChanged: (Int) -> Unit,
searchMode: Int,
onSearchModeChanged: (Int) -> Unit,
isAdvanced: Boolean,
onAdvancedChanged: (Boolean) -> Unit,
showInfo: () -> Unit,
maxItemsInEachRow: Int,
) {
FlowRow(
modifier = modifier.wrapContentHeight(align = Alignment.Top),
maxItemsInEachRow = maxItemsInEachRow,
) {
categoryTable.forEach {
val selected = category and it.first != 0
FilterChip(
selected = selected,
onClick = { onCategoryChanged(if (selected) category xor it.first else category or it.first) },
label = { Text(text = stringResource(id = it.second)) },
leadingIcon = {
AnimatedVisibility(selected) {
Icon(imageVector = Icons.Default.Check, contentDescription = null)
}
},
modifier = Modifier.padding(horizontal = 4.dp).widthIn(min = 142.dp)
.align(alignment = Alignment.CenterVertically).weight(1f),
)
}
}
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
FlowRow(
horizontalArrangement = Arrangement.SpaceBetween,
maxItemsInEachRow = maxItemsInEachRow,
) {
modeTable.forEach {
Row(
modifier = Modifier.clip(MaterialTheme.shapes.small)
.clickable { onSearchModeChanged(it.first) }.padding(end = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = it.first == searchMode,
onClick = { onSearchModeChanged(it.first) },
)
Text(text = stringResource(id = it.second))
}
}
IconButton(onClick = showInfo) {
Icon(imageVector = Icons.AutoMirrored.Default.Help, contentDescription = null)
}
LabeledCheckbox(
checked = isAdvanced,
onCheckedChange = onAdvancedChanged,
label = stringResource(id = R.string.search_enable_advance),
)
}
}
}
@@ -0,0 +1,239 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.util.toIntOrDefault
import kotlinx.coroutines.launch
private val categoryTable = arrayOf(
EhUtils.DOUJINSHI to R.string.doujinshi,
EhUtils.MANGA to R.string.manga,
EhUtils.ARTIST_CG to R.string.artist_cg,
EhUtils.GAME_CG to R.string.game_cg,
EhUtils.WESTERN to R.string.western,
EhUtils.NON_H to R.string.non_h,
EhUtils.IMAGE_SET to R.string.image_set,
EhUtils.COSPLAY to R.string.cosplay,
EhUtils.ASIAN_PORN to R.string.asian_porn,
EhUtils.MISC to R.string.misc,
)
@Composable
fun SearchFilter(
modifier: Modifier = Modifier,
category: Int,
onCategoryChanged: (Int) -> Unit,
advancedOption: AdvancedSearchOption,
onAdvancedOptionChanged: (AdvancedSearchOption) -> Unit,
) = Column(modifier) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val dialogState = LocalDialogState.current
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
categoryTable.forEach {
val selected = category and it.first != 0
FilterChip(
selected = selected,
onClick = { onCategoryChanged(category xor it.first) },
label = { Text(text = stringResource(id = it.second)) },
)
}
}
var languageFilter by Settings.languageFilter.asMutableState()
val languages = remember {
GalleryInfo.S_LANG_TAGS.map { tag ->
tag.substringAfter(':').let {
if (EhTagDatabase.initialized && EhTagDatabase.isTranslatable(context)) {
EhTagDatabase.getTranslation("l", it) ?: it
} else {
it
}
}
}.toTypedArray()
}
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val any = stringResource(id = R.string.any)
val languageStr = stringResource(id = R.string.key_language)
FilterChip(
selected = languageFilter != -1,
onClick = {
scope.launch {
languageFilter = dialogState.showSingleChoice(any, *languages, selected = languageFilter + 1) - 1
}
},
label = {
Text(text = if (languageFilter == -1) languageStr else languages[languageFilter])
},
trailingIcon = {
Icon(
imageVector = if (languageFilter == -1) Icons.Outlined.FilterAlt else Icons.Default.FilterAlt,
contentDescription = null,
)
},
)
val pageErr1 = stringResource(R.string.search_sp_err1)
val pageErr2 = stringResource(R.string.search_sp_err2)
val pages = stringResource(id = R.string.key_pages)
val pagesText = remember(advancedOption.fromPage, advancedOption.toPage) {
advancedOption.run {
val hasFrom = fromPage > 0
val hasTo = toPage > 0
if (hasFrom && hasTo) {
"$fromPage - $toPage P"
} else if (hasFrom) {
"$fromPage+ P"
} else if (hasTo) {
"$toPage- P"
} else {
pages
}
}
}
FilterChip(
selected = advancedOption.fromPage != 0 || advancedOption.toPage != 0,
onClick = {
scope.launch {
val (from, to) = dialogState.awaitResult(
initial = advancedOption.fromPage to advancedOption.toPage,
title = R.string.key_pages,
invalidator = { (from, to) ->
if (to != 0 && to < 10) {
pageErr1
} else if (from != 0 && to != 0 && to - from < 20) {
pageErr2
} else {
null
}
},
) { error ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
value = expectedValue.first.takeIf { it > 0 }?.toString().orEmpty(),
onValueChange = {
expectedValue =
expectedValue.copy(first = it.toIntOrDefault(0).coerceAtLeast(0))
},
modifier = Modifier.width(112.dp).padding(16.dp),
singleLine = true,
isError = error != null,
)
Text(text = stringResource(id = R.string.search_sp_to))
OutlinedTextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
value = expectedValue.second.takeIf { it > 0 }?.toString().orEmpty(),
onValueChange = {
expectedValue =
expectedValue.copy(second = it.toIntOrDefault(0).coerceAtLeast(0))
},
modifier = Modifier.width(112.dp).padding(16.dp),
singleLine = true,
isError = error != null,
)
Text(text = stringResource(id = R.string.search_sp_suffix))
}
if (error != null) {
ListItem(
headlineContent = { Text(text = error) },
leadingContent = {
Icon(imageVector = Icons.Default.Info, contentDescription = null)
},
colors = ListItemDefaults.colors(
headlineColor = MaterialTheme.colorScheme.error,
leadingIconColor = MaterialTheme.colorScheme.error,
),
)
}
}
}
onAdvancedOptionChanged(advancedOption.copy(fromPage = from, toPage = to))
}
},
label = { Text(text = pagesText) },
)
val minRatingItems = stringArrayResource(id = R.array.search_min_rating)
val minRatingStr = stringResource(id = R.string.search_sr)
val minRatingIndex = (advancedOption.minRating - 1).coerceAtLeast(0)
FilterChip(
selected = advancedOption.minRating != 0,
onClick = {
scope.launch {
val selected = dialogState.showSingleChoice(*minRatingItems, selected = minRatingIndex)
onAdvancedOptionChanged(advancedOption.copy(minRating = if (selected == 0) 0 else selected + 1))
}
},
label = { Text(text = minRatingStr + minRatingItems[minRatingIndex]) },
)
fun checked(bit: Int) = advancedOption.advanceSearch and bit != 0
fun AdvancedSearchOption.inv(bit: Int) = onAdvancedOptionChanged(copy(advanceSearch = advanceSearch xor bit))
FilterChip(
selected = checked(AdvanceTable.SH),
onClick = { advancedOption.inv(AdvanceTable.SH) },
label = { Text(text = stringResource(id = R.string.search_sh)) },
)
FilterChip(
selected = checked(AdvanceTable.STO),
onClick = { advancedOption.inv(AdvanceTable.STO) },
label = { Text(text = stringResource(id = R.string.search_sto)) },
)
val disableFilter = stringResource(id = R.string.search_sf)
FilterChip(
selected = checked(AdvanceTable.SFL),
onClick = { advancedOption.inv(AdvanceTable.SFL) },
label = { Text(text = disableFilter + stringResource(id = R.string.search_sfl)) },
)
FilterChip(
selected = checked(AdvanceTable.SFU),
onClick = { advancedOption.inv(AdvanceTable.SFU) },
label = { Text(text = disableFilter + stringResource(id = R.string.search_sfu)) },
)
FilterChip(
selected = checked(AdvanceTable.SFT),
onClick = { advancedOption.inv(AdvanceTable.SFT) },
label = { Text(text = disableFilter + stringResource(id = R.string.search_sft)) },
)
}
}
@@ -18,7 +18,7 @@ import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text2.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
@@ -38,12 +38,13 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissValue
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberSwipeToDismissState
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
@@ -72,6 +73,7 @@ import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.DownloadManager.labelList
import com.hippo.ehviewer.download.DownloadService
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.Download
@@ -99,6 +101,7 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import kotlin.math.roundToInt
import kotlin.math.sign
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -110,8 +113,8 @@ import sh.calvin.reorderable.rememberReorderableLazyColumnState
fun DownloadsScreen(navigator: DestinationsNavigator) {
var label by Settings::recentDownloadLabel.observed
var keyword by rememberSaveable { mutableStateOf<String?>(null) }
var filterType by rememberSaveable { mutableStateOf(-1) }
var searchBarOffsetY by remember { mutableStateOf(0) }
var filterType by rememberSaveable { mutableIntStateOf(-1) }
var searchBarOffsetY by remember(label) { mutableIntStateOf(0) }
val searchFieldState = rememberTextFieldState()
val checkedInfoMap = remember { mutableStateMapOf<Long, DownloadInfo>() }
val selectMode by rememberUpdatedState(checkedInfoMap.isNotEmpty())
@@ -135,9 +138,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
} ?: true
}
}
val labelsList = DownloadManager.labelList
val newLabel = stringResource(R.string.new_label_title)
val renameLabel = stringResource(R.string.rename_label_title)
val labelsStr = stringResource(R.string.download_labels)
val labelEmpty = stringResource(R.string.label_text_is_empty)
val defaultInvalid = stringResource(R.string.label_text_is_invalid)
@@ -178,7 +181,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
Settings.hasDefaultDownloadLabel = true
Settings.defaultDownloadLabel = null
}
DownloadManager.labelList.forEach { (label) ->
labelList.forEach { (label) ->
onSelect(label) {
Settings.hasDefaultDownloadLabel = true
Settings.defaultDownloadLabel = label
@@ -195,12 +198,12 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
val labelsListState = rememberLazyListState()
val reorderableLabelState = rememberReorderableLazyColumnState(labelsListState) { from, to ->
coroutineScope.launch {
DownloadManager.moveLabel(from.index - 2, to.index - 2)
}
val fromPosition = from.index - 2
val toPosition = to.index - 2
labelList.apply { add(toPosition, removeAt(fromPosition)) }
view.performHapticFeedback(draggingHapticFeedback)
}
var fromIndex by remember { mutableIntStateOf(-1) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = labelsListState,
@@ -234,10 +237,10 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
},
)
}
items(labelsList, key = { it.label }) { (item) ->
val dismissState = rememberSwipeToDismissState(
itemsIndexed(labelList, key = { _, item -> item.label }) { index, (item) ->
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
if (it == SwipeToDismissValue.EndToStart) {
if (it == SwipeToDismissBoxValue.EndToStart) {
coroutineScope.launch {
DownloadManager.deleteLabel(item)
label = ""
@@ -276,7 +279,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
IconButton(
onClick = {
coroutineScope.launch {
val new = dialogState.awaitInputText(title = newLabel, hint = labelsStr) { text ->
val new = dialogState.awaitInputText(initial = item, title = renameLabel, hint = labelsStr) { text ->
when {
text.isBlank() -> labelEmpty
text == defaultName -> defaultInvalid
@@ -291,7 +294,24 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
) {
Icon(imageVector = Icons.Default.Edit, contentDescription = null)
}
DragHandle()
DragHandle(
onDragStarted = {
fromIndex = index
},
onDragStopped = {
if (fromIndex != -1) {
if (fromIndex != index) {
val range = if (fromIndex < index) fromIndex..index else index..fromIndex
val toUpdate = labelList.slice(range)
toUpdate.zip(range).forEach { it.first.position = it.second }
coroutineScope.launchIO {
EhDB.updateDownloadLabel(toUpdate)
}
}
fromIndex = -1
}
},
)
}
},
)
@@ -327,7 +347,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
onClick = {
expanded = false
coroutineScope.launch {
filterType = dialogState.showSingleChoice(states, filterType + 1) - 1
filterType = dialogState.showSingleChoice(*states, selected = filterType + 1) - 1
}
},
)
@@ -394,13 +414,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
val reorderableState = rememberReorderableLazyColumnState(listState) { from, to ->
val fromIndex = from.index - 1
val toIndex = to.index - 1
val fromItem = list[fromIndex]
val toItem = list[toIndex]
val newList = DownloadManager.moveDownload(fromItem, toItem)
coroutineScope.launchIO {
EhDB.updateDownloadInfo(newList)
}
list.add(toIndex, list.removeAt(fromIndex))
list.apply { add(toIndex, removeAt(fromIndex)) }
view.performHapticFeedback(draggingHapticFeedback)
}
val searchBarConnection = remember {
@@ -412,6 +426,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
}
}
}
var fromIndex by remember { mutableIntStateOf(-1) }
FastScrollLazyColumn(
modifier = Modifier.nestedScroll(searchBarConnection).fillMaxSize(),
state = listState,
@@ -420,7 +435,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
) {
// Fix the first item's reorder animation
item {}
items(list, key = { it.gid }) { info ->
itemsIndexed(list, key = { _, item -> item.gid }) { index, info ->
ReorderableItem(reorderableState, key = info.gid) {
val checked = info.gid in checkedInfoMap
CheckableItem(checked = checked) {
@@ -433,6 +448,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
checkedInfoMap[info.gid] = info
}
} else {
coroutineScope.launchIO {
EhDB.putHistoryInfo(info.galleryInfo)
}
context.navToReader(info.galleryInfo)
}
},
@@ -453,6 +471,22 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
DownloadManager.stopDownload(info.gid)
}
},
onDragStarted = {
fromIndex = index
},
onDragStopped = {
if (fromIndex != -1) {
val direction = (index - fromIndex).sign
if (direction != 0) {
val toItem = list[index - direction]
coroutineScope.launchIO {
val toUpdate = DownloadManager.moveDownload(info, toItem)
EhDB.updateDownloadInfo(toUpdate)
}
}
fromIndex = -1
}
},
info = info,
modifier = Modifier.height(height),
)
@@ -31,11 +31,11 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.currentRecomposeScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -78,6 +78,7 @@ import com.hippo.ehviewer.ui.showDatePicker
import com.hippo.ehviewer.ui.startDownload
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.mapToLongArray
import com.ramcosta.composedestinations.annotation.Destination
@@ -96,7 +97,7 @@ import moe.tarsin.coroutines.runSuspendCatching
fun FavouritesScreen(navigator: DestinationsNavigator) {
// Meta State
var urlBuilder by rememberSaveable { mutableStateOf(FavListUrlBuilder(favCat = Settings.recentFavCat)) }
var searchBarOffsetY by remember { mutableStateOf(0) }
var searchBarOffsetY by remember { mutableIntStateOf(0) }
// Derived State
val keyword = remember(urlBuilder) { urlBuilder.keyword.orEmpty() }
@@ -122,10 +123,11 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
val data = rememberInVM(urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
if (urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
Pager(PagingConfig(20, jumpThreshold = 40)) {
if (keyword.isBlank()) {
val keywordNow = urlBuilder.keyword.orEmpty()
if (keywordNow.isBlank()) {
EhDB.localFavLazyList
} else {
EhDB.searchLocalFav(keyword)
EhDB.searchLocalFav(keywordNow)
}
}
} else {
@@ -152,8 +154,8 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
}.onFailure {
return@withIOContext LoadResult.Error(it)
}.getOrThrow()
Settings.favCat = r.catArray
Settings.favCount = r.countArray
Settings.favCat = r.catArray.toTypedArray()
Settings.favCount = r.countArray.toIntArray()
Settings.favCloudCount = r.countArray.sum()
urlBuilder.jumpTo = null
LoadResult.Page(r.galleryInfoList, r.prev, r.next)
@@ -220,7 +222,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
var expanded by remember { mutableStateOf(false) }
var hidden by remember { mutableStateOf(false) }
val checkedInfoMap = remember { mutableStateMapOf<Long, BaseGalleryInfo>() }
val selectMode by rememberUpdatedState(checkedInfoMap.isNotEmpty())
val selectMode = checkedInfoMap.isNotEmpty()
LockDrawer(selectMode)
SearchBarScreen(
@@ -307,6 +309,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
}
},
refreshState = refreshState,
scrollToTopOnRefresh = urlBuilder.favCat != FavListUrlBuilder.FAV_CAT_LOCAL,
onRefresh = { refresh() },
onLoading = { searchBarOffsetY = 0 },
)
@@ -320,8 +323,6 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
}.mapLatest { it }
}.collectAsState(hidden)
// Explicitly reallocate fabBuilder lambda to recompose secondary fab
val select = selectMode
FabLayout(
hidden = hideFab,
expanded = expanded || selectMode,
@@ -331,7 +332,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
},
autoCancel = !selectMode,
) {
if (!select) {
if (!selectMode) {
onClick(EhIcons.Default.GoTo) {
coroutineScope.launch {
val date = dialogState.showDatePicker()
@@ -380,22 +381,22 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
val dstCat = if (index == 0) FavListUrlBuilder.FAV_CAT_LOCAL else index - 1
val info = checkedInfoMap.run { toMap().values.also { clear() } }
if (srcCat != dstCat) {
if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
// Move from local to cloud
EhDB.removeLocalFavorites(info)
val galleryList = info.map { it.gid to it.token!! }
runSuspendCatching {
runSuspendCatching {
if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
// Move from local to cloud
val galleryList = info.map { it.gid to it.token!! }
EhEngine.addFavorites(galleryList, dstCat)
}
} else if (dstCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
// Move from cloud to local
EhDB.putLocalFavorites(info)
} else {
// Move from cloud to cloud
val gidArray = info.mapToLongArray(BaseGalleryInfo::gid)
runSuspendCatching {
EhDB.removeLocalFavorites(info)
} else if (dstCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
// Move from cloud to local
EhDB.putLocalFavorites(info)
} else {
// Move from cloud to cloud
val gidArray = info.mapToLongArray(BaseGalleryInfo::gid)
EhEngine.modifyFavorites(gidArray, srcCat, dstCat)
}
}.onFailure {
activity.showTip(ExceptionUtils.getReadableString(it))
}
data.refresh()
}
@@ -72,12 +72,13 @@ import androidx.compose.ui.unit.max
import androidx.compose.ui.util.lerp
import androidx.core.text.inSpans
import androidx.core.text.parseAsHtml
import com.hippo.ehviewer.EhApplication
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhFilter.remember
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
@@ -108,7 +109,6 @@ import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.replace
import moe.tarsin.coroutines.runSuspendCatching
import rikka.core.res.resolveColor
@@ -149,7 +149,7 @@ private val MinimumContentPaddingEditText = 88.dp
@Destination
@Composable
fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsNavigator) {
fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) {
LockDrawer(true)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val dialogState = LocalDialogState.current
@@ -158,6 +158,7 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
var commenting by rememberSaveable { mutableStateOf(false) }
val animationProgress by animateFloatMergePredictiveBackAsState(enable = commenting) { commenting = false }
val galleryDetail = remember { EhApplication.galleryDetailCache[gid]!! }
val userCommentBackField = remember { mutableStateOf(TextFieldValue()) }
var userComment by userCommentBackField
var commentId by remember { mutableLongStateOf(-1) }
@@ -191,13 +192,13 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
val focusManager = LocalFocusManager.current
suspend fun Context.sendComment() {
suspend fun sendComment() {
commenting = false
withUIContext { focusManager.clearFocus() }
val url = EhUrl.getGalleryDetailUrl(galleryDetail.gid, galleryDetail.token, 0, false)
runSuspendCatching {
val bbcode = userComment.annotatedString.normalizeSpan().toBBCode()
logcat { bbcode }
logcat("sendComment") { bbcode }
EhEngine.commentGallery(url, bbcode, commentId)
}.onSuccess {
activity.showTip(if (commentId != -1L) editCommentSuccess else commentSuccess)
@@ -245,6 +246,7 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
}
}
}
val hasSignedIn = remember { EhCookieStore.hasSignedIn() }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -259,7 +261,7 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
)
},
floatingActionButton = {
if (!commenting) {
if (hasSignedIn && !commenting) {
FloatingActionButton(
onClick = {
if (commentId != -1L) {
@@ -303,10 +305,15 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
bottom = paddingValues.calculateBottomPadding() + additionalPadding,
),
) {
items(comments.comments) { item ->
items(
items = comments.comments,
key = { it.id },
) { item ->
suspend fun voteComment(comment: GalleryComment, isUp: Boolean) {
galleryDetail.runSuspendCatching {
EhEngine.voteComment(apiUid, apiKey, gid, token, comment.id, if (isUp) 1 else -1)
EhEngine.voteComment(apiUid, apiKey, gid, token, comment.id, if (isUp) 1 else -1).also {
refreshComment(true)
}
}.onSuccess { result ->
activity.showTip(
if (isUp) {
@@ -315,13 +322,6 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
if (0 != result.vote) R.string.vote_down_successfully else R.string.cancel_vote_down_successfully
},
)
val newComment = comment.copy(
score = result.score,
voteUpEd = 0 != result.vote && isUp,
voteDownEd = 0 != result.vote && !isUp,
)
val list = comments.comments.replace(comment, newComment)
comments = comments.copy(comments = list)
}.onFailure {
activity.showTip(R.string.vote_failed)
}
@@ -359,6 +359,7 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
}
GalleryCommentCard(
modifier = Modifier.animateItemPlacement(),
comment = item,
onUserClick = {
val lub = ListUrlBuilder(
@@ -448,7 +449,7 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
IconButton(
onClick = {
coroutineScope.launchIO {
context.sendComment()
sendComment()
}
},
modifier = Modifier.align(Alignment.CenterVertically).padding(16.dp),
@@ -49,9 +49,9 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ModalBottomSheetFix
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
@@ -79,8 +79,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.text.parseAsHtml
import androidx.lifecycle.compose.LifecycleResumeEffect
import arrow.core.partially1
import coil.imageLoader
import com.google.android.material.snackbar.Snackbar
import coil3.imageLoader
import com.hippo.ehviewer.EhApplication.Companion.galleryDetailCache
import com.hippo.ehviewer.EhApplication.Companion.imageCache
import com.hippo.ehviewer.EhDB
@@ -120,6 +119,7 @@ import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.spider.SpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.MODE_READ
import com.hippo.ehviewer.ui.GalleryInfoBottomSheet
import com.hippo.ehviewer.ui.LocalSnackbarHostState
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.confirmRemoveDownload
@@ -145,7 +145,6 @@ import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconToggleButton
import com.hippo.ehviewer.ui.tools.GalleryDetailRating
import com.hippo.ehviewer.ui.tools.IconFix
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.rememberLambda
import com.hippo.ehviewer.util.AppHelper
@@ -170,7 +169,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.parcelize.Parcelize
import moe.tarsin.coroutines.runSuspendCatching
import okhttp3.HttpUrl.Companion.toHttpUrl
import splitties.systemservices.downloadManager
import splitties.systemservices.layoutInflater
@@ -219,7 +217,7 @@ private fun List<GalleryTagGroup>.getArtist(): String? {
@Composable
fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) {
LockDrawer(true)
var galleryInfo by rememberSaveable {
var galleryInfo by remember {
val casted = args as? GalleryInfoArgs
mutableStateOf<GalleryInfo?>(casted?.galleryInfo)
}
@@ -232,15 +230,23 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
val galleryDetailUrl = remember { EhUrl.getGalleryDetailUrl(gid, token, 0, false) }
val context = LocalContext.current
val activity = remember(context) { context.findActivity<MainActivity>() }
val snackbarState = LocalSnackbarHostState.current
var showReadAction by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(args, galleryInfo) {
val page = (args as? TokenArgs)?.page ?: 0
val gi = galleryInfo
if (page != 0 && gi != null) {
Snackbar.make(
activity.findViewById(R.id.content),
context.getString(R.string.read_from, page),
Snackbar.LENGTH_LONG,
).setAction(R.string.read) { context.navToReader(gi.findBaseInfo(), page) }.show()
if (showReadAction) {
val page = (args as? TokenArgs)?.page ?: 0
val gi = galleryInfo
if (page != 0 && gi != null) {
showReadAction = false
val result = snackbarState.showSnackbar(
context.getString(R.string.read_from, page),
context.getString(R.string.read),
true,
)
if (result == SnackbarResult.ActionPerformed) {
context.navToReader(gi.findBaseInfo(), page)
}
}
}
}
with(activity) {
@@ -269,6 +275,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
}
}
}.onFailure {
galleryInfo?.let { info -> EhDB.putHistoryInfo(info.findBaseInfo()) }
getDetailError = ExceptionUtils.getReadableString(it)
}.getOrNull()
galleryDetail?.let {
@@ -486,7 +493,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
else -> stringResource(R.string.more_comment)
}
fun onNavigateToCommentScene() {
navigator.navigate(GalleryCommentsScreenDestination(galleryDetail))
navigator.navigate(GalleryCommentsScreenDestination(galleryDetail.gid))
}
CrystalCard {
commentsList.take(maxShowCount).forEach { item ->
@@ -587,7 +594,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
}
},
) {
IconFix(
Icon(
imageVector = getFavoriteIcon(favSlot != NOT_FAVORITED),
contentDescription = null,
)
@@ -673,7 +680,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
FileUtils.sanitizeFilename("$name.torrent"),
)
r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
r.addRequestHeader("Cookie", EhCookieStore.getCookieHeader(itemUrl.toHttpUrl()))
r.addRequestHeader("Cookie", EhCookieStore.getCookieHeader(itemUrl))
try {
downloadManager.enqueue(r)
activity.showTip(R.string.download_torrent_started)
@@ -948,7 +955,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
var showBottomSheet by remember { mutableStateOf(false) }
if (showBottomSheet && galleryDetail != null) {
ModalBottomSheetFix(
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
windowInsets = WindowInsets(0, 0, 0, 0),
) {
@@ -1129,7 +1136,13 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
contentDescription = null,
)
}
IconButton(onClick = { AppHelper.share(activity, galleryDetailUrl) }) {
IconButton(
onClick = {
AppHelper.share(activity, galleryDetailUrl)
// In case the link is copied to the clipboard
Settings.clipboardTextHashCode = galleryDetailUrl.hashCode()
},
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = null,
@@ -1211,23 +1224,21 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
)
},
) {
Surface {
val gi = galleryInfo
if (gi != null) {
GalleryDetailContent(
galleryInfo = gi,
contentPadding = it,
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
)
} else if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" })
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
val gi = galleryInfo
if (gi != null) {
GalleryDetailContent(
galleryInfo = gi,
contentPadding = it,
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
)
} else if (getDetailError.isNotBlank()) {
GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" })
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
@@ -4,7 +4,6 @@ import android.graphics.Bitmap
import android.net.Uri
import android.view.ViewConfiguration
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
@@ -25,7 +24,7 @@ import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.rememberScrollState
@@ -36,25 +35,19 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.automirrored.filled.LastPage
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ImageSearch
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.outlined.Bookmarks
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LeadingIconTab
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.SwipeToDismissValue
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberSwipeToDismissState
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
@@ -65,6 +58,7 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -109,7 +103,6 @@ import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_NORMAL
import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_SUBSCRIPTION
import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_TAG
import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_TOPLIST
import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_UPLOADER
import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_WHATS_HOT
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
@@ -124,7 +117,6 @@ import com.hippo.ehviewer.ui.destinations.GalleryDetailScreenDestination
import com.hippo.ehviewer.ui.destinations.GalleryListScreenDestination
import com.hippo.ehviewer.ui.destinations.ProgressScreenDestination
import com.hippo.ehviewer.ui.doGalleryInfoAction
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
import com.hippo.ehviewer.ui.main.AdvancedSearchOption
import com.hippo.ehviewer.ui.main.FAB_ANIMATE_TIME
import com.hippo.ehviewer.ui.main.FabLayout
@@ -132,12 +124,10 @@ import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
import com.hippo.ehviewer.ui.main.GalleryList
import com.hippo.ehviewer.ui.main.ImageSearch
import com.hippo.ehviewer.ui.main.NormalSearch
import com.hippo.ehviewer.ui.main.SearchAdvanced
import com.hippo.ehviewer.ui.main.SearchFilter
import com.hippo.ehviewer.ui.showDatePicker
import com.hippo.ehviewer.ui.tools.Deferred
import com.hippo.ehviewer.ui.tools.DragHandle
import com.hippo.ehviewer.ui.tools.IconFix
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
@@ -145,6 +135,7 @@ import com.hippo.ehviewer.ui.tools.draggingHapticFeedback
import com.hippo.ehviewer.ui.tools.observed
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.pickVisualMedia
import com.ramcosta.composedestinations.annotation.Destination
@@ -188,11 +179,9 @@ fun ToplistScreen(navigator: DestinationsNavigator) =
fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
val searchFieldState = rememberTextFieldState()
var urlBuilder by rememberSaveable(lub) { mutableStateOf(lub) }
var searchBarOffsetY by remember { mutableStateOf(0) }
var searchBarOffsetY by remember { mutableIntStateOf(0) }
var showSearchLayout by rememberSaveable { mutableStateOf(false) }
var searchNormalMode by rememberSaveable { mutableStateOf(true) }
var searchAdvancedMode by rememberSaveable { mutableStateOf(false) }
var category by rememberSaveable { mutableIntStateOf(Settings.searchCategory) }
var searchMethod by rememberSaveable { mutableIntStateOf(1) }
var advancedSearchOption by rememberSaveable { mutableStateOf(AdvancedSearchOption()) }
@@ -227,7 +216,6 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
) { showSearchLayout = false }
val context = LocalContext.current
val activity = remember { context.findActivity<MainActivity>() }
val windowSizeClass = calculateWindowSizeClass(activity)
val density = LocalDensity.current
val dialogState = LocalDialogState.current
val coroutineScope = rememberCoroutineScope()
@@ -236,7 +224,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
val isTopList = remember(urlBuilder) { urlBuilder.mode == MODE_TOPLIST }
val ehHint = stringResource(R.string.gallery_list_search_bar_hint_e_hentai)
val exHint = stringResource(R.string.gallery_list_search_bar_hint_exhentai)
val searchBarHint = remember { if (EhUtils.isExHentai) exHint else ehHint }
val searchBarHint by rememberUpdatedState(if (EhUtils.isExHentai) exHint else ehHint)
val suitableTitle = getSuitableTitleForUrlBuilder(urlBuilder)
val data = rememberInVM {
Pager(PagingConfig(25)) {
@@ -292,6 +280,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
}
}.flow.cachedIn(viewModelScope)
}.collectAsLazyPagingItems()
FavouriteStatusRouter.observe(data)
val listMode by Settings.listMode.collectAsState()
val quickSearchList = remember { mutableStateListOf<QuickSearch>() }
@@ -418,17 +407,10 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
val reorderableLazyListState = rememberReorderableLazyColumnState(quickSearchListState) { from, to ->
val fromIndex = from.index - 1
val toIndex = to.index - 1
quickSearchList.apply {
add(toIndex, removeAt(fromIndex))
}
coroutineScope.launchIO {
val range = if (fromIndex < toIndex) fromIndex..toIndex else toIndex..fromIndex
val list = quickSearchList.slice(range)
list.zip(range).forEach { it.first.position = it.second }
EhDB.updateQuickSearch(list)
}
quickSearchList.apply { add(toIndex, removeAt(fromIndex)) }
view.performHapticFeedback(draggingHapticFeedback)
}
var fromIndex by remember { mutableIntStateOf(-1) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = quickSearchListState,
@@ -438,11 +420,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
stickyHeader {
HorizontalDivider()
}
items(quickSearchList, key = { it.id!! }) { item ->
itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { index, item ->
ReorderableItem(reorderableLazyListState, key = item.id!!) { isDragging ->
val dismissState = rememberSwipeToDismissState(
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
if (it == SwipeToDismissValue.EndToStart) {
if (it == SwipeToDismissBoxValue.EndToStart) {
quickSearchList.remove(item)
coroutineScope.launchIO {
EhDB.deleteQuickSearch(item)
@@ -480,7 +462,24 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
Text(text = item.name)
},
trailingContent = {
DragHandle()
DragHandle(
onDragStarted = {
fromIndex = index
},
onDragStopped = {
if (fromIndex != -1) {
if (fromIndex != index) {
val range = if (fromIndex < index) fromIndex..index else index..fromIndex
val toUpdate = quickSearchList.slice(range)
toUpdate.zip(range).forEach { it.first.position = it.second }
coroutineScope.launchIO {
EhDB.updateQuickSearch(toUpdate)
}
}
fromIndex = -1
}
},
)
},
)
}
@@ -534,8 +533,6 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
var expanded by remember { mutableStateOf(false) }
val searchErr1 = stringResource(R.string.search_sp_err1)
val searchErr2 = stringResource(R.string.search_sp_err2)
val selectImageFirst = stringResource(R.string.select_image_first)
SearchBarScreen(
title = suitableTitle,
@@ -550,45 +547,24 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
val newMode = if (oldMode == MODE_SUBSCRIPTION) MODE_SUBSCRIPTION else MODE_NORMAL
builder.mode = newMode
builder.keyword = query
builder.category = category
builder.advanceSearch = advancedSearchOption.advanceSearch
builder.minRating = advancedSearchOption.minRating
builder.pageFrom = advancedSearchOption.fromPage
builder.pageTo = advancedSearchOption.toPage
} else {
if (searchNormalMode) {
when (searchMethod) {
1 -> builder.mode = MODE_NORMAL
2 -> builder.mode = MODE_SUBSCRIPTION
3 -> builder.mode = MODE_UPLOADER
4 -> builder.mode = MODE_TAG
}
builder.keyword = query
builder.category = category
if (searchAdvancedMode) {
builder.advanceSearch = advancedSearchOption.advanceSearch
builder.minRating = advancedSearchOption.minRating
val pageFrom = advancedSearchOption.fromPage
val pageTo = advancedSearchOption.toPage
if (pageTo != -1 && pageTo < 10) {
activity.showTip(searchErr1)
return@SearchBarScreen
} else if (pageFrom != -1 && pageTo != -1 && pageTo - pageFrom < 20) {
activity.showTip(searchErr2)
return@SearchBarScreen
}
builder.pageFrom = pageFrom
builder.pageTo = pageTo
}
} else {
builder.mode = MODE_IMAGE_SEARCH
if (imagePath.isBlank()) {
activity.showTip(selectImageFirst)
return@SearchBarScreen
}
val uri = Uri.parse(imagePath)
val temp = AppConfig.createTempFile() ?: return@SearchBarScreen
val bitmap = context.decodeBitmap(uri) ?: return@SearchBarScreen
temp.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
builder.imagePath = temp.path
builder.isUseSimilarityScan = useSimilarityScan
builder.isOnlySearchCovers = searchCoverOnly
builder.mode = MODE_IMAGE_SEARCH
if (imagePath.isBlank()) {
activity.showTip(selectImageFirst)
return@SearchBarScreen
}
val uri = Uri.parse(imagePath)
val temp = AppConfig.createTempFile() ?: return@SearchBarScreen
val bitmap = context.decodeBitmap(uri) ?: return@SearchBarScreen
temp.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
builder.imagePath = temp.path
builder.isUseSimilarityScan = useSimilarityScan
builder.isOnlySearchCovers = searchCoverOnly
}
when (oldMode) {
MODE_TOPLIST, MODE_WHATS_HOT -> {
@@ -629,6 +605,17 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
)
}
},
filter = {
SearchFilter(
category = category,
onCategoryChanged = {
Settings.searchCategory = it
category = it
},
advancedOption = advancedSearchOption,
onAdvancedOptionChanged = { advancedSearchOption = it },
)
},
) { contentPadding ->
val layoutDirection = LocalLayoutDirection.current
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
@@ -648,113 +635,35 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
alpha = 1 - animatedSearchLayout
},
) {
AnimatedVisibility(visible = searchNormalMode) {
ElevatedCard(modifier = Modifier.padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) {
Column(
modifier = Modifier.padding(
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.search_normal),
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
NormalSearch(
category = category,
onCategoryChanged = {
Settings.searchCategory = it
category = it
},
searchMode = searchMethod,
onSearchModeChanged = { searchMethod = it },
isAdvanced = searchAdvancedMode,
onAdvancedChanged = { searchAdvancedMode = it },
showInfo = { BaseDialogBuilder(context).setMessage(R.string.search_tip).show() },
maxItemsInEachRow = when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> 2
WindowWidthSizeClass.Medium -> 3
else -> 5
},
)
}
ElevatedCard(modifier = Modifier.padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) {
Column(
modifier = Modifier.padding(
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.search_image),
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
ImageSearch(
imagePath = imagePath,
onSelectImage = {
coroutineScope.launch {
val image = context.pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly)
if (image != null) imagePath = image.toString()
}
},
uss = useSimilarityScan,
onUssChecked = { useSimilarityScan = it },
osc = searchCoverOnly,
onOscChecked = { searchCoverOnly = it },
)
}
}
AnimatedVisibility(visible = searchNormalMode && searchAdvancedMode) {
ElevatedCard(modifier = Modifier.padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) {
Column(
modifier = Modifier.padding(
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.search_advance),
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
SearchAdvanced(
state = advancedSearchOption,
onStateChanged = { advancedSearchOption = it },
)
}
}
}
AnimatedVisibility(visible = !searchNormalMode) {
ElevatedCard(modifier = Modifier.padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) {
Column(
modifier = Modifier.padding(
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.search_image),
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
ImageSearch(
imagePath = imagePath,
onSelectImage = {
coroutineScope.launch {
val image = context.pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly)
if (image != null) imagePath = image.toString()
}
},
uss = useSimilarityScan,
onUssChecked = { useSimilarityScan = it },
osc = searchCoverOnly,
onOscChecked = { searchCoverOnly = it },
)
}
}
}
PrimaryTabRow(
selectedTabIndex = if (searchNormalMode) 0 else 1,
divider = {},
) {
LeadingIconTab(
selected = searchNormalMode,
onClick = { searchNormalMode = true },
text = { Text(text = stringResource(id = R.string.keyword_search)) },
icon = { IconFix(imageVector = Icons.Default.Translate, contentDescription = null) },
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
)
LeadingIconTab(
selected = !searchNormalMode,
onClick = { searchNormalMode = false },
text = { Text(text = stringResource(id = R.string.search_image)) },
icon = { IconFix(imageVector = Icons.Default.ImageSearch, contentDescription = null) },
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
)
}
}
val height by collectListThumbSizeAsState()
@@ -39,7 +39,7 @@ import androidx.room.paging.util.getClippedRefreshKey
import androidx.room.paging.util.getLimit
import androidx.room.paging.util.getOffset
import arrow.fx.coroutines.parMap
import coil.imageLoader
import coil3.imageLoader
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhEngine
@@ -14,11 +14,11 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissValue
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberSwipeToDismissState
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
@@ -49,6 +49,7 @@ import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
@@ -67,6 +68,7 @@ fun HistoryScreen(navigator: DestinationsNavigator) {
EhDB.historyLazyList
}.flow.cachedIn(viewModelScope)
}.collectAsLazyPagingItems()
FavouriteStatusRouter.observe(historyData)
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -109,9 +111,9 @@ fun HistoryScreen(navigator: DestinationsNavigator) {
) { index ->
val info = historyData[index]
if (info != null) {
val dismissState = rememberSwipeToDismissState(
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
if (it == SwipeToDismissValue.EndToStart) {
if (it == SwipeToDismissBoxValue.EndToStart) {
coroutineScope.launchIO {
EhDB.deleteHistoryInfo(info)
}
@@ -5,6 +5,7 @@ import androidx.navigation.NavController
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
import com.hippo.ehviewer.client.parser.GalleryListUrlParser
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
import com.hippo.ehviewer.ui.NavGraphs
import com.hippo.ehviewer.ui.destinations.GalleryDetailScreenDestination
import com.hippo.ehviewer.ui.destinations.GalleryListScreenDestination
import com.hippo.ehviewer.ui.destinations.ProgressScreenDestination
@@ -29,6 +30,11 @@ fun DestinationsNavigator.navWithUrl(url: String): Boolean {
return true
}
@MainThread
fun DestinationsNavigator.popNavigate(direction: Direction) = navigate(direction) {
popUpTo(NavGraphs.root.route)
}
@MainThread
fun NavController.navWithUrl(url: String): Boolean {
val dest = urlToDestination(url) ?: return false
@@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
@@ -55,7 +56,10 @@ fun ProgressScreen(gid: Long, token: String, page: Int, navigator: DestinationsN
}
}
}
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (error.isNotBlank()) {
Column(
modifier = Modifier.clickable { error = "" },
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff. Show more