Compare commits

..
455 Commits
Author SHA1 Message Date
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
FooIbar b2368b312f Prepare for next iteration 2023-12-01 22:41:12 +08:00
FooIbar 3cade906d6 Prepare for release 1.9.0 (#406)
* Prepare for release 1.9.0

* Add known issues

* Bump version

* [no ci] Rename CHANGELOG.MD
2023-12-01 22:35:47 +08:00
FooIbar e7cb33ef6f Fix calling Activity.recreate from IO thread 2023-12-01 22:25:12 +08:00
Vladimir DostoyevskiyandGG Bond 8c331c9577 Fix BottomSheet (#414)
* Fix BottomSheet

* Predictive Back for ModelBottomSheet

* Naming

* Naming

* Naming

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-01 22:13:03 +08:00
FooIbar 8560a050ba Make CheckBox/RadioButton text clickable and refine layout (#416)
* Make CheckBox/RadioButton text clickable

* Introduce LabeledCheckbox and refine layout
2023-12-01 13:59:28 +00:00
FooIbar 583637b692 Fix IME not hiding after performing search (#412)
* Fix IME not hiding after performing search

Regression of https://android.googlesource.com/platform/frameworks/support/+/8f81d46e2c3f37564d0b7515ec9411314645e187

* Add comment
2023-12-01 06:31:02 +00:00
FooIbar 791359e949 Fix last item placement animation 2023-12-01 13:30:17 +08:00
FooIbar e238b6aec0 Fix touch slop 2023-12-01 12:27:16 +08:00
renovate[bot] 17a143c4de Update dependency com.android.application to v8.2.0 2023-11-30 19:36:10 +00:00
FooIbar dfb0aa823e Fix crash when deleting downloads 2023-12-01 01:57:18 +08:00
renovate[bot] b5cad36967 Update kotlinx.serialization to v1.6.2 2023-11-30 15:53:13 +00:00
tehcneko 86378630bb Fix searchLayout close icon (#404)
* Fix searchLayout close icon

* Update GalleryListScreen.kt
2023-11-30 12:35:43 +00:00
Vladimir DostoyevskiyandGG Bond 730035aff7 Refine Fablayout (#403)
* Fix read page

* Refine FabLayout

* Let Fablayout have consistent animation state, walk consistently

* Let Fablayout collapse when secondaryFab updated

* Move animation delay out of FabLayout, should not handle that within FabLayout

* Refine predictive back

* Refine predictive back

* Introduce waitCollapse

* Refine

* Simplify

* Simplify

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-11-30 11:02:58 +00:00
renovate[bot] 64ac770c56 Update dependency dev.chrisbanes.compose:compose-bom to v2023.12.00-alpha03 2023-11-30 09:04:05 +00:00
FooIbar 557a5599e2 Make release build profileable 2023-11-30 16:47:41 +08:00
FooIbar ffd24015ac Update renovate.json
skip-checks: true
2023-11-30 13:19:25 +08:00
renovate[bot] 5eb1f12113 Update dependency androidx.work:work-runtime-ktx to v2.9.0 2023-11-30 04:48:06 +00:00
renovate[bot] 8425fed5a7 Update plugin spotless to v6.23.2 2023-11-30 03:01:33 +00:00
GG Bond 1e34aa4d9a Enable picker mode
Seems fixed by https://github.com/androidx/androidx/commit/4cf31c1a2c2a1f65fcb842490391c8a91fe44a84
2023-11-30 11:01:11 +08:00
GG Bond f383594050 Update snapshot 2023-11-30 11:01:11 +08:00
FooIbar 086db7572d Fix FAB not collapsing with no selected items 2023-11-30 09:28:26 +08:00
FooIbar 082e05584a Fix FAB animation 2023-11-30 09:28:14 +08:00
renovate[bot] fb20f899e2 Update dependency androidx.compose.ui:ui-text-android to v1.6.0-beta02 2023-11-29 22:58:17 +00:00
renovate[bot] c0c07ff2cf Update androidx.room to v2.6.1 2023-11-29 18:15:46 +00:00
renovate[bot] 2f67b741ff Update actions/setup-java action to v4 2023-11-29 16:26:11 +00:00
renovate[bot] 5a96048051 Update dependency gradle to v8.5 2023-11-29 14:58:30 +00:00
FooIbar 5d8b38ecf9 Fix HistoryScreen tip padding 2023-11-29 22:56:38 +08:00
FooIbar 2941ac8285 Add error tip for local favorites 2023-11-29 22:48:55 +08:00
FooIbar 07e316d9a9 Refine DownloadsScreen (#392)
* Fix downloads reorder and delete

* Fix download label edit and delete

* Fix changing download label
2023-11-29 14:15:37 +00:00
GG Bond 5bd9fa3a62 Drop compose viewbinding dependency 2023-11-29 18:25:23 +08:00
GG Bond 0b5da2b2dd Drop explicitly fragment dependency 2023-11-29 18:25:23 +08:00
GG Bond 714a8ccf65 Drop swiperefreshlayout dependency 2023-11-29 18:25:23 +08:00
GG Bond 5c731cc6b2 Drop rv selection dependency 2023-11-29 18:25:23 +08:00
GG Bond eda8115fe5 Revert "Drop fixed workaround"
Revert reason: BTF2 still not fixed
This reverts commit 3c988c63f244f0ec1bf027cc84d49c3e1c404aba.
2023-11-29 18:25:23 +08:00
GG Bond cf0a408b90 Drop fixed workaround 2023-11-29 18:25:23 +08:00
GG Bond f9786377a5 Update compose snapshot, new PullToRefreshContainer style 2023-11-29 18:25:23 +08:00
renovate[bot] 93f234b16d Update dependency com.google.devtools.ksp to v2.0.0-Beta1-1.0.15 2023-11-29 03:39:16 +00:00
GG Bond afb986937b Defer searchBarOffsetY read to minimize recompose scope 2023-11-29 11:38:54 +08:00
Vladimir DostoyevskiyandGG Bond 61fecaffe1 Workaround Infinity recompose (#387)
* Workaround Infinity recompose

* Observe all Snapshot apply that may trigger recompose when debug mode

* Continue to fix things up

* Fix things up

* Fix things up

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-11-28 17:03:37 +00:00
FooIbar 6f87afc1bf Fix navigation drawer padding 2023-11-28 23:35:43 +08:00
FooIbar 2e5950a1a8 Disable haptic feedback if ticks are too dense 2023-11-28 18:01:44 +08:00
FooIbar 4bf0c4ecec Minimize unnecessary network requests 2023-11-28 17:41:45 +08:00
FooIbar 3a8dbcdb32 Disallow empty gallery list result
It has to be a parse error
2023-11-28 16:10:22 +08:00
GG Bond 1f3546f3cc Fix remove fav 2023-11-28 15:12:17 +08:00
GG Bond f864d65d98 Don't collect If isInFavScene 2023-11-28 15:12:17 +08:00
GG Bond 3479498c6c optimize detailScreen state collect 2023-11-28 14:53:26 +08:00
GG Bond ac319330be Reduce allocation 2023-11-28 14:53:26 +08:00
GG Bond 9b3595e45f Reduce allocation 2023-11-28 14:53:26 +08:00
GG Bond d855858543 Refactor DownloadManager NotifyFlow 2023-11-28 14:53:26 +08:00
GG Bond ada52f1687 Reduce allocation 2023-11-28 13:42:59 +08:00
GG Bond ef68374f52 Reduce allocation on anim path 2023-11-28 13:42:59 +08:00
Vladimir DostoyevskiyandGG Bond 0bf8049021 Reduce allocation for getCategoryColor (#379)
* Reduce allocation for getCategoryColor

* refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-11-28 05:09:12 +00:00
GG Bond 3cff3f837a Reduce allocation in NavDrawer 2023-11-28 12:25:22 +08:00
GG Bond ca94a63762 Drop unused SimpleRatingView 2023-11-28 12:25:22 +08:00
GG Bond 81b8bc9029 Fixup 2023-11-28 12:25:22 +08:00
GG Bond 9d38520b85 Rename to *Screen 2023-11-28 12:25:22 +08:00
FooIbar 800e09adad Use relative links in READMEs 2023-11-28 11:40:30 +08:00
FooIbar 30d6c11220 Move showDatePicker to CommonOperations.kt 2023-11-28 01:36:55 +08:00
FooIbar e465356df8 Use large heap on API < 26 2023-11-28 00:52:18 +08:00
FooIbar ff8e1b7d28 Fix downloads and favorites selection 2023-11-27 23:34:01 +08:00
GG Bond 175e1d7df9 Convert TimePicker to use a dialogState 2023-11-27 23:05:00 +08:00
FooIbar 271c1b1390 Fix downloads filter and search 2023-11-27 23:02:24 +08:00
GG Bond a128356fc4 Cleanup unused 2023-11-27 22:33:16 +08:00
FooIbar 2ed957b5f2 Compose DatePicker 2023-11-27 22:12:01 +08:00
FooIbar 85bb9b9f97 Fix crash when TextField with focus is scaled to 0 2023-11-27 21:18:03 +08:00
GG Bond 050b5c0451 Kill EditTextDialogBuilder 2023-11-27 21:10:41 +08:00
GG Bond a14df3fc0c Fix bigicon tint in nightmode 2023-11-27 21:01:17 +08:00
GG Bond 939136476e Fix tip 2023-11-27 20:19:59 +08:00
GG Bond 7e2af653ee Cleanup unused 2023-11-27 20:19:59 +08:00
GG Bond d357a55307 Cleanup unused 2023-11-27 20:19:59 +08:00
GG Bond 23509ac2c9 Fix activity transition style 2023-11-27 20:19:59 +08:00
FooIbar 118a4f75c0 Fix crash info collector 2023-11-27 19:56:47 +08:00
renovate[bot] 2b8a7fafe6 Update plugin spotless to v6.23.0 2023-11-27 08:09:14 +00:00
Vladimir DostoyevskiyandGG Bond 10eda7e5f3 Introduce LockDrawerHandle (#360)
* Introduce LockDrawerHandle

* refine

* Ensure SearchBar PredictiveBackHandler is latest when enabled

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-11-27 08:08:54 +00:00
FooIbar c23cadec8a Cleanup snackbar usage
`SnackbarDuration.Short` is already longer than `Snackbar.LENGTH_LONG`
2023-11-27 15:16:11 +08:00
FooIbar 0114081a2b Fix search bar scroll-away offset 2023-11-27 14:14:59 +08:00
GG Bond 2611601fb9 Optional Predictive NavAnim 2023-11-27 13:56:36 +08:00
FooIbar 4194087f0b Fix GalleryInfoBottomSheet content padding 2023-11-27 13:27:42 +08:00
renovate[bot] 673adc41ba Update dependency org.jsoup:jsoup to v1.17.1 2023-11-27 03:09:59 +00:00
FooIbar b195ae25a8 Fix LazyList items jumping during scroll
https://issuetracker.google.com/issues/312025159
2023-11-27 02:19:22 +08:00
GG Bond 556c49fbfd Check meteredNetworkWaring 2023-11-27 00:37:19 +08:00
GG Bond 358a5249a4 Cleanup 2023-11-27 00:37:19 +08:00
GG Bond d5b837e6af CheckClipboard within Composables 2023-11-27 00:37:19 +08:00
GG Bond 61100d0c83 Upgrade lifecycle for new compose utils 2023-11-27 00:37:19 +08:00
GG Bond 8473ca9fff Observe Intent within Composables 2023-11-27 00:37:19 +08:00
FooIbar 37a2227c5c Fix OOB in DownloadsScreen 2023-11-27 00:16:05 +08:00
Vladimir DostoyevskiyandGG Bond d6a4ab7471 CompositionLocal Sheet State (#351)
* CompositionLocal Sheet State

* Fix typo

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-11-26 15:43:30 +00:00
GG Bond 6291288c42 Lowering default navAnim duration to 500ms 2023-11-26 23:25:22 +08:00
Vladimir DostoyevskiyandGG Bond 6c926419e9 Compose Era!!! (#346)
* Initial Kill Fragment!

* Cleanup

* Cleanup

* Convert to use Compose ModalBottomSheet

* Ignore insets

* Navigate

* Fixes

* Cleanup

* Fix navToWebview

* Fix navToWebview

* showtip

* Refine

* Fixes

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-11-26 15:02:03 +00:00
FooIbar f292f31ac1 Refine DownloadsScreen icons (#349)
* Convert big download icon to ImageVector

* Tweak download label icons
2023-11-26 14:54:29 +00:00
FooIbar 86a49f764e Add haptic feedback for drag and drop in Downloads 2023-11-26 22:32:04 +08:00
FooIbar 5dad21f4ea Fix download label drag and drop 2023-11-26 21:57:21 +08:00
FooIbar a4e9291bf9 Implement LoadStateIndicator 2023-11-26 21:08:18 +08:00
Vladimir DostoyevskiyandGG Bond 2a667e62f4 Compose DownloadScreen (#343)
* Initial Compose DownloadScreen

* Cleanup namespace

* Reorderable

* Reorderable

* Fixes

* Fix layout

* Fixes

* State labelList

* Fixes

* SwipeToDismiss

* RenameLabel

* Rewrite renameLabel to notify

* Fix name shadow

* Fixes

* Fixes

* SelectMode

* Drop unused

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-11-26 20:12:01 +08:00
FooIbar 6bd8063976 Replace thumb size setting with thumb column count 2023-11-26 15:09:43 +08:00
FooIbar 0aba5907b0 Fix title overflow 2023-11-26 01:05:57 +08:00
FooIbar 32182a7791 Fix keyword and title not updating 2023-11-26 01:05:57 +08:00
Vladimir DostoyevskiyandGG Bond 198960f93c Compose DownloadCard (#339)
* Cleanup res

* Compose DownloadCard

* Refine

* Fix CI

* Fixes

* Fix recompose

* Fix textStyle

* Fixes

* Fixes

* Restore design specs and clean up

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-11-25 16:46:07 +00:00
FooIbar 618f14a732 Reset search bar offset on loading 2023-11-26 00:09:44 +08:00
FooIbar fec2c39364 Extract common part of GalleryList and Favorites (#338)
* Extract common part of GalleryList and Favorites

* Scroll to top after refreshing
2023-11-25 15:15:28 +00:00
FooIbar dd06d82cd9 Refine side sheet layout 2023-11-25 16:38:30 +08:00
GG Bond 4c98ee5350 Refine CheckBox dialog design 2023-11-25 15:53:36 +08:00
GG Bond 2800a7bc15 Kill CheckBoxDialogBuilder 2023-11-25 15:53:36 +08:00
FooIbar 04818e5afb Fix IME won't pop up after dismissing dialog (#334)
* Fix IME won't pop up after dismissing dialog

* Add keep rule for reflection
2023-11-25 14:17:21 +08:00
Vladimir DostoyevskiyandGG Bond 122089eb1e Predictive Back for NavHost (#335)
* Grab navigation-compose

* Initial SeekableTransitions

* Refine

* Fixes

* Fix anim again

* Easeout Predictive Back

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-11-25 14:11:00 +08:00
GG Bond b991e5d70a Cleanup ActionBuilder 2023-11-24 22:21:03 +08:00
GG Bond 0d00ae207a Drawer blurs behind 2023-11-24 21:51:22 +08:00
GG Bond 9e451c18d9 Simplify GalleryInfoListItem placeholder 2023-11-24 21:32:36 +08:00
GG Bond 71ebd99115 Stateful listThumbSize 2023-11-24 21:22:08 +08:00
GG Bond 116dbead4b Stateful thumbSizeDp 2023-11-24 21:22:08 +08:00
FooIbar ef8adb00f4 Fix invisible FAB being clickable 2023-11-24 20:47:20 +08:00
FooIbar 7d74781f96 Collect debug info when dumping logs 2023-11-24 20:17:12 +08:00
Vladimir Dostoyevskiyandskymkmk b7627dc576 Fix GalleryList to use FastScrollLazyVerticalGrid when listMode (#324)
* Fix GalleryList to use FastScrollLazyVerticalGrid when listMode

* Fix recompose

* Initial PrefDelegate -> State<T>, MutableState<T>

* Migrate

* Optimize

---------

Co-authored-by: skymkmk <whatever@tagkong.com>
2023-11-24 10:44:38 +00:00
FooIbar cf42acd65c Lock side drawer as well 2023-11-24 18:31:34 +08:00
skymkmk 4163c895df Tweak SearchTab Design 2023-11-24 17:44:06 +08:00
FooIbar 63ddd5ea89 Fix side sheet padding 2023-11-24 17:17:33 +08:00
FooIbar 216dfded4b Fix downloads FAB animation 2023-11-24 16:50:29 +08:00
FooIbar 08b5eac6bd Restore history item delete animation 2023-11-24 16:14:53 +08:00
FooIbar 6e541be6ca Restore quick search item drag and drop animation 2023-11-24 15:48:47 +08:00
FooIbar 17a9c02f96 [Marshmallow] Fix OOM when decoding images 2023-11-24 13:22:23 +08:00
Vladimir Dostoyevskiyandskymkmk ed640ab00e Enhanced swipe-edge-oriented PredictiveBack (#317)
* Enhanced swipe-edge-oriented PredictiveBack

Following MDC NavigationDrawer design specs

* Cleanups

---------

Co-authored-by: skymkmk <whatever@tagkong.com>
2023-11-23 18:01:58 +00:00
skymkmk d69df5f7ab PredictiveBack for ModalNavigationDrawer, SideDrawer 2023-11-24 00:47:36 +08:00
FooIbar 6f6461e855 Fix disowned gallery uploader parsing 2023-11-24 00:06:59 +08:00
skymkmk 682d959688 All Easing for animateFloatMergePredictiveBackAsState 2023-11-23 23:31:02 +08:00
skymkmk 179686de16 Reimplement animateFloatMergePredictiveBackAsState 2023-11-23 23:31:02 +08:00
FooIbar 32e35622bd Kotlin 2.0.0-Beta1 (#312)
* Kotlin 2.0.0-Beta1

* Cleanup
2023-11-23 14:21:43 +00:00
c9c06202bb Compose GalleryListScene (#301)
* Initial Migrate

* Cleanup

* Search

* Refine

* Initial searchLayout

* Cleanup

* Margins

* Animated State

* Animated State

* Fixes

* ShowSearchFab

* Refine

* Log

* Refine naming

* Update

* Scroll to hide FAB

* Cleanups

* Initial side sheet

* Refine side sheet

* Refine layout

* Refine transition

* Refine padding

* Refine ProvideSideSheetContent to handle multiple screen on composition

* Let sidesheet container padding vec insets

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
Co-authored-by: skymkmk <admin@skymkmk.com>
2023-11-23 14:14:02 +00:00
FooIbar 42d17621f9 Fix advanced search not working 2023-11-23 21:42:55 +08:00
renovate[bot] 629044c241 Update dependency dev.chrisbanes.compose:compose-bom to v2023.12.00-alpha02 2023-11-23 12:48:58 +00:00
FooIbar 9be19489a4 Fix crash when visiting newer version galleries 2023-11-23 20:48:32 +08:00
FooIbar cde4f9e405 Fix refresh state 2023-11-23 20:48:21 +08:00
FooIbar 16fddcb377 Refine 2023-11-23 20:48:21 +08:00
FooIbar 35cff4647a Fix favorites refresh 2023-11-23 20:48:21 +08:00
renovate[bot] 80ad749fc2 Update compose.destinations to v1.9.55 2023-11-21 14:00:36 +00:00
FooIbar 1a1d444b20 Restore common actions in comment field 2023-11-21 22:00:18 +08:00
FooIbar bb2d500067 Refine FavouritesScreen layout 2023-11-21 21:53:44 +08:00
FooIbar c3c7e88c15 Fix pull request builds being pushed to users 2023-11-21 17:17:07 +08:00
FooIbar 8dc3b1f376 Restore secondary FAB fade 2023-11-21 16:53:49 +08:00
Lokesh Gidra b49a80bb35 Cleanup unused res 2023-11-20 21:45:40 +08:00
Lokesh Gidra 21bfd4dd2d Cleanup unused res 2023-11-20 21:45:40 +08:00
Lokesh Gidra 3551ed7c7a Refine ActionBuilder dsl 2023-11-20 21:45:40 +08:00
FooIbar 5f8ab98e9e Tweak settings transition animations 2023-11-20 17:48:56 +08:00
FooIbar 8c6b2f5366 Fix side sheet padding 2023-11-20 16:10:57 +08:00
Vladimir DostoyevskiyandLokesh Gidra bac60fbec8 Compose FavouriteScreen (#295)
* Restore FavouriteScene SideSheet dragToOpen

* Initial move to Compose FavScene

* Refine state

* Refine state

* Introduce ProvideSideSheetContent with extra checkers

* Make sideSheet activity private

* Dont allow provide a null AssistContent

* Restore trailingIcon to openSideSheet

* Refine

* Cleanup

* Implement searchBarConnection

* Reduce allocation for animation

* Initial compose FabLayout API

* Restore showGoToDialog

* Refine

* Refine

* Refine

* PullTORefresh

* PullTORefresh

* Refine Dsl

* Insetless placeholder

* let pullToRefresh use insetless padding as well

* Support drawer gestures

* Fix tip color and ripple

* Make pullToRefresh optional

* Composite container color

* Fix padding

* Fix misadd

* Fix gesture

* Fixes

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-20 07:09:35 +00:00
FooIbar 4eed03ebed Add configuration for lock bot 2023-11-20 11:37:38 +08:00
Lokesh Gidra e33295d0a6 Don't allow RecyclerView intercept ACTION_CANCEL TouchEvent as well 2023-11-19 16:22:21 +08:00
Vladimir DostoyevskiyandLokesh Gidra 07a495c499 Reimplement ModalNavigationDrawer to allow HistoryScreen gesture (#293)
* Reimplement ModalNavigationDrawer to allow HistoryScreen gesture

* Fix RTL, fix gesture disabled

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-19 07:53:54 +00:00
e69eba6023 Compose FabLayout (#290)
* ComposeFabLayout with Predictive Back

* Refine

* Fix padding

* Fix anim

* Fix scrim

* Cleanup

* Convert FavoritesScene to use ComposeFabLayout

* Convert DownloadsScene to use ComposeFabLayout

* Cleanup unused code

* Rename ComposeFabLayout to FabLayout

* Refine

* Refine

* Intercept MotionEvent.ACTION_CANCEL for rv have SelectionTracker attached

* Revert to old icons

* Revert FAB orders

* Cleanup resources

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2023-11-19 09:21:52 +08:00
Lokesh Gidra 8be6ad84fc Get rid of IntList, LongList 2023-11-17 23:54:19 +08:00
Lokesh Gidra 59e46aeeea Navigate with GalleryDetailScreenArgs directly 2023-11-17 23:54:19 +08:00
renovate[bot] 4404531171 Update dependency com.android.application to v8.1.4 2023-11-16 19:51:03 +00:00
FooIbar 99db84fdd0 Cleanup code 2023-11-17 00:10:44 +08:00
FooIbar aa50b69754 Add rotate inertia for favSelectDialog 2023-11-16 23:15:02 +08:00
FooIbar fa2933df7c Workaround for BTF2 cursor not showing 2023-11-16 23:07:29 +08:00
FooIbar c51a26b9b7 Close drawer before navigating to another activity
Fix crash caused by activity recreate when it's invisible
2023-11-16 19:00:58 +08:00
FooIbar 9859a1eaac Fix amoled dark mode 2023-11-16 19:00:43 +08:00
Lokesh Gidra cd3858ace8 Rotatable fav select dialog 2023-11-16 14:27:51 +08:00
FooIbar 0c54e8b0b3 Allow setting search bar title 2023-11-16 12:47:33 +08:00
Lokesh Gidra e32a7b61b6 Workaround IME won't show again once hidden 2023-11-16 11:58:23 +08:00
FooIbar ea2977268b Follow upstream API changes 2023-11-16 11:21:20 +08:00
Vladimir Dostoyevskiy 6b529bcb18 Upgrade compose bom (#277)
* Upgrade compose bom

* Update settings.gradle.kts
2023-11-16 02:32:14 +00:00
FooIbar 941e7d18f3 Implement pull to refresh in GalleryCommentsScreen (#272)
* Use AndroidX snapshot builds

* Implement pull to refresh in GalleryCommentsScreen

* Don't use snapshot builds for other libraries
2023-11-16 09:34:29 +08:00
FooIbar d0fe9fac8c Fix crash when applying style to comment 2023-11-16 09:33:58 +08:00
FooIbar 8f37c5ba25 Fix favButton text not updating in gallery detail 2023-11-16 09:33:24 +08:00
renovate[bot] b63b36e6d2 Update dependency androidx.browser:browser to v1.7.0 2023-11-15 21:52:38 +00:00
renovate[bot] 081357cbb4 Update dependency androidx.activity:activity-compose to v1.8.1 2023-11-15 18:15:44 +00:00
renovate[bot] 0f7aa5f9ea Update kotlinx.serialization to v1.6.1 2023-11-15 16:07:25 +00:00
Lokesh Gidra a565b428e8 Apply blur to Compose Dialog 2023-11-15 21:05:45 +08:00
FooIbar 71f4df6f05 Fix download dialog checkbox ripple 2023-11-15 20:41:56 +08:00
FooIbar 5da2f91e6a Fix comment EditText height 2023-11-15 20:32:52 +08:00
FooIbar a6e9867596 Clear previous comment if it was in edit mode 2023-11-15 20:32:52 +08:00
FooIbar 9e1cff014d Minor cleanup 2023-11-15 20:32:52 +08:00
Lokesh Gidra 9c06f10d43 Drop explicitly androidx collection dependency
Since compose runtime depends 1.4.0a1 now
2023-11-15 20:11:56 +08:00
Lokesh Gidra 3f11df1dc6 Cleanup unused 2023-11-15 20:11:56 +08:00
Lokesh Gidra 6dd4505cec Convert download dialog to coroutine dialog 2023-11-15 20:11:56 +08:00
Lokesh Gidra 8faabd7759 Cleanup unused res 2023-11-15 19:03:40 +08:00
FooIbar 288e56d7e0 Fix search bar width 2023-11-15 15:23:15 +08:00
Vladimir DostoyevskiyandLokesh Gidra 7bcddb5f51 Initial limited support for BBCode editor (#260)
* Initial support edit

* Override LocalTextToolbar to reimpl BBCode editor: groundwork

* Impl

* Hacky

* Keep spans when editing

* Fix bold

* Fixes

* Initial AnnoStr to BBCode

* Implement edit with annotatedString

* Cleanup

* Cleanup

* Hack

* Some necessary comment

* Make url span a noop now

* Cleanup

* Cleanup

* Fix annotatedStr concat

* Fix toBBCode

* Clear focus as well

* Support Overlapped SpanStyle to BBCode

* Refine

* Cleanup code

* Fix algo

* Fix conflict

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-15 13:03:18 +08:00
FooIbar 7b0bd309e4 Fix light splash screen in dark theme on API >= 31 2023-11-15 01:19:53 +08:00
FooIbar 237938b1f0 Check temporary IP ban and refine error translation (#262)
* Check temporary IP ban and refine error translation

* Fix lint
2023-11-14 10:27:45 +00:00
FooIbar 07009f8bd6 Expose searchFieldState as workaround
Until we migrate GalleryListScene to compose
2023-11-14 16:09:23 +08:00
FooIbar f7ef768c8f Set default filter mode 2023-11-14 00:37:15 +08:00
FooIbar d1c3c9a37a Refine modifyFavorites 2023-11-13 18:23:58 +08:00
Lokesh Gidra 78f15860e0 Check sadpanda in cronet path 2023-11-13 18:23:58 +08:00
Lokesh Gidra 6b11773da9 ModifyFavoritesRange concurrently
Order?
2023-11-13 18:23:58 +08:00
Lokesh Gidra ed26cb8f35 Move modifyFavorites to use cronet 2023-11-13 18:23:58 +08:00
Lokesh Gidra 337f27dc38 Convert ghRequest to use stdlib Base64 impl 2023-11-13 18:23:58 +08:00
FooIbar 97fab45877 Avoid unnecessary overhead 2023-11-13 16:49:01 +08:00
FooIbar 8e8749ed6e Fix crash when initial composition hasn't been performed 2023-11-13 15:04:02 +08:00
Lokesh Gidra a0e999576e Fix comment textfield cursor color 2023-11-13 12:07:44 +08:00
Lokesh Gidra b29802c260 Keep GalleryDetail comments in sync 2023-11-13 11:32:24 +08:00
Lokesh Gidra 665e5a3d39 Immutable GalleryComment 2023-11-13 11:32:24 +08:00
Lokesh Gidra c2a2dafb47 Resolve #251 2023-11-13 11:32:24 +08:00
Lokesh Gidra 1d12a16e3d Resolve #250 2023-11-13 11:32:24 +08:00
Vladimir DostoyevskiyandLokesh Gidra f3fe5f0a75 Support cronetRequest with body (#248)
* Initial Add RequestBody support for cronetRequest

* Convert modifyFavorites to use cronet

* Code refactor

* Avoid some unnecessary copy

* Suppress NewApi for all managed usage

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-12 15:45:02 +00:00
Vladimir DostoyevskiyandLokesh Gidra 3bad1d06f4 Migrate more to cronet directly (#247)
* Convert GalleryListParser to parse a DirectByteBuffer

* Convert FavoritesParser to parse a DirectByteBuffer

* Limit buffer size

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-12 19:29:31 +08:00
FooIbar 70f2bfb760 Fix fab animation 2023-11-12 16:06:36 +08:00
Lokesh Gidra 9e86d42972 Predictive Back for Compose SearchBar
https://android-review.googlesource.com/c/platform/frameworks/support/+/2721593
2023-11-12 15:41:41 +08:00
FooIbar c8ade4d750 Fix recyclerview item disappearing 2023-11-12 01:39:16 +08:00
FooIbar 11b7bbd7ef Fix search bar text missing 2023-11-11 23:51:48 +08:00
Lokesh Gidra 513bce51ea Avoid unnecessary recompose in ReaderProgressIndicator 2023-11-11 22:57:04 +08:00
FooIbar f2c8c5d66d Mitigate OOM for API < 26 2023-11-11 22:44:55 +08:00
4e08f4f962 Compose Searchbar (#237)
* Migrate to Compose SearchBar

* Fix searchbar padding

* Fix content padding

* Implement scroll away behaviour

* Reset searchBarOffsetY when expand

* Fix multiple listener

* Fix cursor position

* Fix spotless

---------

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-11 13:03:11 +00:00
FooIbar a9bd55dff5 Restore fab rotation when showing and hiding (#235)
* Restore fab rotation when showing and hiding

* Make it rotate in the same direction
2023-11-10 12:29:01 +00:00
FooIbar 5cec825dbe Clean up duplicate imports 2023-11-10 18:05:20 +08:00
FooIbar 14c5b28cdc Refactor visibility animation for FabLayout 2023-11-10 16:41:42 +08:00
nullArrayListandLokesh Gidra 7693b11e0a Fix wrong SideSheetDialog usage (#231)
* Fix wrong SideSheetDialog usage

* Do not assume fragmentView first composition earlier than drawerView's

Use MainActivity composition instead

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-10 05:47:14 +00:00
FooIbar f9cb49e4d3 Only show commit subjects in changelog 2023-11-10 02:14:56 +08:00
Lokesh Gidra 892a614246 Drop overloadaggressively
https://r8.googlesource.com/r8/+/04d49b152b624a437db46e17410bb1cb1e4dd809
2023-11-09 22:34:31 +08:00
FooIbar 4a482d996e Reduce category min width for small screen devices
Tested-on: 4.65" 720x1280
2023-11-09 20:19:41 +08:00
Lokesh Gidra af9baa8ce4 Convert to use enableEdgeToEdge 2023-11-09 19:39:48 +08:00
Lokesh Gidra 39a11525a9 Rename EMPTY_GALLERY_COMMENT_LIST 2023-11-09 18:20:00 +08:00
Lokesh Gidra 2337dda4eb Remove EMPTY_GALLERY_TAG_GROUP_ARRAY
There is no reason to keep it
emptyList() always return a singleton
2023-11-09 18:20:00 +08:00
Lokesh Gidra c0a610a02b Remove GalleryApiInfo
No user now
2023-11-09 18:20:00 +08:00
Lokesh Gidra 85f7b54719 Convert GalleryComment to data class
Generate equals method for compose
2023-11-09 18:20:00 +08:00
Lokesh Gidra e0aea2fedf Convert GalleryTagGroup to data class
Generate equals method for compose
2023-11-09 18:20:00 +08:00
Lokesh Gidra ee83dbb877 Convert GalleryDetail to data class
Generate equals method for compose
2023-11-09 18:20:00 +08:00
Lokesh Gidra 1de4bc11eb Keep primary constructor only 2023-11-09 18:20:00 +08:00
FooIbar 04a7656afa Fix gallery detail refresh 2023-11-09 16:36:14 +08:00
nullArrayListandLokesh Gidra 77ec7e76e9 Refactor out animateFloatMergePredictiveBackAsState (#221)
* Refactor out animateFloatMergePredictiveBackAsState

* Revert EditTextHeight to 80dp

* Fix

* Fix animation when content too long

* Fix double navbar padding

* Fix lazycolumn jumpping

* Fix padding

* Fix padding again

* Refine

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-09 05:24:42 +00:00
FooIbar 9ca7be081a Built-in proguard rules are obsolete, use our own
Fix regression of #216
2023-11-09 10:30:27 +08:00
FooIbar fb36346f0b Fix variable shadowing (regression of #218) 2023-11-09 09:35:46 +08:00
FooIbar 95679f3fb9 Append skip H@H key to original image url on retry
Since original images may be served by H@H now
2023-11-09 01:04:49 +08:00
nullArrayListandLokesh Gidra addd4124c1 Initial Predictive back animation support for commentFragment Edittext (#217)
* Initial Predictive back animation support for commentFragment Edittext

* Fix

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-08 14:47:24 +00:00
FooIbar 74b79d9faa Use built-in proguard rules and @Keep annotation
`-keepattributes LineNumberTable,SourceFile` is not needed since R8 v8.2
APK size is increased by 1.7% though
2023-11-08 22:45:24 +08:00
nullArrayListandLokesh Gidra 9daa05ef02 Enable Predictive Back (#214)
* Enable Predictive Back

Tested with UP1A.231005.007, no bugs

* Fix Wrong SDK behaviour

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-08 19:06:41 +08:00
FooIbar 5ee884e0a2 Update thanks and Traditional Chinese README (#213)
* Update thanks and Traditional Chinese README

* Credit to Compose Destinations
2023-11-08 10:12:33 +00:00
FooIbar dba7f4e6c0 Fix inconsistent refresh state
Don't save it as `onRestoreInstanceState` will be called after loading
state has settled
2023-11-08 16:55:30 +08:00
FooIbar 8221514805 Update README.md 2023-11-08 16:54:00 +08:00
FooIbar 2ae3effc0e Move updates check to MainActivity 2023-11-08 16:09:45 +08:00
Lokesh Gidra 5b450ff18f Fix not filter all comments 2023-11-08 12:33:26 +08:00
Lokesh Gidra 92be6e9340 Explicitly reallocate lambda by key, fix download not work due to composer skip local fun reallocation 2023-11-08 12:33:26 +08:00
Lokesh Gidra 8884aeec11 Fix filter comment 2023-11-08 12:33:26 +08:00
Lokesh Gidra 8e15eda4e3 Fix navigate on IO thread crash 2023-11-08 12:33:26 +08:00
Lokesh Gidra 0dbf220655 Enable some compose compiler optimization 2023-11-08 12:33:26 +08:00
renovate[bot] fe69d59ef1 Update dependency com.android.application to v8.1.3 2023-11-07 23:30:33 +00:00
renovate[bot] 1e1f8350b5 Update dependency androidx.compose.compiler:compiler to v1.5.4 2023-11-07 19:07:38 +00:00
FooIbar afa2d03b56 Fix local favorites list jumping (#206)
* Revert "Fix local favorites scroll state loss"

This reverts commit c4be269f

* Enable placeholders as workaround

* Remove TODO

* Bump minimum fast scroller handler height to 48 dp

Make it consistent with `VerticalFastScroller`
2023-11-07 14:56:46 +00:00
FooIbar 1293d7fe28 Fix history not updated 2023-11-07 18:22:31 +08:00
renovate[bot] 62e879f2de Update dependency io.ktor:ktor-io-jvm to v2.3.6 2023-11-07 09:13:33 +00:00
Lokesh Gidra a771994f6f Introduce ActionBuilder 2023-11-07 17:13:15 +08:00
Lokesh Gidra 8c2ba051a0 Various cleanup for GalleryDetailScene 2023-11-07 17:13:15 +08:00
FooIbar ae2f289122 Make TorrentResult immutable and do some cleanup 2023-11-07 16:35:05 +08:00
nullArrayListandLokesh Gidra 703d63d106 Fragmentless GalleryDetailScene (#202)
* Initial fragmentless port

* Compile it

* Refine

* Refine

* All

* Fix

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-07 15:39:59 +08:00
FooIbar 2d7b024293 Fix unused resources inspection not working in AS (#200)
* Fix unused resources inspection not working in AS

* Remove unused resources

* Update CI

* Fix CI

* Add baseline for lint

* Test CI
2023-11-06 22:18:56 +08:00
Lokesh Gidra 26906f6709 Fix GalleryDetailScene comment ellipsize 2023-11-06 18:29:48 +08:00
Lokesh Gidra e9ec97403f Fix with missing ComposeWithMD3 2023-11-06 18:29:48 +08:00
Lokesh Gidra 34fb72707b Drop BaseToolbarScene since no user now 2023-11-06 18:29:48 +08:00
Lokesh Gidra fb7eb64c3a Drop unused xml layout 2023-11-06 18:29:48 +08:00
Lokesh Gidra ef65720999 Drop leakcanary since we will totally migrate to compose 2023-11-06 18:29:48 +08:00
Lokesh Gidra 3bc71d6798 Fix comment display limits 2023-11-06 18:29:48 +08:00
FooIbar c4be269fb7 Fix local favorites scroll state loss 2023-11-06 16:18:25 +08:00
nullArrayListandLokesh Gidra 1238edfe2b Compose GalleryCommentsScreen (#187)
* Initial Stub Compose GalleryCommentsScreen

* Initial Fab and edittext

* Refine comment edittext

* Support load more comments

* Animated loading state

* Refine, cleanup migrated things

* Initial comment dialog

* Refine

* Introduce buildAction dsl

* Impl voteComment

* Refine and cleanup migrated things

* Impl check vote status

* Refine

* Refine

* Padding a ime

* Support send commnet

* Cleanup all the code

* Let edittext and sendButton padding a navbar

* Let LazyColumn padding a ime

* Let LazyColumn contentPadding a edittext and fab

* Handle unresolved span with onCardClick

* Modify how LinkifyTextView works

* Adjust padding

* Adjust comment textstyle

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-06 03:19:59 +00:00
FooIbar 747fd23b96 Add experimental option to tweak swipe sensitivity 2023-11-05 10:57:20 +08:00
FooIbar 022f7a3ef1 Fix crash when clicking retry button in favorites 2023-11-04 23:59:40 +08:00
FooIbar e0cad8600c Fix crash caused by drawer state deserialization 2023-11-04 18:50:52 +08:00
renovate[bot] 9510ebbc31 Update dependency com.mikepenz.aboutlibraries.plugin to v10.9.2 2023-11-04 09:31:51 +00:00
FooIbar b63f2fd393 Limit request concurrency 2023-11-04 15:32:08 +08:00
FooIbar 95a0e9f03b Cleanup crash info collector 2023-11-04 14:25:01 +08:00
renovate[bot] 8265dd7f02 Update dependency com.google.devtools.ksp to v1.9.20-1.0.14 2023-11-03 03:58:14 +00:00
FooIbar 67feb19cab Fix unable to open drawer after double clicked 2023-11-03 00:46:10 +08:00
FooIbar 614e847de0 Fix incorrect favorite states in search results 2023-11-02 23:29:41 +08:00
renovate[bot] c2ff4b6a0b Update dependency com.android.tools:desugar_jdk_libs_nio to v2.0.4 2023-11-02 10:08:47 +00:00
FooIbar b273f1039c [Reader] Hide slider ticks if they are too dense 2023-11-02 18:08:31 +08:00
Lokesh Gidra bea0c6a1f0 Simplify with Fragment.ComposeWithMD3 2023-11-02 16:46:35 +08:00
Lokesh Gidra dcfaa361ec Extract a fragmentless ProgressScreen
ProgressScene.KEY_ACTION is always ProgressScene.ACTION_GALLERY_TOKEN, thus remove it
2023-11-02 16:03:03 +08:00
Lokesh Gidra 9904e166d2 Extract a fragmentless HistoryScreen 2023-11-02 15:13:52 +08:00
Lokesh Gidra 5f02769012 Extract a fragmentless GalleryPreviewScreen 2023-11-02 13:42:21 +08:00
nullArrayListandLokesh Gidra 19e5636dd2 Migrate ConfigureActivity to compose-destinations (#178)
* Add compose-destinations lib, prepare to drop fragment

* Move ConfigureActivity to compose destinations

* Restore navAnims

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-02 11:40:13 +08:00
renovate[bot] 9a220f89d9 Update androidx.navigation to v2.7.5 2023-11-01 18:01:33 +00:00
FooIbar 1c76ec813c Fix crash when clicking download notification 2023-11-02 02:01:16 +08:00
FooIbar d36c189430 Update xz to v5.4.5 2023-11-01 23:29:48 +08:00
FooIbar 61e2ace5c8 Fix insets on API < 29 2023-11-01 23:17:46 +08:00
Lokesh Gidra d6a3f21754 Fix a weird NPE 2023-11-01 22:12:07 +08:00
Lokesh Gidra 4e54d7f66f Refine FavouriteStatusRouter observable stateFlow 2023-11-01 22:12:07 +08:00
Lokesh Gidra f2dff038cd Global, CompositionLocal DialogState
Signed-off-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-01 19:37:22 +08:00
Lokesh Gidra a7b30640e0 Drop DrawerView
Signed-off-by: Lokesh Gidra <lokeshgidra@google.com>
2023-11-01 19:37:22 +08:00
FooIbar 45048b1f46 [Marshmallow] Fix crash when saving images 2023-11-01 16:26:06 +08:00
FooIbar 498129314a Refine storage permission request 2023-11-01 15:42:55 +08:00
Officer 3ee4b4a61e Cleanup ReaderActivity & Fix activity not finish when no archive passwd available 2023-11-01 15:41:23 +08:00
FooIbar ee59c0f22e Convert ReaderActivity dialog to use a DialogState 2023-11-01 12:30:36 +08:00
FooIbar c56134cd92 Fix API results parsing error on certain galleries 2023-11-01 12:17:48 +08:00
renovate[bot] b3d1b66a06 Update dependency com.google.devtools.ksp to v1.9.20-1.0.13 2023-11-01 03:49:48 +00:00
renovate[bot] c9bb81bf63 Update kotlin to v1.9.20 2023-11-01 02:41:28 +00:00
FooIbar d176810dae Update compose compiler for kotlin 1.9.20 2023-11-01 10:38:36 +08:00
FooIbar 17bdca71b1 Reduce drawer and history item gesture sensitivity 2023-11-01 09:50:25 +08:00
FooIbar 22fd3730df Sync with upstream changes and cleanup code 2023-11-01 02:37:34 +08:00
FooIbar 23d1271949 Prepare for next iteration 2023-10-31 13:30:29 +08:00
FooIbar bf6aa3e8ae Release 1.8.11.0
Releases / Build (push) Has been cancelled
2023-10-31 12:49:53 +08:00
FooIbar 75fcfde892 Always enable gestures if drawer is open 2023-10-31 11:58:13 +08:00
FooIbar 89be27995e Fix #154 regression (#158)
* Revert "Fix crash when deleting filters"

This reverts commit 717d3717ba.

* Revert "Update dependency dev.chrisbanes.compose:compose-bom to v2023.11.00-alpha02"

This reverts commit 38c18149

Reason for revert: https://issuetracker.google.com/issues/295745063#comment57
2023-10-31 03:45:25 +00:00
FooIbar 6738dc6cdc Partially enable drawer gestures for now 2023-10-31 11:34:14 +08:00
renovate[bot] 9ff0decb98 Update dependency io.coil-kt:coil-bom to v2.5.0 2023-10-30 23:02:14 +00:00
FooIbar 717d3717ba Fix crash when deleting filters 2023-10-31 03:25:25 +08:00
FooIbar fdbca869a8 Allow hiding comments below custom threshold 2023-10-31 00:44:32 +08:00
Astesia aafa750ca3 change runnable to coroutine job, and fix fragment compose memory leak (#152) 2023-10-30 22:39:29 +08:00
Astesia afe2bac672 Fix fragment compose view memory leak (#151)
* fix fragment compose view memory leak

* chang wildcard import
2023-10-30 20:07:37 +08:00
FooIbar b6f5d72db5 Fix NPE when evicting cache but Image was recycled
But why was it recycled?
2023-10-30 18:00:37 +08:00
FooIbar f0021a7601 Fix tag filters not working in thumbnail mode 2023-10-30 17:22:31 +08:00
FooIbar ea5bfbb346 Fix ViewModel memory leak 2023-10-30 12:32:11 +08:00
FooIbar 38fbcf4457 Update issue template 2023-10-30 00:48:24 +08:00
FooIbar 7c43e8f349 Avoid uncaught exceptions crashing entire app 2023-10-30 00:30:01 +08:00
FooIbar 93df4e3dd9 Revert "Scroll to top after refreshing" (#139)
* Revert "Fix progress not showing after clicking error tip"

This reverts commit bc914566dd.

* Revert "Try to fix recyclerview disappearing after refresh (#61)"

This reverts commit f9a3c3d266.

* Revert "Scroll to top after refreshing"

This reverts commit b0896ea465.
2023-10-29 14:44:42 +00:00
fricyo f7d45a52a7 Fix a strange word 2023-10-29 21:01:21 +08:00
FooIbar 328f96d115 Fix GalleryPreviewScreen insets 2023-10-29 20:57:01 +08:00
fricyo 22338a72d8 Complete the missing translations (#131)
* Complete the missing translations

* Remove the duplicate

* Update ""settings_eh_force_eh_thumb_summary""
2023-10-29 17:18:03 +08:00
FooIbar 256dedcb7c Add note for forceEhThumb 2023-10-29 16:36:49 +08:00
FooIbar 95d2438b84 Fix state recalculation after recompose 2023-10-29 16:19:59 +08:00
FooIbar f8d27e165e Update UI state on main thread and fix weight 2023-10-29 16:19:59 +08:00
FooIbar e2f024ae29 Fix reader not loading under certain circumstances (#128)
* Revert "Fix reader bug on certain orientation settings"

This reverts commit 6e59583dea.

* Revert "Skip `buildProvider` if the activity will restart"

This reverts commit a880f801fb.

* Preserve gallery provider in view model
2023-10-29 03:24:24 +08:00
FooIbar 9c28fbeaf8 Allow custom user agent string 2023-10-28 22:34:00 +08:00
FooIbar 6458eb090a Add missing color filter modes 2023-10-28 20:39:16 +08:00
FooIbar f1ca2b6958 Add animations for category chips 2023-10-28 19:34:32 +08:00
FooIbar 60f031e7e3 Fix crash when navController is not initialized 2023-10-28 13:43:32 +08:00
FooIbar e6a44ee0e1 Clarify system requirement for Marshmallow flavor 2023-10-28 02:49:27 +08:00
FooIbar 7a3a641a07 Fix crash when copying cookies 2023-10-28 02:10:13 +08:00
FooIbar 1021efb239 Fix NPE caused by coroutine cancellation 2023-10-27 23:35:23 +08:00
FooIbar edbe6509e1 Remember last viewed toplist 2023-10-27 20:37:20 +08:00
FooIbar 44a6253bfc Sync with upstream API changes 2023-10-27 19:40:41 +08:00
FooIbar 3d6e5df71e Leave 56 dp minimum edge gap for navigation drawer 2023-10-27 13:09:11 +08:00
FooIbar 2733d2e58c Convert more icons to ImageVector 2023-10-27 11:43:47 +08:00
FooIbar 0c78673870 Fix unexpected touch scale behavior in PageViewer 2023-10-27 03:11:00 +08:00
FooIbar d618948429 Set min width and max items in each row (#99)
* Set category chip to fixed width

* Set min width and max items in each row
2023-10-26 17:54:56 +00:00
FooIbar 7584704d22 Avoid race condition when updating tag database
`updateInternal` and `issueUpdateInMemoryData` will try to create tag
database directory at the same time on first launch and one of them will
fail, causing app crash
2023-10-27 00:10:59 +08:00
FooIbar 4a4658cc54 Dispatch and apply system bars insets separately 2023-10-26 22:43:43 +08:00
FooIbar e9875f1a24 Use lazy initialization instead 2023-10-26 21:01:01 +08:00
FooIbar cc11257d80 Suppress lint 2023-10-26 20:17:28 +08:00
FooIbar 4fd64601ef Use ImageVector icons 2023-10-26 19:45:29 +08:00
FooIbar 7eea490adf Drop RikkaX 2023-10-26 19:29:57 +08:00
FooIbar b2b12f7b93 Migrate from RikkaX Insets to Insetter 2023-10-26 13:22:23 +08:00
renovate[bot] 66d026cffa Update kotlin to v1.9.20-RC2 2023-10-26 04:12:31 +00:00
FooIbar 5a15a20dd6 Update compose compiler for kotlin 1.9.20-RC2 2023-10-26 12:09:43 +08:00
renovate[bot] 70450a9725 Update dependency com.google.devtools.ksp to v1.9.20-RC2-1.0.13 2023-10-26 00:43:46 +00:00
FooIbar 470a3c6a09 Move search fab to GalleryListScene 2023-10-26 00:03:04 +08:00
FooIbar 15f8b2f15d Cleanup code 2023-10-26 00:02:20 +08:00
FooIbar 717cbe7630 Request recomposition after view gets created 2023-10-25 23:51:09 +08:00
FooIbar d34075d29d Apply ime padding to suggestion list 2023-10-25 23:51:09 +08:00
FooIbar 80d4d66fb5 Dispatch insets from compose to android view 2023-10-25 23:51:09 +08:00
FooIbar 6b20b46761 Revert "Set bar style correctly"
This reverts 216a043094.

Reason for revert: Incorrect status bar color when app's night mode
setting is different from system's
2023-10-25 21:29:26 +08:00
FooIbar cd764a4a3c Fix spotless 2023-10-25 18:29:44 +08:00
FooIbar 5422b3d8bd Disable edge to edge temporarily 2023-10-25 17:42:32 +08:00
FooIbar 0273390d79 Flatten layout 2023-10-25 15:36:30 +08:00
FooIbar 8f7242b939 Bring search fab in front of search layout 2023-10-25 15:26:43 +08:00
FooIbar 246dee63b4 Move Context.findActivity to separated file 2023-10-25 13:24:28 +08:00
FooIbar 697918a73a Move navItems to top level 2023-10-25 13:11:21 +08:00
FooIbar 67e480220e Refine navigation drawer 2023-10-25 12:58:44 +08:00
FooIbar 5784eb008e Migrate navigation drawer to compose (#80)
* Drop AboveSnackerLayout

* Migrate navigation drawer to compose

* Suspend until logged in before loading

* Use AndroidViewBinding

* fixup! Migrate navigation drawer to compose

* Cleanup AboveSnackView usage

* Cleanup res

* Cleanup dependencies
2023-10-25 01:53:44 +08:00
FooIbar 82d66c4d57 Prepare for next iteration 2023-10-25 01:53:13 +08:00
368 changed files with 15577 additions and 18393 deletions

No files matched your search

+10 -1
View File
@@ -64,7 +64,7 @@ body:
description: 您可以通过设置-高级-导出日志来获得日志文件 / You can get logs file in Settings - Advanced - Dump logcat
placeholder: 06-15 17:44:53.704 23382 23382 E ...
validations:
required: false
required: true
- type: textarea
id: more
@@ -79,6 +79,15 @@ body:
validations:
required: false
- type: input
id: site
attributes:
label: 浏览站点 / Browsing site
description: E-Hentai / ExHentai
placeholder: E-Hentai
validations:
required: true
- type: input
id: version
attributes:
+1 -5
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- '*'
- 'renovate/**'
pull_request:
workflow_dispatch:
@@ -19,7 +18,7 @@ jobs:
submodules: 'recursive'
- name: Setup Java 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
@@ -48,9 +47,6 @@ jobs:
- name: Spotless Check
run: ./gradlew spotlessCheck
- name: Lint Check
run: ./gradlew lintMarshmallowGmsRelease
- name: CMake Cache
uses: actions/cache@v3
with:
+22
View File
@@ -0,0 +1,22 @@
name: 'Lock Threads'
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
issues: write
concurrency:
group: lock-threads
jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
with:
issue-inactive-days: '1'
include-any-issue-labels: 'duplicate, wontfix'
process-only: 'issues'
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
submodules: 'recursive'
- name: Setup Java 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
+77
View File
@@ -0,0 +1,77 @@
# 变更日志
## 未发布
### 重大变更
* 由于 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
### 重大变更
* 后备 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
### 重大变更
* 除阅读器外全部迁移至 Compose
### 新功能
* 设置-高级 中增加实验性滑动手势敏感度设置
* 支持通过滑动手势打开右侧抽屉
* 支持预见式返回动画,相关设置位于 设置-高级
### 改进
* \[阅读器] 画廊页数过多时隐藏进度条刻度并禁用触感反馈
* 优化原图下载重试策略
* 优化小屏设备上的搜索类别布局
* 优化过渡动画
* 优化历史记录删除动画
* 设置-EH 中的缩略图大小设置改为缩略图列数
### Bug 修复
* 修复点击通知栏的下载通知闪退 [#175](https://github.com/FooIbar/EhViewer/issues/175)
* 修复收藏状态显示错误 [#189](https://github.com/FooIbar/EhViewer/issues/189)
* 修复双击导航按钮后无法打开导航抽屉
* 修复某些情况下闪退 [#142](https://github.com/FooIbar/EhViewer/issues/142) [#208](https://github.com/FooIbar/EhViewer/issues/208)
* 修复收藏界面点击重试崩溃 [#193](https://github.com/FooIbar/EhViewer/issues/193)
* 修复本地收藏滚动位置丢失 [#192](https://github.com/FooIbar/EhViewer/issues/192)
* 修复手速过快页面切换过快使加载圈圈不自动消失 [#209](https://github.com/FooIbar/EhViewer/issues/209)
* 修复打开并关闭右侧抽屉后,状态栏一片白色 [#101](https://github.com/FooIbar/EhViewer/issues/101)
* 修复使用深色主题时,切换界面会闪过亮色界面 [#121](https://github.com/FooIbar/EhViewer/issues/121)
* \[Marshmallow] 修复小内存设备上图片解码失败 [#229](https://github.com/FooIbar/EhViewer/issues/229) [#372](https://github.com/FooIbar/EhViewer/issues/372)
* 修复已舍弃画廊上传者解析
### 已知问题
* 画廊信息对话框无法通过返回关闭 [#413](https://github.com/FooIbar/EhViewer/issues/413)
+21 -28
View File
@@ -1,15 +1,11 @@
# Maintenance Mode
The project is in maintenance mode, will only receive bugfixes.
<p align="right">
<strong>English</strong>
<span> | </span>
<a href="https://github.com/FooIbar/EhViewer/blob/1.8.x.x/docs/README/zh-cn.md">
<a href="/docs/README/zh-cn.md">
简体中文
</a>
<span> | </span>
<a href="https://github.com/FooIbar/EhViewer/blob/1.8.x.x/docs/README/zh-tw.md">
<a href="/docs/README/zh-tw.md">
正體中文
</a>
</p>
@@ -23,15 +19,12 @@ The project is in maintenance mode, will only receive bugfixes.
<a href="https://github.com/FooIbar/EhViewer/actions/workflows/ci.yml">
<img src="https://github.com/FooIbar/EhViewer/actions/workflows/ci.yml/badge.svg" alt="Github Actions">
</a>
<a href="https://github.com/FooIbar/EhViewer/blob/1.8.x.x/LICENSE">
<a href="/LICENSE">
<img src="https://img.shields.io/github/license/FooIbar/EhViewer" alt="LICENSE">
</a>
<a href="https://www.codefactor.io/repository/github/FooIbar/EhViewer">
<img src="https://www.codefactor.io/repository/github/FooIbar/EhViewer/badge" alt="CodeFactor">
</a>
<a href="https://github.com/FooIbar/EhViewer/releases">
<img src="https://img.shields.io/github/downloads/FooIbar/EhViewer/total.svg" alt="Downloads">
</a>
<a href="https://github.com/FooIbar/EhViewer/releases">
<img src="https://img.shields.io/github/v/release/FooIbar/EhViewer" alt="Release">
</a>
@@ -42,23 +35,23 @@ The project is in maintenance mode, will only receive bugfixes.
<div align="center">
<h3>
<a href="https://github.com/FooIbar/EhViewer#description">
<a href="#description">
Description
</a>
<span> | </span>
<a href="https://github.com/FooIbar/EhViewer#download">
<a href="#download">
Download
</a>
<span> | </span>
<a href="https://github.com/FooIbar/EhViewer#screenshot">
<a href="#screenshot">
Screenshot
</a>
<span> | </span>
<a href="https://github.com/FooIbar/EhViewer#thanks">
<a href="#thanks">
Thanks
</a>
<span> | </span>
<a href="https://github.com/FooIbar/EhViewer#license">
<a href="#license">
License
</a>
</h3>
@@ -73,12 +66,12 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
# Download
| Flavor | Feature |
|-|-|
| Default | Android 9+, full support |
| Marshmallow | Android 6.0+, limited support* |
| OSS | FOSS dependencies** |
| GMS | Proprietary GMS component |
| Flavor | Feature |
|-------------|-----------------------------------|
| Default | Android 9+, full support |
| Marshmallow | Android 6.0-8.1, limited support* |
| OSS | FOSS dependencies** |
| GMS | Proprietary GMS component |
\* Archives and GIFs are not supported
@@ -100,14 +93,14 @@ Here is the libraries
- [Arrow](https://arrow-kt.io/)
- [AOSP & AndroidX](http://source.android.com/)
- [Kotlin & KotlinX](https://kotlinlang.org/)
- [FullDraggableDrawer](https://github.com/PureWriter/FullDraggableDrawer)
- [MCA](https://github.com/material-components/material-components-android)
- [material-design-icons](https://github.com/google/material-design-icons)
- [Okhttp](https://square.github.io/okhttp/)
- [RikkaX](https://github.com/RikkaApps/RikkaX)
- [Libarchive](http://www.libarchive.org/)
- [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/)
- [Jsoup](https://jsoup.org/)
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
- [libarchive](http://www.libarchive.org/)
- [jsoup](https://jsoup.org/)
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
+25 -25
View File
@@ -64,8 +64,8 @@ android {
applicationId = "moe.tarsin.ehviewer"
minSdk = 28
targetSdk = 34
versionCode = 180045
versionName = "1.8.10.3"
versionCode = 180048
versionName = "1.10.1"
resourceConfigurations.addAll(
listOf(
"zh",
@@ -108,10 +108,6 @@ android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
lint {
checkOnly += setOf("InlinedApi", "NewApi", "UnusedAttribute")
error += setOf("InlinedApi", "UnusedAttribute")
}
}
create("oss") {
dimension = "oss"
@@ -135,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",
@@ -149,13 +145,14 @@ android {
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
)
}
lint {
abortOnError = true
checkReleaseBuilds = false
disable.add("MissingTranslation")
disable += setOf("MissingTranslation", "MissingQuantity")
fatal += setOf("NewApi", "InlineApi")
}
packaging {
@@ -202,40 +199,38 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.biometric)
implementation(libs.androidx.browser)
implementation(libs.androidx.collection)
// https://developer.android.com/jetpack/androidx/releases/compose-material3
api(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.compose.destinations.core)
ksp(libs.compose.destinations.compiler)
implementation(libs.androidx.core)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.coordinatorlayout)
implementation(libs.androidx.fragment)
// https://developer.android.com/jetpack/androidx/releases/lifecycle
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// https://developer.android.com/jetpack/androidx/releases/navigation
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.bundles.androidx.recyclerview)
implementation(libs.androidx.recyclerview)
// https://developer.android.com/jetpack/androidx/releases/room
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.work.runtime)
implementation(libs.drawer)
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.bundles.splitties)
@@ -246,10 +241,13 @@ dependencies {
implementation(libs.okio.jvm)
implementation(libs.diff)
implementation(libs.aboutlibraries.core)
implementation(libs.insetter) // Dead Dependency
implementation(libs.bundles.rikkax)
implementation(libs.reorderable)
implementation(platform(libs.arrow.stack))
implementation(libs.arrow.fx.coroutines)
@@ -263,18 +261,20 @@ dependencies {
implementation(libs.bundles.kotlinx.serialization)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.jsoup)
debugImplementation(libs.chucker)
releaseImplementation(libs.chucker.nop)
debugImplementation(libs.leakcanary.android)
coreLibraryDesugaring(libs.desugar)
"gmsImplementation"(libs.bundles.cronet)
}
configurations.all {
resolutionStrategy {
exclude("androidx.navigation", "navigation-compose")
}
}
kotlin {
jvmToolchain(17)
}
+8 -18
View File
@@ -1,30 +1,20 @@
-keepclassmembers class * implements android.os.Parcelable {
public static final ** CREATOR;
}
-keepclasseswithmembernames,includedescriptorclasses class * {
native <methods>;
}
# https://issuetracker.google.com/issues/190382641
-keepclassmembers class kotlin.SafePublicationLazyImpl {
java.lang.Object _value;
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
-keep class androidx.viewpager.widget.ViewPager$LayoutParams { int position; }
-keep class com.hippo.ehviewer.client.parser.Torrent { *; }
-keep class com.hippo.ehviewer.client.parser.GalleryListResult { *; }
-keep class com.hippo.ehviewer.client.parser.Limits { *; }
-keep class com.hippo.ehviewer.client.data.BaseGalleryInfo { *; }
-keep class com.hippo.ehviewer.ui.scene.SearchBarScene { getBinding(); }
-keepclassmembers class * implements android.os.Parcelable {
public static final ** CREATOR;
}
# Ktor logger
-dontwarn org.slf4j.impl.StaticLoggerBinder
-keepattributes LineNumberTable,SourceFile
-renamesourcefileattribute SourceFile
-dontwarn org.conscrypt.Conscrypt
-repackageclasses
-allowaccessmodification
-overloadaggressively
-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,86 +1,20 @@
package com.hippo.ehviewer.cronet
import com.hippo.ehviewer.client.CHROME_ACCEPT
import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
import com.hippo.ehviewer.client.CHROME_USER_AGENT
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 com.hippo.ehviewer.Settings
import okio.Path.Companion.toOkioPath
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException
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(CHROME_USER_AGENT)
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) {
data.flip()
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, 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) }
}.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.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)
}
+4 -2
View File
@@ -23,16 +23,20 @@
android:allowBackup="true"
android:appCategory="image"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_scheme"
android:hasFragileUserData="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="@bool/large_heap"
android:localeConfig="@xml/locale_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
tools:targetApi="tiramisu">
<profileable android:shell="true" />
<activity
android:name=".ui.ConfigureActivity"
android:exported="false"
@@ -135,8 +139,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"
+1 -1
View File
@@ -24,7 +24,7 @@ set(BUILD_TESTING 0)
FetchContent_Declare(liblzma
GIT_REPOSITORY https://github.com/FooIbar/xz.git
GIT_TAG v5.4.4
GIT_TAG v5.4.5
)
FetchContent_MakeAvailable(liblzma)
@@ -0,0 +1,989 @@
@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
}
}
@@ -0,0 +1,535 @@
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
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.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.horizontalDrag
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.CornerSize
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.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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
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
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
private const val DrawerPositionalThreshold = 0.5f
private val DrawerVelocityThreshold = 400.dp
private val ContainerWidth = 360.0.dp
@Suppress("NotCloseable")
@Stable
class DrawerState2(
initialValue: DrawerValue,
val confirmStateChange: (DrawerValue) -> Boolean = { true },
) {
val anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
animationSpec = AnimationSpec,
confirmValueChange = confirmStateChange,
positionalThreshold = { distance -> distance * DrawerPositionalThreshold },
velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } },
)
/**
* Whether the drawer is open.
*/
val isOpen: Boolean
get() = currentValue == DrawerValue.Open
/**
* Whether the drawer is closed.
*/
val isClosed: Boolean
get() = currentValue == DrawerValue.Closed
/**
* The current value of the state.
*
* If no swipe or animation is in progress, this corresponds to the start the drawer
* currently in. If a swipe or an animation is in progress, this corresponds the state drawer
* was in before the swipe or animation started.
*/
val currentValue: DrawerValue
get() {
return anchoredDraggableState.currentValue
}
/**
* Whether the state is currently animating.
*/
val isAnimationRunning: Boolean
get() {
return anchoredDraggableState.isAnimationRunning
}
/**
* Open the drawer with animation and suspend until it if fully opened or animation has been
* cancelled. This method will throw [CancellationException] if the animation is
* interrupted
*
* @return the reason the open animation ended
*/
suspend fun open() = animateTo(DrawerValue.Open)
/**
* Close the drawer with animation and suspend until it if fully closed or animation has been
* cancelled. This method will throw [CancellationException] if the animation is
* interrupted
*
* @return the reason the close animation ended
*/
suspend fun close() = animateTo(DrawerValue.Closed)
/**
* Set the state of the drawer with specific animation
*
* @param targetValue The new value to animate to.
* @param anim The animation that will be used to animate to the new value.
*/
@Deprecated(
message = "This method has been replaced by the open and close methods. The animation " +
"spec is now an implementation detail of ModalDrawer.",
)
suspend fun animateTo(targetValue: DrawerValue, anim: AnimationSpec<Float>) {
animateTo(targetValue = targetValue, animationSpec = anim)
}
/**
* Set the state without any animation and suspend until it's set
*
* @param targetValue The new target value
*/
suspend fun snapTo(targetValue: DrawerValue) {
anchoredDraggableState.snapTo(targetValue)
}
/**
* The target value of the drawer state.
*
* If a swipe is in progress, this is the value that the Drawer 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: DrawerValue
get() = anchoredDraggableState.targetValue
/**
* The current position (in pixels) of the drawer sheet, or Float.NaN before the offset is
* initialized.
*
* @see [AnchoredDraggableState.offset] for more information.
*/
@Deprecated(
message = "Please access the offset through currentOffset, which returns the value " +
"directly instead of wrapping it in a state object.",
replaceWith = ReplaceWith("currentOffset"),
)
val offset: State<Float> = object : State<Float> {
override val value: Float get() = anchoredDraggableState.offset
}
/**
* The current position (in pixels) of the drawer sheet, or Float.NaN before the offset is
* initialized.
*
* @see [AnchoredDraggableState.offset] for more information.
*/
val currentOffset: Float get() = anchoredDraggableState.offset
internal var density: Density? by mutableStateOf(null)
private fun requireDensity() = requireNotNull(density) {
"The density on BottomDrawerState ($this) was not set. Did you use BottomDrawer" +
" with the BottomDrawer composable?"
}
internal fun requireOffset(): Float = anchoredDraggableState.requireOffset()
private suspend fun animateTo(
targetValue: DrawerValue,
animationSpec: AnimationSpec<Float> = AnimationSpec,
velocity: Float = anchoredDraggableState.lastVelocity,
) {
anchoredDraggableState.anchoredDrag(targetValue = targetValue) { anchors, latestTarget ->
val targetOffset = anchors.positionOf(latestTarget)
if (!targetOffset.isNaN()) {
var prev = if (currentOffset.isNaN()) 0f else currentOffset
animate(prev, targetOffset, velocity, animationSpec) { value, velocity ->
// Our onDrag coerces the value within the bounds, but an animation may
// overshoot, for example a spring animation or an overshooting interpolator
// We respect the user's intention and allow the overshoot, but still use
// DraggableState's drag for its mutex.
dragTo(value, velocity)
prev = value
}
}
}
}
companion object {
/**
* The default [Saver] implementation for [DrawerState2].
*/
fun Saver(confirmStateChange: (DrawerValue) -> Boolean) =
Saver<DrawerState2, DrawerValue>(
save = { it.currentValue },
restore = { DrawerState2(it, confirmStateChange) },
)
}
}
/**
* Create and [remember] a [DrawerState2].
*
* @param initialValue The initial value of the state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
fun rememberDrawerState(
initialValue: DrawerValue,
confirmStateChange: (DrawerValue) -> Boolean = { true },
): DrawerState2 {
return rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) {
DrawerState2(initialValue, confirmStateChange)
}
}
@Composable
fun ModalNavigationDrawer(
drawerContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
gesturesEnabled: Boolean = true,
scrimColor: Color = DrawerDefaults.scrimColor,
content: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var minValue by remember { mutableStateOf(-with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
SideEffect {
drawerState.density = density
drawerState.anchoredDraggableState.updateAnchors(
DraggableAnchors {
DrawerValue.Closed at minValue
DrawerValue.Open at maxValue
},
)
}
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val velocityTracker = remember { VelocityTracker() }
val dragModifier = if (gesturesEnabled) {
Modifier.pointerInput(isRtl) {
val multiplier = if (isRtl) -1 else 1
awaitEachGesture {
fun canConsume(slop: Float) = (slop > 0 && drawerState.isClosed) || (slop < 0 && drawerState.isOpen)
val down = awaitFirstDown(requireUnconsumed = false)
var ignore = false
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
val overSlop = over * multiplier
val canConsume = canConsume(overSlop)
if (canConsume && !ignore) {
velocityTracker.addPointerInputChange(change)
change.consume()
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
} else {
ignore = true
}
}
if (drag != null) {
horizontalDrag(drag.id) {
velocityTracker.addPointerInputChange(it)
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
}
val velocity = velocityTracker.calculateVelocity()
velocityTracker.resetTracking()
scope.launch {
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
}
}
}
}
} else {
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 {
if (radius != 0.dp) {
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
shape = RectangleShape
clip = true
}
}
Box(modifier = blurModifier) {
content()
}
Scrim(
open = drawerState.isOpen,
onClose = {
if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) {
scope.launch { drawerState.close() }
}
},
fraction = {
calculateFraction(minValue, maxValue, drawerState.requireOffset())
},
color = scrimColor,
)
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(drawerState.isOpen) {
drawerState.close()
}
val absoluteElevation = LocalAbsoluteTonalElevation.current + DrawerDefaults.ModalDrawerElevation
val predictiveModifier = if (drawerState.isOpen) {
if (predictiveState > 0) {
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val multiplierX = lerp(1f, 1.05f, predictiveState)
val multiplierY = lerp(1f, 0.95f, predictiveState)
val scaledWidth = (multiplierX * placeable.width).roundToInt()
val scaledHeight = (multiplierY * placeable.height).roundToInt()
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
layout(scaledWidth, scaledHeight) {
reMeasured.placeRelative(scaledWidth - placeable.width, 0)
}
}.background(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
shape = DrawerDefaults.shape,
)
} else {
Modifier.graphicsLayer {
val scale = lerp(1f, 0.95f, -predictiveState)
scaleX = scale
scaleY = scale
}.offset {
IntOffset(lerp(0f, minValue * 0.05f, -predictiveState).roundToInt(), 0)
}
}
} else {
Modifier.onGloballyPositioned {
val w = it.size.width
if (w != 0) {
minValue = -it.size.width.toFloat()
}
}
}
Box(
modifier = Modifier.offset {
IntOffset(drawerState.requireOffset().roundToInt(), 0)
} then predictiveModifier.align(Alignment.CenterStart),
contentAlignment = Alignment.CenterEnd,
) {
drawerContent()
}
}
}
@Composable
fun SideDrawer(
drawerContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
gesturesEnabled: Boolean = true,
scrimColor: Color = DrawerDefaults.scrimColor,
content: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var minValue by remember { mutableStateOf(with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
SideEffect {
drawerState.density = density
drawerState.anchoredDraggableState.updateAnchors(
DraggableAnchors {
DrawerValue.Closed at minValue
DrawerValue.Open at maxValue
},
)
}
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val velocityTracker = remember { VelocityTracker() }
val dragModifier = if (gesturesEnabled) {
Modifier.pointerInput(isRtl) {
val multiplier = if (isRtl) -1 else 1
awaitEachGesture {
fun canConsume(slop: Float) = (slop > 0 && drawerState.isOpen) || (slop < 0 && drawerState.isClosed)
val down = awaitFirstDown(requireUnconsumed = false)
var ignore = false
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
val overSlop = over * multiplier
val canConsume = canConsume(overSlop)
if (canConsume && !ignore) {
velocityTracker.addPointerInputChange(change)
change.consume()
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
} else {
ignore = true
}
}
if (drag != null) {
horizontalDrag(drag.id) {
velocityTracker.addPointerInputChange(it)
drag.consume()
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
}
val velocity = velocityTracker.calculateVelocity()
velocityTracker.resetTracking()
scope.launch {
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
}
}
}
}
} else {
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 {
if (radius != 0.dp) {
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
shape = RectangleShape
clip = true
}
}
Box(modifier = blurModifier) {
content()
}
Scrim(
open = drawerState.isOpen,
onClose = {
if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) {
scope.launch { drawerState.close() }
}
},
fraction = {
calculateFraction(minValue, maxValue, drawerState.requireOffset())
},
color = scrimColor,
)
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(drawerState.isOpen) {
drawerState.close()
}
val absoluteElevation = LocalAbsoluteTonalElevation.current + DrawerDefaults.ModalDrawerElevation
val predictiveModifier = if (drawerState.isOpen) {
if (predictiveState < 0) {
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val multiplierX = lerp(1f, 1.05f, -predictiveState)
val multiplierY = lerp(1f, 0.95f, -predictiveState)
val scaledWidth = (multiplierX * placeable.width).roundToInt()
val scaledHeight = (multiplierY * placeable.height).roundToInt()
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
layout(scaledWidth, scaledHeight) {
reMeasured.placeRelative(-(scaledWidth - placeable.width), 0)
}
}.background(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
shape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
)
} else {
Modifier.graphicsLayer {
val scale = lerp(1f, 0.95f, predictiveState)
scaleX = scale
scaleY = scale
}.offset {
IntOffset(lerp(0f, minValue * 0.05f, predictiveState).roundToInt(), 0)
}
}
} else {
Modifier.onGloballyPositioned {
val w = it.size.width
if (w != 0) {
minValue = it.size.width.toFloat()
}
}
}
Box(
modifier = Modifier.offset {
IntOffset(drawerState.requireOffset().roundToInt(), 0)
}.align(Alignment.CenterEnd) then predictiveModifier,
contentAlignment = Alignment.CenterStart,
) {
drawerContent()
}
}
}
@Suppress("SameParameterValue")
private fun calculateFraction(a: Float, b: Float, pos: Float) = ((pos - a) / (b - a)).coerceIn(0f, 1f)
@Composable
private fun Scrim(open: Boolean, onClose: () -> Unit, fraction: () -> Float, color: Color) {
val dismissDrawer = if (open) {
Modifier.pointerInput(onClose) { detectTapGestures { onClose() } }
} else {
Modifier
}
Canvas(Modifier.fillMaxSize().then(dismissDrawer)) {
drawRect(color, alpha = fraction())
}
}
@@ -0,0 +1,502 @@
/*
* Copyright 2022 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.
*/
@file:Suppress("ktlint:standard:property-naming")
package androidx.compose.material3
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.MutableWindowInsets
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text2.BasicTextField2
import androidx.compose.foundation.text2.input.TextFieldLineLimits
import androidx.compose.foundation.text2.input.TextFieldState
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.offset
import androidx.compose.ui.util.lerp
import androidx.compose.ui.zIndex
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sign
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
/**
* <a href="https://m3.material.io/components/search/overview" class="external" target="_blank">Material Design search</a>.
*
* A search bar represents a floating search field that allows users to enter a keyword or phrase
* and get relevant information. It can be used as a way to navigate through an app via search
* queries.
*
* An active search bar expands into a search "view" and can be used to display dynamic suggestions.
*
* ![Search bar image](https://developer.android.com/images/reference/androidx/compose/material3/search-bar.png)
*
* A [SearchBar] expands to occupy the entirety of its allowed size when active. For full-screen
* behavior as specified by Material guidelines, parent layouts of the [SearchBar] must not pass
* any [Constraints] that limit its size, and the host activity should set
* `WindowCompat.setDecorFitsSystemWindows(window, false)`.
*
* If this expansion behavior is undesirable, for example on large tablet screens, [DockedSearchBar]
* can be used instead.
*
* @param state the query text to be shown in the search bar's input field
* @param onSearch the callback to be invoked when the input service triggers the [ImeAction.Search]
* action.
* @param active whether this search bar is active
* @param onActiveChange the callback to be invoked when this search bar's active state is changed
* @param modifier the [Modifier] to be applied to this search bar
* @param enabled controls the enabled state of this search bar. When `false`, this component will
* not respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param placeholder the placeholder to be displayed when the search bar's query is empty.
* @param leadingIcon the leading icon to be displayed at the beginning of the search bar container
* @param trailingIcon the trailing icon to be displayed at the end of the search bar container
* @param shape the shape of this search bar when it is not [active]. When [active], the shape will
* always be [SearchBarDefaults.fullScreenShape].
* @param colors [SearchBarColors] that will be used to resolve the colors used for this search bar
* in different states. See [SearchBarDefaults.colors].
* @param tonalElevation when [SearchBarColors.containerColor] is [ColorScheme.surface], a
* translucent primary color overlay is applied on top of the container. A higher tonal elevation
* value will result in a darker color in light theme and lighter color in dark theme. See also:
* [Surface].
* @param shadowElevation the elevation for the shadow below the search bar
* @param windowInsets the window insets that the search bar will respect
* @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
* for this search bar. You can create and pass in your own `remember`ed instance to observe
* [Interaction]s and customize the appearance / behavior of this search bar in different states.
* @param content the content of this search bar that will be displayed below the input field
*/
@ExperimentalMaterial3Api
@Composable
fun SearchBar(
state: TextFieldState,
onSearch: () -> Unit,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
title: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
shape: Shape = SearchBarDefaults.inputFieldShape,
colors: SearchBarColors = SearchBarDefaults.colors(),
tonalElevation: Dp = SearchBarDefaults.TonalElevation,
shadowElevation: Dp = SearchBarDefaults.ShadowElevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable ColumnScope.() -> Unit,
) {
val animationProgress = remember { Animatable(initialValue = if (active) 1f else 0f) }
var firstBackEvent by remember { mutableStateOf<BackEventCompat?>(null) }
var currentBackEvent by remember { mutableStateOf<BackEventCompat?>(null) }
val focusManager = LocalFocusManager.current
val density = LocalDensity.current
val defaultInputFieldShape = SearchBarDefaults.inputFieldShape
val defaultFullScreenShape = SearchBarDefaults.fullScreenShape
val useFullScreenShape by remember {
derivedStateOf(structuralEqualityPolicy()) { animationProgress.value == 1f }
}
val animatedShape = remember(useFullScreenShape, shape) {
when {
shape == defaultInputFieldShape ->
// The shape can only be animated if it's the default spec value
GenericShape { size, _ ->
val radius = with(density) {
(SearchBarCornerRadius * (1 - animationProgress.value)).toPx()
}
addRoundRect(RoundRect(size.toRect(), CornerRadius(radius)))
}
useFullScreenShape -> defaultFullScreenShape
else -> shape
}
}
// The main animation complexity is allowing the component to smoothly expand while keeping the
// input field at the same relative location on screen. `Modifier.windowInsetsPadding` does not
// support animation and thus is not suitable. Instead, we convert the insets to a padding
// applied to the Surface, which gradually becomes padding applied to the input field as the
// animation proceeds.
val unconsumedInsets = remember { MutableWindowInsets() }
val topPadding = remember(density) {
derivedStateOf {
SearchBarVerticalPadding +
unconsumedInsets.asPaddingValues(density).calculateTopPadding()
}
}
Surface(
shape = animatedShape,
color = colors.containerColor,
contentColor = contentColorFor(colors.containerColor),
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
modifier = modifier
.padding(horizontal = lerp(SearchBarHorizontalPadding, 0.dp, animationProgress.value))
.zIndex(1f)
.onConsumedWindowInsetsChanged { consumedInsets ->
unconsumedInsets.insets = windowInsets.exclude(consumedInsets)
}
.consumeWindowInsets(unconsumedInsets)
.layout { measurable, constraints ->
val animatedTopPadding =
lerp(topPadding.value, 0.dp, animationProgress.value).roundToPx()
val startWidth: Int
val startHeight: Int
if (currentBackEvent == null) {
startWidth = max(constraints.minWidth, SearchBarMinWidth.roundToPx())
.coerceAtMost(min(constraints.maxWidth, SearchBarMaxWidth.roundToPx()))
startHeight = max(constraints.minHeight, InputFieldHeight.roundToPx())
.coerceAtMost(constraints.maxHeight)
} else {
startWidth =
(constraints.maxWidth * SearchBarPredictiveBackMinScale).roundToInt()
startHeight =
(constraints.maxHeight * SearchBarPredictiveBackMinScale).roundToInt()
}
val endWidth = constraints.maxWidth
val endHeight = constraints.maxHeight
val width = lerp(startWidth, endWidth, animationProgress.value)
val height =
lerp(startHeight, endHeight, animationProgress.value) + animatedTopPadding
val minOffsetMargin = SearchBarPredictiveBackMinMargin.roundToPx()
val predictiveBackOffsetX = calculatePredictiveBackOffsetX(
constraints,
minOffsetMargin,
currentBackEvent,
animationProgress.value,
)
val predictiveBackOffsetY = calculatePredictiveBackOffsetY(
constraints,
minOffsetMargin,
currentBackEvent,
firstBackEvent,
height,
SearchBarPredictiveBackMaxOffsetY.roundToPx(),
)
val placeable = measurable.measure(
Constraints
.fixed(width, height)
.offset(
vertical = -animatedTopPadding,
),
)
layout(width, height) {
placeable.placeRelative(
predictiveBackOffsetX,
animatedTopPadding + predictiveBackOffsetY,
)
}
},
) {
Column {
val animatedInputFieldPadding = remember {
AnimatedPaddingValues2(animationProgress.asState(), topPadding)
}
SearchBarInputField(
state = state,
onSearch = onSearch,
onActiveChange = onActiveChange,
modifier = Modifier.padding(paddingValues = animatedInputFieldPadding),
enabled = enabled,
label = title.takeUnless { active || animationProgress.value > 0 || state.text.isNotEmpty() },
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
colors = colors.inputFieldColors,
interactionSource = interactionSource,
)
val showResults by remember {
derivedStateOf(structuralEqualityPolicy()) { animationProgress.value > 0 }
}
if (showResults) {
Column(Modifier.graphicsLayer { alpha = animationProgress.value }) {
HorizontalDivider(color = colors.dividerColor)
content()
}
}
}
}
val isFocused = interactionSource.collectIsFocusedAsState().value
val shouldClearFocus = !active && isFocused
if (shouldClearFocus) {
LaunchedEffect(true) {
// Not strictly needed according to the motion spec, but since the animation already has
// a delay, this works around b/261632544.
delay(AnimationDelayMillis.toLong())
focusManager.clearFocus()
}
}
LaunchedEffect(active) {
val animationInProgress = animationProgress.value > 0 && animationProgress.value < 1
val animationSpec =
if (animationInProgress) {
AnimationPredictiveBackExitFloatSpec
} else if (active) {
AnimationEnterFloatSpec
} else {
AnimationExitFloatSpec
}
animationProgress.animateTo(
targetValue = if (active) 1f else 0f,
animationSpec = animationSpec,
)
}
val mutatorMutex = remember { MutatorMutex() }
if (active) {
PredictiveBackHandler(true) { progress ->
mutatorMutex.mutate {
var canceled = false
try {
progress.collect { backEvent ->
if (firstBackEvent == null) {
firstBackEvent = backEvent
}
currentBackEvent = backEvent
val interpolatedProgress = EaseOut.transform(backEvent.progress)
animationProgress.snapTo(targetValue = 1 - interpolatedProgress)
}
} catch (e: CancellationException) {
canceled = true
} finally {
firstBackEvent = null
currentBackEvent = null
onActiveChange(canceled)
}
}
}
}
}
private fun calculatePredictiveBackOffsetX(
constraints: Constraints,
minMargin: Int,
currentBackEvent: BackEventCompat?,
progress: Float,
): Int {
if (currentBackEvent == null) {
return 0
}
val directionMultiplier = if (currentBackEvent.swipeEdge == BackEventCompat.EDGE_LEFT) 1 else -1
val maxOffsetX =
(constraints.maxWidth * SearchBarPredictiveBackMaxOffsetXRatio) - minMargin
return (maxOffsetX * (1 - progress)).toInt() * directionMultiplier
}
private fun calculatePredictiveBackOffsetY(
constraints: Constraints,
minMargin: Int,
currentBackEvent: BackEventCompat?,
firstBackEvent: BackEventCompat?,
height: Int,
maxOffsetY: Int,
): Int {
if (firstBackEvent == null || currentBackEvent == null) {
return 0
}
val availableVerticalSpace = max(0, (constraints.maxHeight - height) / 2 - minMargin)
val adjustedMaxOffsetY = min(availableVerticalSpace, maxOffsetY)
val yDelta = currentBackEvent.touchY - firstBackEvent.touchY
val yProgress = abs(yDelta) / constraints.maxHeight
val directionMultiplier = sign(yDelta)
return (lerp(0, adjustedMaxOffsetY, yProgress) * directionMultiplier).roundToInt()
}
@Composable
private fun SearchBarInputField(
state: TextFieldState,
onSearch: () -> Unit,
onActiveChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
colors: TextFieldColors = SearchBarDefaults.inputFieldColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focusRequester = remember { FocusRequester() }
val focused by interactionSource.collectIsFocusedAsState()
val textColor = LocalTextStyle.current.color.takeOrElse {
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
}
BasicTextField2(
state = state,
modifier = modifier
.height(InputFieldHeight)
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { if (it.isFocused) onActiveChange(true) }
.semantics {
onClick {
focusRequester.requestFocus()
true
}
},
enabled = enabled,
lineLimits = TextFieldLineLimits.SingleLine,
textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)),
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
interactionSource = interactionSource,
decorator = @Composable { innerTextField ->
TextFieldDefaults.DecorationBox(
value = state.text.toString(),
innerTextField = innerTextField,
enabled = enabled,
singleLine = true,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon?.let { leading ->
{
Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() }
}
},
trailingIcon = trailingIcon?.let { trailing ->
{
Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() }
}
},
shape = SearchBarDefaults.inputFieldShape,
colors = colors,
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
container = {},
)
},
)
}
@Stable
private class AnimatedPaddingValues2(
val animationProgress: State<Float>,
val topPadding: State<Dp>,
) : PaddingValues {
override fun calculateTopPadding(): Dp = topPadding.value * animationProgress.value
override fun calculateBottomPadding(): Dp = SearchBarVerticalPadding * animationProgress.value
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = 0.dp
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = 0.dp
}
// Measurement specs
private val SearchBarCornerRadius: Dp = InputFieldHeight / 2
internal val SearchBarMinWidth: Dp = 360.dp
private val SearchBarMaxWidth: Dp = 720.dp
internal val SearchBarVerticalPadding: Dp = 8.dp
internal val SearchBarHorizontalPadding: Dp = 16.dp
// Search bar has 16dp padding between icons and start/end, while by default text field has 12dp.
private val SearchBarIconOffsetX: Dp = 4.dp
private const val SearchBarPredictiveBackMinScale: Float = 9f / 10f
private val SearchBarPredictiveBackMinMargin: Dp = 8.dp
private const val SearchBarPredictiveBackMaxOffsetXRatio: Float = 1f / 20f
private val SearchBarPredictiveBackMaxOffsetY: Dp = 24.dp
// Animation specs
private const val AnimationEnterDurationMillis: Int = 600
private const val AnimationExitDurationMillis: Int = 350
private const val AnimationDelayMillis: Int = 100
private val AnimationEnterEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f)
private val AnimationExitEasing = CubicBezierEasing(0.0f, 1.0f, 0.0f, 1.0f)
private val AnimationEnterFloatSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = AnimationEnterDurationMillis,
delayMillis = AnimationDelayMillis,
easing = AnimationEnterEasing,
)
private val AnimationExitFloatSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = AnimationExitDurationMillis,
delayMillis = AnimationDelayMillis,
easing = AnimationExitEasing,
)
private val AnimationPredictiveBackExitFloatSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = AnimationExitDurationMillis,
easing = AnimationExitEasing,
)
@@ -0,0 +1,63 @@
/*
* Copyright 2023 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.navigation.compose
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraph
import androidx.navigation.NavGraphNavigator
import androidx.navigation.Navigator
import androidx.navigation.NavigatorProvider
/**
* Custom subclass of [NavGraphNavigator] that adds support for defining
* transitions at the navigation graph level.
*/
@Navigator.Name("navigation")
internal class ComposeNavGraphNavigator(
navigatorProvider: NavigatorProvider,
) : NavGraphNavigator(navigatorProvider) {
override fun createDestination(): NavGraph {
return ComposeNavGraph(this)
}
internal class ComposeNavGraph(
navGraphNavigator: Navigator<out NavGraph>,
) : NavGraph(navGraphNavigator) {
internal var enterTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? = null
internal var exitTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? = null
internal var popEnterTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? = null
internal var popExitTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? = null
}
}
@@ -0,0 +1,128 @@
/*
* Copyright 2020 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.navigation.compose
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavOptions
import androidx.navigation.Navigator
import androidx.navigation.compose.ComposeNavigator.Destination
/**
* Navigator that navigates through [Composable]s. Every destination using this Navigator must
* set a valid [Composable] by setting it directly on an instantiated [Destination] or calling
* [composable].
*/
@Navigator.Name("composable")
public class ComposeNavigator : Navigator<Destination>() {
/**
* Get the map of transitions currently in progress from the [state].
*/
internal val transitionsInProgress get() = state.transitionsInProgress
/**
* Get the back stack from the [state].
*/
public val backStack get() = state.backStack
internal val isPop = mutableStateOf(false)
override fun navigate(
entries: List<NavBackStackEntry>,
navOptions: NavOptions?,
navigatorExtras: Extras?,
) {
entries.forEach { entry ->
state.pushWithTransition(entry)
}
isPop.value = false
}
override fun createDestination(): Destination {
return Destination(this) { }
}
override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
state.popWithTransition(popUpTo, savedState)
isPop.value = true
}
/**
* Callback to mark a navigation in transition as complete.
*
* This should be called in conjunction with [navigate] and [popBackStack] as those
* calls merely start a transition to the target destination, and requires manually marking
* the transition as complete by calling this method.
*
* Failing to call this method could result in entries being prevented from reaching their
* final [Lifecycle.State].
*/
public fun onTransitionComplete(entry: NavBackStackEntry) {
state.markTransitionComplete(entry)
}
/**
* NavDestination specific to [ComposeNavigator]
*/
@NavDestination.ClassType(Composable::class)
public class Destination(
navigator: ComposeNavigator,
internal val content:
@Composable AnimatedContentScope.(@JvmSuppressWildcards NavBackStackEntry) -> Unit,
) : NavDestination(navigator) {
@Deprecated(
message = "Deprecated in favor of Destination that supports AnimatedContent",
level = DeprecationLevel.HIDDEN,
)
constructor(
navigator: ComposeNavigator,
content: @Composable (NavBackStackEntry) -> @JvmSuppressWildcards Unit,
) : this(navigator, content = { entry -> content(entry) })
internal var enterTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? = null
internal var exitTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? = null
internal var popEnterTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? = null
internal var popExitTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? = null
}
internal companion object {
internal const val NAME = "composable"
}
}
@@ -0,0 +1,137 @@
/*
* 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.navigation.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.DialogNavigator.Destination
/**
* Show each [Destination] on the [DialogNavigator]'s back stack as a [Dialog].
*
* Note that [NavHost] will call this for you; you do not need to call it manually.
*/
@Composable
public fun DialogHost(dialogNavigator: DialogNavigator) {
val saveableStateHolder = rememberSaveableStateHolder()
val dialogBackStack by dialogNavigator.backStack.collectAsState()
val visibleBackStack = rememberVisibleList(dialogBackStack)
visibleBackStack.PopulateVisibleList(dialogBackStack)
val transitionInProgress by dialogNavigator.transitionInProgress.collectAsState()
val dialogsToDispose = remember { mutableStateListOf<NavBackStackEntry>() }
visibleBackStack.forEach { backStackEntry ->
val destination = backStackEntry.destination as Destination
Dialog(
onDismissRequest = { dialogNavigator.dismiss(backStackEntry) },
properties = destination.dialogProperties,
) {
DisposableEffect(backStackEntry) {
dialogsToDispose.add(backStackEntry)
onDispose {
dialogNavigator.onTransitionComplete(backStackEntry)
dialogsToDispose.remove(backStackEntry)
}
}
// while in the scope of the composable, we provide the navBackStackEntry as the
// ViewModelStoreOwner and LifecycleOwner
backStackEntry.LocalOwnersProvider(saveableStateHolder) {
destination.content(backStackEntry)
}
}
}
// Dialogs may have been popped before it was composed. To prevent leakage, we need to
// mark popped entries as complete here. Check that we don't accidentally complete popped
// entries that were composed, unless they were disposed of already.
LaunchedEffect(transitionInProgress, dialogsToDispose) {
transitionInProgress.forEach { entry ->
if (!dialogNavigator.backStack.value.contains(entry) &&
!dialogsToDispose.contains(entry)
) {
dialogNavigator.onTransitionComplete(entry)
}
}
}
}
@Composable
internal fun MutableList<NavBackStackEntry>.PopulateVisibleList(
backStack: Collection<NavBackStackEntry>,
) {
val isInspecting = LocalInspectionMode.current
backStack.forEach { entry ->
DisposableEffect(entry.lifecycle) {
val observer = LifecycleEventObserver { _, event ->
// show dialog in preview
if (isInspecting && !contains(entry)) {
add(entry)
}
// ON_START -> add to visibleBackStack, ON_STOP -> remove from visibleBackStack
if (event == Lifecycle.Event.ON_START) {
// We want to treat the visible lists as Sets but we want to keep
// the functionality of mutableStateListOf() so that we recompose in response
// to adds and removes.
if (!contains(entry)) {
add(entry)
}
}
if (event == Lifecycle.Event.ON_STOP) {
remove(entry)
}
}
entry.lifecycle.addObserver(observer)
onDispose {
entry.lifecycle.removeObserver(observer)
}
}
}
}
@Composable
internal fun rememberVisibleList(
backStack: Collection<NavBackStackEntry>,
): SnapshotStateList<NavBackStackEntry> {
// show dialog in preview
val isInspecting = LocalInspectionMode.current
return remember(backStack) {
mutableStateListOf<NavBackStackEntry>().also {
it.addAll(
backStack.filter { entry ->
if (isInspecting) {
true
} else {
entry.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
},
)
}
}
}
@@ -0,0 +1,96 @@
/*
* Copyright 2020 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.navigation.compose
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.FloatingWindow
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavOptions
import androidx.navigation.Navigator
import androidx.navigation.compose.DialogNavigator.Destination
/**
* Navigator that navigates through [Composable]s that will be hosted within a
* [Dialog]. Every destination using this Navigator must set a valid [Composable] by setting it
* directly on an instantiated [Destination] or calling [dialog].
*/
@Navigator.Name("dialog")
public class DialogNavigator : Navigator<Destination>() {
/**
* Get the back stack from the [state].
*/
internal val backStack get() = state.backStack
/**
* Get the transitioning dialogs from the [state].
*/
internal val transitionInProgress get() = state.transitionsInProgress
/**
* Dismiss the dialog destination associated with the given [backStackEntry].
*/
internal fun dismiss(backStackEntry: NavBackStackEntry) {
popBackStack(backStackEntry, false)
}
override fun navigate(
entries: List<NavBackStackEntry>,
navOptions: NavOptions?,
navigatorExtras: Extras?,
) {
entries.forEach { entry ->
state.push(entry)
}
}
override fun createDestination(): Destination {
return Destination(this) { }
}
override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
state.popWithTransition(popUpTo, savedState)
// When popping, the incoming dialog is marked transitioning to hold it in
// STARTED. With pop complete, we can remove it from transition so it can move to RESUMED.
val popIndex = state.transitionsInProgress.value.indexOf(popUpTo)
// do not mark complete for entries up to and including popUpTo
state.transitionsInProgress.value.forEachIndexed { index, entry ->
if (index > popIndex) onTransitionComplete(entry)
}
}
internal fun onTransitionComplete(entry: NavBackStackEntry) {
state.markTransitionComplete(entry)
}
/**
* NavDestination specific to [DialogNavigator]
*/
@NavDestination.ClassType(Composable::class)
public class Destination(
navigator: DialogNavigator,
internal val dialogProperties: DialogProperties = DialogProperties(),
internal val content: @Composable (NavBackStackEntry) -> Unit,
) : NavDestination(navigator), FloatingWindow
internal companion object {
internal const val NAME = "dialog"
}
}
@@ -0,0 +1,88 @@
/*
* 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.
*/
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
package androidx.navigation.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.saveable.SaveableStateHolder
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSavedStateRegistryOwner
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import java.lang.ref.WeakReference
import java.util.UUID
/**
* Provides [this] [NavBackStackEntry] as [LocalViewModelStoreOwner], [LocalLifecycleOwner] and
* [LocalSavedStateRegistryOwner] to the [content] and saves the [content]'s saveable states with
* the given [saveableStateHolder].
*
* @param saveableStateHolder The [SaveableStateHolder] that holds the saved states. The same
* holder should be used for all [NavBackStackEntry]s in the encapsulating [Composable] and the
* holder should be hoisted.
* @param content The content [Composable]
*/
@Composable
public fun NavBackStackEntry.LocalOwnersProvider(
saveableStateHolder: SaveableStateHolder,
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
LocalViewModelStoreOwner provides this,
LocalLifecycleOwner provides this,
LocalSavedStateRegistryOwner provides this,
) {
saveableStateHolder.SaveableStateProvider(content)
}
}
@Composable
private fun SaveableStateHolder.SaveableStateProvider(content: @Composable () -> Unit) {
val viewModel = viewModel<BackStackEntryIdViewModel>()
// Stash a reference to the SaveableStateHolder in the ViewModel so that
// it is available when the ViewModel is cleared, marking the permanent removal of this
// NavBackStackEntry from the back stack. Which, because of animations,
// only happens after this leaves composition. Which means we can't rely on
// DisposableEffect to clean up this reference (as it'll be cleaned up too early)
viewModel.saveableStateHolderRef = WeakReference(this)
SaveableStateProvider(viewModel.id, content)
}
internal class BackStackEntryIdViewModel(handle: SavedStateHandle) : ViewModel() {
private val IdKey = "SaveableStateHolder_BackStackEntryKey"
// we create our own id for each back stack entry to support multiple entries of the same
// destination. this id will be restored by SavedStateHandle
val id: UUID = handle.get<UUID>(IdKey) ?: UUID.randomUUID().also { handle.set(IdKey, it) }
lateinit var saveableStateHolderRef: WeakReference<SaveableStateHolder>
// onCleared will be called on the entries removed from the back stack. here we notify
// SaveableStateProvider that we should remove any state is had associated with this
// destination as it is no longer needed.
override fun onCleared() {
super.onCleared()
saveableStateHolderRef.get()?.removeState(id)
saveableStateHolderRef.clear()
}
}
@@ -0,0 +1,243 @@
/*
* Copyright 2020 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.navigation.compose
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDeepLink
import androidx.navigation.NavGraph
import androidx.navigation.NavGraphBuilder
import androidx.navigation.get
/**
* Add the [Composable] to the [NavGraphBuilder]
*
* @param route route for the destination
* @param arguments list of arguments to associate with destination
* @param deepLinks list of deep links to associate with the destinations
* @param content composable for the destination
*/
@Deprecated(
message = "Deprecated in favor of composable builder that supports AnimatedContent",
level = DeprecationLevel.HIDDEN,
)
public fun NavGraphBuilder.composable(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
content: @Composable (NavBackStackEntry) -> Unit,
) {
addDestination(
ComposeNavigator.Destination(provider[ComposeNavigator::class]) {
entry ->
content(entry)
}.apply {
this.route = route
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
},
)
}
/**
* Add the [Composable] to the [NavGraphBuilder]
*
* @param route route for the destination
* @param arguments list of arguments to associate with destination
* @param deepLinks list of deep links to associate with the destinations
* @param enterTransition callback to determine the destination's enter transition
* @param exitTransition callback to determine the destination's exit transition
* @param popEnterTransition callback to determine the destination's popEnter transition
* @param popExitTransition callback to determine the destination's popExit transition
* @param content composable for the destination
*/
public fun NavGraphBuilder.composable(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
enterTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? = null,
exitTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? = null,
popEnterTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? =
enterTransition,
popExitTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? =
exitTransition,
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
) {
addDestination(
ComposeNavigator.Destination(
provider[ComposeNavigator::class],
content,
).apply {
this.route = route
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
this.enterTransition = enterTransition
this.exitTransition = exitTransition
this.popEnterTransition = popEnterTransition
this.popExitTransition = popExitTransition
},
)
}
/**
* Construct a nested [NavGraph]
*
* @sample androidx.navigation.compose.samples.NestedNavInGraphWithArgs
*
* @param startDestination the starting destination's route for this NavGraph
* @param route the destination's unique route
* @param arguments list of arguments to associate with destination
* @param deepLinks list of deep links to associate with the destinations
* @param builder the builder used to construct the graph
*/
@Deprecated(
message = "Deprecated in favor of navigation builder that supports AnimatedContent",
level = DeprecationLevel.HIDDEN,
)
public fun NavGraphBuilder.navigation(
startDestination: String,
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
builder: NavGraphBuilder.() -> Unit,
) {
addDestination(
NavGraphBuilder(provider, startDestination, route).apply(builder).build().apply {
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
},
)
}
/**
* Construct a nested [NavGraph]
*
* @param startDestination the starting destination's route for this NavGraph
* @param route the destination's unique route
* @param arguments list of arguments to associate with destination
* @param deepLinks list of deep links to associate with the destinations
* @param enterTransition callback to define enter transitions for destination in this NavGraph
* @param exitTransition callback to define exit transitions for destination in this NavGraph
* @param popEnterTransition callback to define pop enter transitions for destination in this
* NavGraph
* @param popExitTransition callback to define pop exit transitions for destination in this NavGraph
* @param builder the builder used to construct the graph
*
* @return the newly constructed nested NavGraph
*/
public fun NavGraphBuilder.navigation(
startDestination: String,
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? =
null,
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? =
null,
popEnterTransition: (
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? = enterTransition,
popExitTransition: (
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? = exitTransition,
builder: NavGraphBuilder.() -> Unit,
) {
addDestination(
NavGraphBuilder(provider, startDestination, route).apply(builder).build().apply {
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
if (this is ComposeNavGraphNavigator.ComposeNavGraph) {
this.enterTransition = enterTransition
this.exitTransition = exitTransition
this.popEnterTransition = popEnterTransition
this.popExitTransition = popExitTransition
}
},
)
}
/**
* Add the [Composable] to the [NavGraphBuilder] that will be hosted within a
* [androidx.compose.ui.window.Dialog]. This is suitable only when this dialog represents
* a separate screen in your app that needs its own lifecycle and saved state, independent
* of any other destination in your navigation graph. For use cases such as `AlertDialog`,
* you should use those APIs directly in the [composable] destination that wants to show that
* dialog.
*
* @param route route for the destination
* @param arguments list of arguments to associate with destination
* @param deepLinks list of deep links to associate with the destinations
* @param dialogProperties properties that should be passed to [androidx.compose.ui.window.Dialog].
* @param content composable content for the destination that will be hosted within the Dialog
*/
public fun NavGraphBuilder.dialog(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
dialogProperties: DialogProperties = DialogProperties(),
content: @Composable (NavBackStackEntry) -> Unit,
) {
addDestination(
DialogNavigator.Destination(
provider[DialogNavigator::class],
dialogProperties,
content,
).apply {
this.route = route
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
},
)
}
@@ -0,0 +1,424 @@
/*
* Copyright 2020 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.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.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph
import androidx.navigation.NavGraphBuilder
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
/**
* Provides in place in the Compose hierarchy for self contained navigation to occur.
*
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
* the provided [navController].
*
* The builder passed into this method is [remember]ed. This means that for this NavHost, the
* contents of the builder cannot be changed.
*
* @sample androidx.navigation.compose.samples.NavScaffold
*
* @param navController the navController for this host
* @param startDestination the route for the start destination
* @param modifier The modifier to be applied to the layout.
* @param route the route for the graph
* @param builder the builder used to construct the graph
*/
@Deprecated(
message = "Deprecated in favor of NavHost that supports AnimatedContent",
level = DeprecationLevel.HIDDEN,
)
@Composable
public fun NavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
route: String? = null,
builder: NavGraphBuilder.() -> Unit,
) {
NavHost(
navController,
remember(route, startDestination, builder) {
navController.createGraph(startDestination, route, builder)
},
modifier,
)
}
/**
* Provides in place in the Compose hierarchy for self contained navigation to occur.
*
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
* the provided [navController].
*
* The builder passed into this method is [remember]ed. This means that for this NavHost, the
* contents of the builder cannot be changed.
*
* @param navController the navController for this host
* @param startDestination the route for the start destination
* @param modifier The modifier to be applied to the layout.
* @param contentAlignment The [Alignment] of the [AnimatedContent]
* @param route the route for the graph
* @param enterTransition callback to define enter transitions for destination in this host
* @param exitTransition callback to define exit transitions for destination in this host
* @param popEnterTransition callback to define popEnter transitions for destination in this host
* @param popExitTransition callback to define popExit transitions for destination in this host
* @param builder the builder used to construct the graph
*/
@Composable
public fun NavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.Center,
route: String? = null,
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
{ fadeIn(animationSpec = tween(700)) },
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
{ fadeOut(animationSpec = tween(700)) },
popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
enterTransition,
popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
exitTransition,
builder: NavGraphBuilder.() -> Unit,
) {
NavHost(
navController,
remember(route, startDestination, builder) {
navController.createGraph(startDestination, route, builder)
},
modifier,
contentAlignment,
enterTransition,
exitTransition,
popEnterTransition,
popExitTransition,
)
}
/**
* Provides in place in the Compose hierarchy for self contained navigation to occur.
*
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
* the provided [navController].
*
* The graph passed into this method is [remember]ed. This means that for this NavHost, the graph
* cannot be changed.
*
* @param navController the navController for this host
* @param graph the graph for this host
* @param modifier The modifier to be applied to the layout.
*/
@Deprecated(
message = "Deprecated in favor of NavHost that supports AnimatedContent",
level = DeprecationLevel.HIDDEN,
)
@Composable
public fun NavHost(
navController: NavHostController,
graph: NavGraph,
modifier: Modifier = Modifier,
) = NavHost(navController, graph, modifier)
/**
* Provides in place in the Compose hierarchy for self contained navigation to occur.
*
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
* the provided [navController].
*
* @param navController the navController for this host
* @param graph the graph for this host
* @param modifier The modifier to be applied to the layout.
* @param contentAlignment The [Alignment] of the [AnimatedContent]
* @param enterTransition callback to define enter transitions for destination in this host
* @param exitTransition callback to define exit transitions for destination in this host
* @param popEnterTransition callback to define popEnter transitions for destination in this host
* @param popExitTransition callback to define popExit transitions for destination in this host
*/
@SuppressLint("StateFlowValueCalledInComposition")
@Composable
public fun NavHost(
navController: NavHostController,
graph: NavGraph,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.Center,
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
{ fadeIn(animationSpec = tween(700)) },
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
{ fadeOut(animationSpec = tween(700)) },
popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
enterTransition,
popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
exitTransition,
) {
val lifecycleOwner = LocalLifecycleOwner.current
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner"
}
navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
// Then set the graph
navController.graph = graph
// Find the ComposeNavigator, returning early if it isn't found
// (such as is the case when using TestNavHostController)
val composeNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
ComposeNavigator.NAME,
) as? ComposeNavigator ?: return
val currentBackStack by composeNavigator.backStack.collectAsState()
DisposableEffect(lifecycleOwner) {
// Setup the navController with proper owners
navController.setLifecycleOwner(lifecycleOwner)
onDispose { }
}
val saveableStateHolder = rememberSaveableStateHolder()
val allVisibleEntries by navController.visibleEntries.collectAsState()
// Intercept back only when there's a destination to pop
val visibleEntries by remember {
derivedStateOf {
allVisibleEntries.filter { entry ->
entry.destination.navigatorName == ComposeNavigator.NAME
}
}
}
val backStackEntry: NavBackStackEntry? = visibleEntries.lastOrNull()
val zIndices = remember { mutableMapOf<String, Float>() }
if (backStackEntry != null) {
val finalEnter: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
val targetDestination = targetState.destination as ComposeNavigator.Destination
if (composeNavigator.isPop.value) {
targetDestination.hierarchy.firstNotNullOfOrNull { destination ->
destination.createPopEnterTransition(this)
} ?: popEnterTransition.invoke(this)
} else {
targetDestination.hierarchy.firstNotNullOfOrNull { destination ->
destination.createEnterTransition(this)
} ?: enterTransition.invoke(this)
}
}
val finalExit: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
val initialDestination = initialState.destination as ComposeNavigator.Destination
if (composeNavigator.isPop.value) {
initialDestination.hierarchy.firstNotNullOfOrNull { destination ->
destination.createPopExitTransition(this)
} ?: popExitTransition.invoke(this)
} else {
initialDestination.hierarchy.firstNotNullOfOrNull { destination ->
destination.createExitTransition(this)
} ?: exitTransition.invoke(this)
}
}
DisposableEffect(true) {
onDispose {
visibleEntries.forEach { entry ->
composeNavigator.onTransitionComplete(entry)
}
}
}
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 = {
// If the initialState of the AnimatedContent is not in visibleEntries, we are in
// a case where visible has cleared the old state for some reason, so instead of
// attempting to animate away from the initialState, we skip the animation.
if (initialState in visibleEntries) {
val initialZIndex = zIndices[initialState.id]
?: 0f.also { zIndices[initialState.id] = 0f }
val targetZIndex = when {
targetState.id == initialState.id -> initialZIndex
composeNavigator.isPop.value -> initialZIndex - 1f
else -> initialZIndex + 1f
}.also { zIndices[targetState.id] = it }
ContentTransform(finalEnter(this), finalExit(this), targetZIndex)
} else {
EnterTransition.None togetherWith ExitTransition.None
}
},
contentAlignment,
contentKey = { it.id },
) {
// 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)
}
}
LaunchedEffect(transition.currentState, transition.targetState) {
if (transition.currentState == transition.targetState) {
visibleEntries.forEach { entry ->
composeNavigator.onTransitionComplete(entry)
}
zIndices
.filter { it.key != transition.targetState.id }
.forEach { zIndices.remove(it.key) }
}
}
}
val dialogNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
DialogNavigator.NAME,
) as? DialogNavigator ?: return
// Show any dialog destinations
DialogHost(dialogNavigator)
}
private fun NavDestination.createEnterTransition(
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
): EnterTransition? = when (this) {
is ComposeNavigator.Destination -> this.enterTransition?.invoke(scope)
is ComposeNavGraphNavigator.ComposeNavGraph -> this.enterTransition?.invoke(scope)
else -> null
}
private fun NavDestination.createExitTransition(
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
): ExitTransition? = when (this) {
is ComposeNavigator.Destination -> this.exitTransition?.invoke(scope)
is ComposeNavGraphNavigator.ComposeNavGraph -> this.exitTransition?.invoke(scope)
else -> null
}
private fun NavDestination.createPopEnterTransition(
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
): EnterTransition? = when (this) {
is ComposeNavigator.Destination -> this.popEnterTransition?.invoke(scope)
is ComposeNavGraphNavigator.ComposeNavGraph -> this.popEnterTransition?.invoke(scope)
else -> null
}
private fun NavDestination.createPopExitTransition(
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
): ExitTransition? = when (this) {
is ComposeNavigator.Destination -> this.popExitTransition?.invoke(scope)
is ComposeNavGraphNavigator.ComposeNavGraph -> this.popExitTransition?.invoke(scope)
else -> null
}
@@ -0,0 +1,86 @@
/*
* Copyright 2020 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.
*/
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
package androidx.navigation.compose
import android.content.Context
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.Navigator
/**
* Gets the current navigation back stack entry as a [MutableState]. When the given navController
* changes the back stack due to a [NavController.navigate] or [NavController.popBackStack] this
* will trigger a recompose and return the top entry on the back stack.
*
* @return a mutable state of the current back stack entry
*/
@Composable
public fun NavController.currentBackStackEntryAsState(): State<NavBackStackEntry?> {
return currentBackStackEntryFlow.collectAsState(null)
}
/**
* Creates a NavHostController that handles the adding of the [ComposeNavigator] and
* [DialogNavigator]. Additional [Navigator] instances can be passed through [navigators] to
* be applied to the returned NavController. Note that each [Navigator] must be separately
* remembered before being passed in here: any changes to those inputs will cause the
* NavController to be recreated.
*
* @see NavHost
*/
@Composable
public fun rememberNavController(
vararg navigators: Navigator<out NavDestination>,
): NavHostController {
val context = LocalContext.current
return rememberSaveable(inputs = navigators, saver = NavControllerSaver(context)) {
createNavController(context)
}.apply {
for (navigator in navigators) {
navigatorProvider.addNavigator(navigator)
}
}
}
private fun createNavController(context: Context) =
NavHostController(context).apply {
navigatorProvider.addNavigator(ComposeNavGraphNavigator(navigatorProvider))
navigatorProvider.addNavigator(ComposeNavigator())
navigatorProvider.addNavigator(DialogNavigator())
}
/**
* Saver to save and restore the NavController across config change and process death.
*/
private fun NavControllerSaver(
context: Context,
): Saver<NavHostController, *> = Saver<NavHostController, Bundle>(
save = { it.saveState() },
restore = { createNavController(context).apply { restoreState(it) } },
)
@@ -1,38 +0,0 @@
package androidx.navigation.ui
import android.annotation.SuppressLint
import android.view.MenuItem
import androidx.annotation.IdRes
import androidx.navigation.ActivityNavigator
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavOptions
@SuppressLint("PrivateResource")
fun onNavDestinationSelected2(item: MenuItem, navController: NavController): Boolean {
val builder = NavOptions.Builder().setLaunchSingleTop(true)
if (navController.currentDestination!!.parent!!.findNode(item.itemId) is ActivityNavigator.Destination) {
builder.setEnterAnim(R.anim.nav_default_enter_anim)
.setExitAnim(R.anim.nav_default_exit_anim)
.setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
.setPopExitAnim(R.anim.nav_default_pop_exit_anim)
} else {
builder.setEnterAnim(androidx.fragment.R.animator.fragment_open_enter)
.setExitAnim(androidx.fragment.R.animator.fragment_open_exit)
.setPopEnterAnim(androidx.fragment.R.animator.fragment_close_enter)
.setPopExitAnim(androidx.fragment.R.animator.fragment_close_exit)
}
val options = builder.build()
return try {
// TODO provide proper API instead of using Exceptions as Control-Flow.
navController.navigate(item.itemId, null, options)
// Return true only if the destination we've navigated to matches the MenuItem
navController.currentDestination?.matchDestination(item.itemId) == true
} catch (e: IllegalArgumentException) {
false
}
}
fun NavDestination.matchDestination(@IdRes destId: Int): Boolean =
hierarchy.any { it.id == destId }
@@ -1,25 +0,0 @@
package androidx.swiperefreshlayout.widget
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.ShapeDrawable
import android.util.AttributeSet
import androidx.core.graphics.ColorUtils
import rikka.core.res.resolveColor
@SuppressLint("PrivateResource")
class ThemedSwipeRefreshLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : SwipeRefreshLayout(context, attrs) {
init {
val surfaceColor =
context.theme.resolveColor(com.google.android.material.R.attr.colorSurface)
val overlayColor =
context.getColor(com.google.android.material.R.color.m3_popupmenu_overlay_color)
val backgroundColor = ColorUtils.compositeColors(overlayColor, surfaceColor)
(mCircleView.background as ShapeDrawable).paint.color = backgroundColor
setColorSchemeColors(context.theme.resolveColor(com.google.android.material.R.attr.colorAccent))
}
}
@@ -1,111 +0,0 @@
package com.davemorrissey.labs.subscaleview
import android.graphics.Bitmap
import android.graphics.Rect
import com.davemorrissey.labs.subscaleview.provider.InputProvider
/**
* Helper class used to set the source and additional attributes from a variety of sources. Supports
* use of a bitmap, asset, resource, external file or any other URI.
*
*
* When you are using a preview image, you must set the dimensions of the full size image on the
* ImageSource object for the full size image using the [.dimensions] method.
*/
class ImageSource {
val bitmap: Bitmap?
val provider: InputProvider?
private var sWidth = 0
private var sHeight = 0
var sRegion = Rect(0, 0, 0, 0)
private set
var isCached = false
private set
private constructor(bitmap: Bitmap, cached: Boolean) {
this.bitmap = bitmap
provider = null
sWidth = bitmap.width
sHeight = bitmap.height
isCached = cached
}
private constructor(provider: InputProvider) {
bitmap = null
this.provider = provider
}
/**
* Use a region of the source image. Region must be set independently for the full size image and the preview if
* you are using one.
*
* @param sRegion the region of the source image to be displayed.
* @return this instance for chaining.
*/
fun region(sRegion: Rect): ImageSource {
this.sRegion = sRegion
setInvariants()
return this
}
/**
* Declare the dimensions of the image. This is only required for a full size image, when you are specifying a URI
* and also a preview image. When displaying a bitmap object, or not using a preview, you do not need to declare
* the image dimensions. Note if the declared dimensions are found to be incorrect, the view will reset.
*
* @param sWidth width of the source image.
* @param sHeight height of the source image.
* @return this instance for chaining.
*/
fun dimensions(sWidth: Int, sHeight: Int): ImageSource {
if (bitmap == null) {
this.sWidth = sWidth
this.sHeight = sHeight
}
setInvariants()
return this
}
private fun setInvariants() {
if (!sRegion.isEmpty) {
sWidth = sRegion.width()
sHeight = sRegion.height()
}
}
companion object {
/**
* Create an instance from an input provider.
*
* @param provider input stream provider.
* @return an [ImageSource] instance.
*/
fun provider(provider: InputProvider): ImageSource {
return ImageSource(provider)
}
/**
* Provide a loaded bitmap for display.
*
* @param bitmap bitmap to be displayed.
* @return an [ImageSource] instance.
*/
fun bitmap(bitmap: Bitmap): ImageSource {
return ImageSource(bitmap, false)
}
/**
* Provide a loaded and cached bitmap for display. This bitmap will not be recycled when it is no
* longer needed. Use this method if you loaded the bitmap with an image loader such as Picasso
* or Volley.
*
* @param bitmap bitmap to be displayed.
* @return an [ImageSource] instance.
*/
fun cachedBitmap(bitmap: Bitmap): ImageSource {
return ImageSource(bitmap, true)
}
}
}
@@ -1,15 +0,0 @@
package com.davemorrissey.labs.subscaleview
import android.graphics.PointF
import java.io.Serializable
/**
* Wraps the scale, center and orientation of a displayed image for easy restoration on screen rotate.
*/
class ImageViewState(val scale: Float, center: PointF) : Serializable {
private val centerX: Float = center.x
private val centerY: Float = center.y
val center: PointF
get() = PointF(centerX, centerY)
}
File diff suppressed because it is too large. Load diff
@@ -1,50 +0,0 @@
package com.davemorrissey.labs.subscaleview.decoder
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Point
import android.graphics.Rect
import com.davemorrissey.labs.subscaleview.provider.InputProvider
/**
* Interface for image decoding classes.
*/
interface Decoder {
/**
* Initialise the decoder. When possible, perform initial setup work once in this method. The
* dimensions of the image must be returned.
*
* @param context Application context. A reference may be held, but must be cleared on recycle.
* @param provider Provider of the image.
* @return Dimensions of the image.
* @throws Exception if initialisation fails.
*/
fun init(context: Context, provider: InputProvider): Point
/**
* Decode a region of the image with the given sample size. This method is called off the UI
* thread so it can safely load the image on the current thread. It is called from
* [android.os.AsyncTask]s running in an executor that may have multiple threads, so
* implementations must be thread safe. Adding `synchronized` to the method signature
* is the simplest way to achieve this, but bear in mind the [.recycle] method can be
* called concurrently.
*
* @param sRect Source image rectangle to decode.
* @param sampleSize Sample size.
* @return The decoded region. It is safe to return null if decoding fails.
*/
fun decodeRegion(sRect: Rect, sampleSize: Int): Bitmap
/**
* Status check. Should return false before initialisation and after recycle.
*
* @return true if the decoder is ready to be used.
*/
val isReady: Boolean
/**
* This method will be called when the decoder is no longer required. It should clean up any
* resources still in use.
*/
fun recycle()
}
@@ -1,58 +0,0 @@
package com.davemorrissey.labs.subscaleview.decoder
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Point
import android.graphics.Rect
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.provider.InputProvider
class ImageDecoder(private val cropBorders: Boolean, bitmapConfig: Bitmap.Config? = null) : Decoder {
init {
val globalBitmapConfig = SubsamplingScaleImageView.preferredBitmapConfig
}
/**
* Initialise the decoder. When possible, perform initial setup work once in this method. The
* dimensions of the image must be returned.
*
* @param context Application context. A reference may be held, but must be cleared on recycle.
* @param provider Provider of the image.
* @return Dimensions of the image.
* @throws Exception if initialisation fails.
*/
override fun init(context: Context, provider: InputProvider): Point {
TODO()
}
/**
* Decode a region of the image with the given sample size. This method is called off the UI
* thread so it can safely load the image on the current thread. It is called from
* [android.os.AsyncTask]s running in an executor that may have multiple threads, so
* implementations must be thread safe. Adding `synchronized` to the method signature
* is the simplest way to achieve this, but bear in mind the [.recycle] method can be
* called concurrently.
*
* @param sRect Source image rectangle to decode.
* @param sampleSize Sample size.
* @return The decoded region. It is safe to return null if decoding fails.
*/
override fun decodeRegion(sRect: Rect, sampleSize: Int): Bitmap {
TODO()
}
/**
* Status check. Should return false before initialisation and after recycle.
*
* @return true if the decoder is ready to be used.
*/
override val isReady: Boolean = TODO()
/**
* This method will be called when the decoder is no longer required. It should clean up any
* resources still in use.
*/
override fun recycle() {
TODO()
}
}
@@ -1,3 +0,0 @@
package com.davemorrissey.labs.subscaleview.provider
interface InputProvider
@@ -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,28 +18,31 @@ package com.hippo.ehviewer
import android.app.Application
import android.content.ComponentCallbacks2
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.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.MergeInterceptor
import com.hippo.ehviewer.coil.installCronetHttpUriFetcher
import com.hippo.ehviewer.coil.installKtorHttpUriFetcher
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
@@ -49,11 +52,19 @@ import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isCronetSupported
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
import com.hippo.ehviewer.util.isAtLeastS
import com.hippo.ehviewer.util.isCronetAvailable
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
@@ -63,11 +74,16 @@ 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 {
AppCompatDelegate.setDefaultNightMode(Settings.theme)
val mode = Settings.theme
if (!isAtLeastS) {
withUIContext {
AppCompatDelegate.setDefaultNightMode(mode)
}
}
}
lifecycle.addObserver(lockObserver)
val handler = Thread.getDefaultUncaughtExceptionHandler()
@@ -88,10 +104,7 @@ class EhApplication : Application(), ImageLoaderFactory {
ReadableTime.initialize(this)
lifecycleScope.launchIO {
launchIO {
migrateCookies()
}
launchIO {
EhTagDatabase.update()
EhTagDatabase
}
launchIO {
EhDB
@@ -99,6 +112,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()
}
@@ -109,6 +138,12 @@ class EhApplication : Application(), ImageLoaderFactory {
}
}
cleanObsoleteCache(this)
if (BuildConfig.DEBUG) {
StrictMode.enableDefaults()
Snapshot.registerApplyObserver { anies, _ ->
logcat(Log.VERBOSE) { anies.toString() }
}
}
}
private suspend fun cleanupDownload() {
@@ -144,12 +179,7 @@ class EhApplication : Application(), ImageLoaderFactory {
override fun newImageLoader() = imageLoader {
components {
if (isCronetSupported) {
callFactory { unreachable() }
installCronetHttpUriFetcher()
} else {
callFactory(baseOkHttpClient)
}
installKtorHttpUriFetcher()
if (isAtLeastP) {
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
}
@@ -157,36 +187,42 @@ class EhApplication : Application(), ImageLoaderFactory {
}
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))
val ktorClient by lazy {
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(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,
)
val noRedirectKtorClient by lazy {
HttpClient(ktorClient.engine) {
followRedirects = false
install(HttpCookies) {
storage = EhCookieStore
}
}
}
@@ -130,7 +130,7 @@ object EhDB {
deleteGalleryInfo(galleryInfo)
}
suspend fun removeLocalFavorites(galleryInfoList: List<BaseGalleryInfo>) {
suspend fun removeLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
galleryInfoList.forEach {
removeLocalFavorites(it)
}
@@ -146,7 +146,7 @@ object EhDB {
db.localFavoritesDao().upsert(LocalFavoriteInfo(galleryInfo.gid))
}
suspend fun putLocalFavorites(galleryInfoList: List<BaseGalleryInfo>) {
suspend fun putLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
galleryInfoList.forEach {
putLocalFavorites(it)
}
@@ -195,7 +195,7 @@ object EhDB {
db.historyDao().upsert(HistoryInfo(galleryInfo.gid))
}
suspend fun modifyHistoryInfoFavslotNonRefresh(gid: Long, slot: Int) {
suspend fun updateFavoriteSlot(gid: Long, slot: Int) {
val dao = db.galleryDao()
dao.load(gid)?.let {
it.favoriteSlot = slot
@@ -292,7 +292,7 @@ object EhDB {
}
runCatching {
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
DownloadManager.addDownload(downloadInfoList, false)
DownloadManager.addDownload(downloadInfoList)
}
runCatching {
val historyInfoList = oldDB.historyDao().list()
@@ -2,8 +2,18 @@
package com.hippo.ehviewer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import com.hippo.ehviewer.client.CHROME_USER_AGENT
import com.hippo.ehviewer.client.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
@@ -12,9 +22,78 @@ import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.map
import splitties.preferences.BoolPref
import splitties.preferences.DataStorePreferences
import splitties.preferences.FloatPref
import splitties.preferences.IntPref
import splitties.preferences.LongPref
import splitties.preferences.PrefDelegate
import splitties.preferences.StringOrNullPref
import splitties.preferences.StringPref
import splitties.preferences.StringSetOrNullPref
import splitties.preferences.StringSetPref
import splitties.preferences.edit
@Suppress("IMPLICIT_CAST_TO_ANY", "UNCHECKED_CAST")
fun <T> PrefDelegate<T>.getValue(): T = when (this) {
is BoolPref -> value
is IntPref -> value
is FloatPref -> value
is LongPref -> value
is StringPref -> value
is StringOrNullPref -> value
is StringSetPref -> value
is StringSetOrNullPref -> value
} as T
@Suppress("UNCHECKED_CAST")
fun <T> PrefDelegate<T>.setValue(newValue: T) = when (this) {
is BoolPref -> value = newValue as Boolean
is IntPref -> value = newValue as Int
is FloatPref -> value = newValue as Float
is LongPref -> value = newValue as Long
is StringPref -> value = newValue as String
is StringOrNullPref -> value = newValue as? String
is StringSetPref -> value = newValue as Set<String?>
is StringSetOrNullPref -> value = newValue as? Set<String?>
}
@Stable
@Composable
inline fun <R, T> PrefDelegate<T>.collectAsState(crossinline transform: @DisallowComposableCalls (T) -> R): State<R> {
val flow = remember { valueFlow().map { transform(it) } }
val init = getValue()
return flow.collectAsState(transform(init))
}
@Stable
@Composable
fun <T> PrefDelegate<T>.collectAsState(): State<T> {
val flow = remember { valueFlow() }
val init = getValue()
return flow.collectAsState(init)
}
@Stable
@Composable
fun <T> PrefDelegate<T>.asMutableState(): MutableState<T> {
val flow = remember { valueFlow() }
val init = getValue()
val readOnly = flow.collectAsState(init)
return remember {
object : MutableState<T> {
override var value: T
get() = readOnly.value
set(value) {
setValue(value)
}
override fun component1() = TODO()
override fun component2() = TODO()
}
}
}
object Settings : DataStorePreferences(null) {
private const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations"
@@ -24,6 +103,16 @@ object Settings : DataStorePreferences(null) {
var favCount by intArrayPref("fav_count", 10).emitTo(_favFlow)
var favCloudCount by intPref("fav_cloud", 0).emitTo(_favFlow)
val listMode = intPref("list_mode_2", 0)
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)
@@ -36,11 +125,10 @@ object Settings : DataStorePreferences(null) {
var preloadImage by intPref("preload_image_2", 5)
var downloadTimeout by intPref("download_timeout", 60)
var theme by intPref("theme_2", -1).observed { updateWhenThemeChanges() }
var listMode by intPref("list_mode_2", 0)
var detailSize by intPref("detail_size_2", 0)
var thumbResolution by intPref("thumb_resolution_2", 0)
var readCacheSize by intPref("read_cache_size_2", 640)
var launchPage by intPref("launch_page_2", 0)
var commentThreshold by intPref("comment_threshold", -101)
var forceEhThumb by boolPref("force_eh_thumb", false)
var showComments by boolPref("show_gallery_comments", true)
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
@@ -48,7 +136,6 @@ object Settings : DataStorePreferences(null) {
var showJpnTitle by boolPref("show_jpn_title", false)
var showGalleryPages by boolPref("show_gallery_pages", true)
var showTagTranslations by boolPref(KEY_SHOW_TAG_TRANSLATIONS, false).observed { updateWhenTagTranslationChanges() }
var meteredNetworkWarning by boolPref("cellular_network_warning", false)
var appLinkVerifyTip by boolPref("app_link_verify_tip", false)
var enabledSecurity by boolPref("enable_secure", false)
var useCIUpdateChannel by boolPref("ci_update_channel", false)
@@ -59,29 +146,42 @@ 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)
var blackDarkTheme by boolPref("black_dark_theme", false).observed { updateWhenAmoledModeChanges() }
var needSignIn by boolPref("need_sign_in", true).also { needSignInFlow = it.valueFlow() }
var harmonizeCategoryColor by boolPref("harmonize_category_color", true)
var preloadThumbAggressively by boolPref("preload_thumb_aggressively", false)
var downloadOriginImage by boolPref("download_origin_image", false)
var enableCronet by boolPref("enable_cronet", true)
var thumbSizeDp by intPref("thumb_size_", 120)
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
var defaultFavSlot by intPref("default_favorite_slot", -2)
var securityDelay by intPref("require_unlock_delay", 0)
var clipboardTextHashCode by intPref("clipboard_text_hash_code", 0)
var listThumbSize by intPref("list_tile_size", 40)
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)
var defaultDownloadLabel by stringOrNullPref("default_download_label", 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 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) {
@@ -1,13 +1,14 @@
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
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.util.isAtLeastS
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import splitties.init.appCtx
import splitties.preferences.PrefDelegate
import splitties.systemservices.uiModeManager
private val collectScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun <T, R : PrefDelegate<T>> R.observed(func: (Unit) -> Unit) = apply { collectScope.launch { changesFlow().collect(func) } }
@@ -44,14 +46,14 @@ fun updateWhenKeepMediaStatusChanges() {
fun updateWhenThemeChanges() {
collectScope.launch {
delay(100) // Avoid recompose being cancelled
AppCompatDelegate.setDefaultNightMode(Settings.theme)
}
}
fun updateWhenAmoledModeChanges() {
collectScope.launch {
delay(100) // Avoid recompose being cancelled
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_AUTO_BATTERY)
if (isAtLeastS) {
val mode = when (Settings.theme) {
AppCompatDelegate.MODE_NIGHT_NO -> UiModeManager.MODE_NIGHT_NO
AppCompatDelegate.MODE_NIGHT_YES -> UiModeManager.MODE_NIGHT_YES
else -> UiModeManager.MODE_NIGHT_AUTO
}
uiModeManager.setApplicationNightMode(mode)
}
AppCompatDelegate.setDefaultNightMode(Settings.theme)
}
}
@@ -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,12 +15,15 @@
*/
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
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.InsufficientFundsException
@@ -33,8 +36,8 @@ 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.GalleryPageApiParser
import com.hippo.ehviewer.client.parser.GalleryPageParser
import com.hippo.ehviewer.client.parser.GalleryTokenApiParser
import com.hippo.ehviewer.client.parser.HomeParser
@@ -45,45 +48,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.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.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 {
@@ -92,10 +114,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)
}
}
}
@@ -104,136 +128,106 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
throw StatusCodeException(code)
}
if (e is ParseException) {
if ("<" !in body) {
throw EhException(body)
} else {
if (Settings.saveParseErrorBody) AppConfig.saveParseErrorBody(e, body)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
}
if (e is ParseException || e is SerializationException) {
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
throw e
}
val httpContentPool = DirectByteBufferPool(16, 0x20000)
val httpContentPool = DirectByteBufferPool(8, 0x80000)
suspend inline fun <T> fetchCompat(url: String, referer: String? = null, crossinline parser: (ByteBuffer) -> T): T {
return if (isCronetSupported) {
cronetRequest(url, referer).execute {
httpContentPool.useInstance { buffer ->
awaitBodyFully {
buffer.put(it)
}
buffer.flip()
parser(buffer)
}
}
} else {
ehRequest(url, referer).execute {
httpContentPool.useInstance { buffer ->
while (true) {
if (body.source().read(buffer) == -1) break
}
buffer.flip()
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, 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) = ehRequest(url, EhUrl.referer)
.executeAndParsingWith(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) = ehRequest(url, EhUrl.referer)
.executeAndParsingWith(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"
@@ -241,26 +235,26 @@ 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: String?) = ehRequest(url, url, EhUrl.origin) {
suspend fun commentGallery(url: String, comment: String, id: Long = -1) = ehRequest(url, url, EhUrl.origin) {
formBody {
if (id == null) {
add("commenttext_new", comment)
if (id == -1L) {
append("commenttext_new", comment)
} else {
add("commenttext_edit", comment)
add("edit_comment", id)
append("commenttext_edit", comment)
append("edit_comment", id.toString())
}
}
}.executeAndParsingWith {
}.fetchUsingAsText {
val document = Jsoup.parse(this)
val elements = document.select("#chd + p")
if (elements.size > 0) {
@@ -269,71 +263,50 @@ object EhEngine {
GalleryDetailParser.parseComments(document)
}
/**
* @param dstCat -1 for delete, 0 - 9 for cloud favorite, others throw Exception
* @param note max 250 characters
*/
suspend fun modifyFavorites(
gid: Long,
token: String?,
dstCat: Int = -1,
note: String = "",
) {
suspend fun modifyFavorites(gid: Long, token: String?, dstCat: Int = -1, note: String = "") {
val catStr: String = when (dstCat) {
-1 -> "favdel"
in 0..9 -> dstCat.toString()
else -> throw EhException("Invalid dstCat: $dstCat")
}
val url = EhUrl.getAddFavorites(gid, token)
return ehRequest(url, url, EhUrl.origin) {
ehRequest(url, url, EhUrl.origin) {
formBody {
add("favcat", catStr)
add("favnote", note)
append("favcat", catStr)
append("favnote", note)
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
add("apply", "Apply Changes")
add("update", "1")
append("apply", "Apply Changes")
append("update", "1")
}
}.executeAndParsingWith { }
}.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
}
@@ -342,11 +315,14 @@ 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(url: String, gidArray: LongArray, dstCat: Int): FavoritesParser.Result {
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavoritesParser.Result {
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"
@@ -354,19 +330,13 @@ object EhEngine {
}
return ehRequest(url, url, EhUrl.origin) {
formBody {
add("ddact", catStr)
gidArray.forEach { add("modifygids[]", it.toString()) }
append("ddact", catStr)
gidArray.forEach { append("modifygids[]", it.toString()) }
}
}.executeAndParsingWith(FavoritesParser::parse).apply { fillGalleryList(galleryInfoList, url) }
}.fetchUsingAsByteBuffer(FavoritesParser::parse).apply { galleryInfoList.fillInfo(url) }
}
suspend fun getGalleryPageApi(
gid: Long,
index: Int,
pToken: String,
showKey: String?,
previousPToken: String?,
): GalleryPageApiParser.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 {
@@ -376,16 +346,10 @@ object EhEngine {
put("imgkey", pToken)
put("showkey", showKey)
}
}.executeAndParsingWith(GalleryPageApiParser::parse)
}.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)
@@ -394,62 +358,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 {
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
put("method", "gdata")
array("gidlist") {
it.forEach {
addJsonArray {
add(it.gid)
add(it.token)
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String) =
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)
}.fetchUsingAsText { GalleryApiParser.parse(this, it) }
}
}.executeAndParsingWith { 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 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 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)
}
}.fetchUsingAsText(VoteTagParser::parse)
suspend fun getGalleryToken(gid: Long, gtoken: String?, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
@@ -462,47 +420,44 @@ object EhEngine {
}
}
}
}.executeAndParsingWith(GalleryTokenApiParser::parse)
}.fetchUsingAsText(GalleryTokenApiParser::parse)
/**
* @param image Must be jpeg
*/
suspend fun imageSearch(image: File, uss: Boolean, osc: Boolean) = ehRequest(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")
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)
}
}.executeAndParsingWith(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 != null) {
hasTags = true
}
if (gi.pages != 0) {
hasPages = true
}
if (gi.rated) {
hasRated = true
}
}
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) }
}
suspend fun modifyFavoritesRange(galleryList: List<Pair<Long, String>>, dstCat: Int) {
galleryList.forEach { (gid, token) -> modifyFavorites(gid, token, dstCat) }
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.chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
if (index != 0) {
delay(REQUEST_INTERVAL)
}
chunk.parMap { (gid, token) ->
modifyFavorites(gid, token, dstCat)
}
}
}
}
@@ -17,17 +17,17 @@ object EhFilter : CoroutineScope {
val filters = async { EhDB.getAllFilter() as MutableList }
private suspend inline fun anyActive(mode: FilterMode, predicate: (Filter) -> Boolean) = filters.await().any { it.mode == mode && it.enable && predicate(it) }
private fun <R> Filter.launchOps(
callback: ((R) -> Unit)? = null,
callback: (suspend (R) -> Unit)? = null,
ops: suspend Filter.() -> R,
) = launch { ops().let { callback?.invoke(it) } }
fun Filter.remember(callback: ((Boolean) -> Unit)? = null) = launchOps(callback) {
fun Filter.remember(callback: (suspend (Boolean) -> Unit)? = null) = launchOps(callback) {
EhDB.addFilter(this).also { if (it) filters.await().add(this) }
}
fun Filter.trigger(callback: ((Unit) -> Unit)? = null) = launchOps(callback) {
fun Filter.trigger(callback: (suspend (Unit) -> Unit)? = null) = launchOps(callback) {
enable = !enable
EhDB.updateFilter(this)
}
fun Filter.forget(callback: ((Unit) -> Unit)? = null) = launchOps(callback) {
fun Filter.forget(callback: (suspend (Unit) -> Unit)? = null) = launchOps(callback) {
EhDB.deleteFilter(this)
filters.await().remove(this)
}
@@ -15,109 +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 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 com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
import com.hippo.ehviewer.Settings
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", CHROME_USER_AGENT)
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 Request.Builder.formBody(builder: FormBody.Builder.() -> Unit) = post(FormBody.Builder().apply(builder).build())
inline fun Request.Builder.multipartBody(builder: MultipartBody.Builder.() -> Unit) = post(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)
@@ -126,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
@@ -55,7 +57,6 @@ object EhTagDatabase : CoroutineScope {
)
private lateinit var tagGroups: Map<String, Map<String, String>>
private val updateLock = Mutex()
private val dbLock = Mutex()
override val coroutineContext = Dispatchers.IO + Job()
var initialized by mutableStateOf(false)
@@ -176,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() {
@@ -202,42 +197,42 @@ object EhTagDatabase : CoroutineScope {
}
}
private suspend fun issueUpdateInMemoryData() {
dbLock.withLock {
getMetadata(appCtx)?.let { urls ->
val dataName = urls[2]
val dir = AppConfig.getFilesDir("tag-translations")
val dataFile = File(dir, dataName).takeIf { it.exists() } ?: return
runSuspendCatching {
tagGroups = dataFile.source().buffer().use(BufferedSource::parseAs)
initialized = true
}.onFailure {
it.printStackTrace()
}
}
}
private fun issueUpdateInMemoryData(file: File? = null) {
val dataFile = file ?: getMetadata(appCtx)?.let { metadata ->
val dataName = metadata[2]
val dir = AppConfig.getFilesDir("tag-translations")
File(dir, dataName).takeIf { it.exists() }
} ?: return
tagGroups = dataFile.source().buffer().use(BufferedSource::parseAs)
initialized = true
}
init {
launch {
issueUpdateInMemoryData()
updateLock.withLock {
runSuspendCatching {
issueUpdateInMemoryData()
}.onFailure {
it.printStackTrace()
}
updateInternal()
}
}
}
private suspend fun updateInternal() {
val urls = getMetadata(appCtx)
urls?.let {
val sha1Name = urls[0]
val sha1Url = urls[1]
val dataName = urls[2]
val dataUrl = urls[3]
getMetadata(appCtx)?.let { metadata ->
val sha1Name = metadata[0]
val sha1Url = metadata[1]
val dataName = metadata[2]
val dataUrl = metadata[3]
val dir = AppConfig.getFilesDir("tag-translations")
checkNotNull(dir)
val sha1File = File(dir, sha1Name)
val dataFile = File(dir, dataName)
runCatching {
runSuspendCatching {
// Check current sha1 and current data
val sha1 = getFileContent(sha1File)
if (!checkData(sha1, dataFile)) {
@@ -247,25 +242,25 @@ 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
if (tempSha1 == sha1) {
// The data is the same
FileUtils.delete(tempSha1File)
return@runCatching
return
}
// 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)) {
FileUtils.delete(tempSha1File)
FileUtils.delete(tempDataFile)
return@runCatching
return
}
// Replace current sha1 and current data with new sha1 and new data
@@ -275,7 +270,7 @@ object EhTagDatabase : CoroutineScope {
tempDataFile.renameTo(dataFile)
// Read new EhTagDatabase
issueUpdateInMemoryData()
issueUpdateInMemoryData(dataFile)
}.onFailure {
it.printStackTrace()
}
@@ -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,12 +16,13 @@
package com.hippo.ehviewer.client
import android.annotation.SuppressLint
import android.content.Context
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import com.google.android.material.R
import com.google.android.material.color.MaterialColors
import androidx.compose.ui.graphics.toArgb
import arrow.core.memoize
import com.google.android.material.color.utilities.Hct
import com.google.android.material.color.utilities.MathUtils
import com.hippo.ehviewer.Settings
@@ -102,17 +103,19 @@ object EhUtils {
}
@SuppressLint("RestrictedApi")
fun harmonizeWithRole(context: Context, src: Int): Int {
val primary = MaterialColors.getColor(context, R.attr.colorPrimaryContainer, "EhUtils")
val harmonizeWithRole = { primaryContainer: Int, src: Int ->
val fromHct = Hct.fromInt(src)
val toHct = Hct.fromInt(primary)
val toHct = Hct.fromInt(primaryContainer)
val differenceDegrees = MathUtils.differenceDegrees(fromHct.hue, toHct.hue)
val rotationDegrees = minOf(differenceDegrees * 0.5, 15.0)
val outputHue = MathUtils.sanitizeDegreesDouble(fromHct.hue + rotationDegrees * MathUtils.rotationDirection(fromHct.hue, toHct.hue))
return Hct.from(outputHue, toHct.chroma, toHct.tone).toInt()
}
Hct.from(outputHue, toHct.chroma, toHct.tone).toInt()
}.memoize()
private fun getCategoryColor(context: Context, category: Int): Int {
@Stable
@ReadOnlyComposable
@Composable
fun getCategoryColor(category: Int): Color {
val primary = when (category) {
DOUJINSHI -> BG_COLOR_DOUJINSHI
MANGA -> BG_COLOR_MANGA
@@ -126,16 +129,15 @@ object EhUtils {
MISC -> BG_COLOR_MISC
else -> BG_COLOR_UNKNOWN
}.toInt()
return if (Settings.harmonizeCategoryColor) {
harmonizeWithRole(context, primary)
val color = if (Settings.harmonizeCategoryColor) {
val primaryContainer = MaterialTheme.colorScheme.primaryContainer.toArgb()
harmonizeWithRole(primaryContainer, primary)
} else {
primary
}
return Color(color)
}
@Composable
fun getCategoryColor(category: Int) = Color(getCategoryColor(LocalContext.current, category))
val categoryTextColor = Color(0xffe6e0e9)
fun signOut() {
@@ -146,10 +148,6 @@ object EhUtils {
Settings.needSignIn = true
}
fun needSignedIn(): Boolean {
return Settings.needSignIn
}
fun getSuitableTitle(gi: GalleryInfo): String {
return if (Settings.showJpnTitle) {
if (gi.titleJpn.isNullOrEmpty()) gi.title else gi.titleJpn
@@ -16,6 +16,7 @@
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
@@ -25,7 +26,7 @@ import kotlinx.parcelize.Parcelize
@Parcelize
@Entity(tableName = "GALLERIES")
data class BaseGalleryInfo(
data class BaseGalleryInfo @Keep constructor(
@PrimaryKey
@ColumnInfo(name = "GID")
override var gid: Long = 0,
@@ -17,41 +17,38 @@ 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
@Parcelize
class FavListUrlBuilder(
var mPrev: String? = null,
var mNext: String? = null,
data class FavListUrlBuilder(
val favCat: Int = FAV_CAT_ALL,
val keyword: String? = null,
var jumpTo: String? = null,
var keyword: String? = null,
var favCat: Int = FAV_CAT_ALL,
var prev: String? = null,
var next: String? = null,
) : Parcelable {
fun setIndex(index: String?, isNext: Boolean) {
mNext = index.takeIf { isNext }
mPrev = index.takeUnless { isNext }
next = index.takeIf { isNext }
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", mPrev)
addQueryParameterIfNotBlank("next", mNext)
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
}
}
@@ -1,38 +0,0 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class GalleryApiInfo(
var gid: Long = 0,
var token: String? = null,
var archiverKey: String? = null,
var title: String? = null,
var titleJpn: String? = null,
var category: Int = 0,
var thumb: String? = null,
var uploader: String? = null,
var posted: Long = 0,
var filecount: Int = 0,
var filesize: Long = 0,
var expunged: Boolean = false,
var rating: Float = 0f,
var torrentcount: Int = 0,
var tags: Array<String>?,
) : Parcelable
@@ -19,19 +19,18 @@ import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class GalleryComment(
// 0 for uploader comment. can't vote
var id: Long = 0,
var score: Int = 0,
var editable: Boolean = false,
var voteUpAble: Boolean = false,
var voteUpEd: Boolean = false,
var voteDownAble: Boolean = false,
var voteDownEd: Boolean = false,
var uploader: Boolean = false,
var voteState: String? = null,
var time: Long = 0,
var user: String? = null,
var comment: String? = null,
var lastEdited: Long = 0,
data class GalleryComment(
val id: Long,
val score: Int,
val editable: Boolean,
val voteUpAble: Boolean,
val voteUpEd: Boolean,
val voteDownAble: Boolean,
val voteDownEd: Boolean,
val uploader: Boolean,
val voteState: String?,
val time: Long,
val user: String?,
val comment: String,
val lastEdited: Long,
) : Parcelable
@@ -19,7 +19,7 @@ import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class GalleryCommentList(
var comments: Array<GalleryComment>,
data class GalleryCommentList(
val comments: List<GalleryComment>,
val hasMore: Boolean,
) : Parcelable
@@ -37,7 +37,7 @@ private val LANGUAGES = arrayOf(
)
@Parcelize
class GalleryDetail(
data class GalleryDetail(
val galleryInfo: BaseGalleryInfo = BaseGalleryInfo(),
var apiUid: Long = -1L,
var apiKey: String? = null,
@@ -51,7 +51,7 @@ class GalleryDetail(
var size: String? = null,
var favoriteCount: Int = 0,
var ratingCount: Int = 0,
val tags: Array<GalleryTagGroup>,
val tags: List<GalleryTagGroup>,
var comments: GalleryCommentList,
val previewPages: Int,
val previewList: List<GalleryPreview>,
@@ -16,6 +16,7 @@
package com.hippo.ehviewer.client.data
import java.util.regex.Pattern
import moe.tarsin.kt.unreachable
interface GalleryInfo {
var gid: Long
@@ -43,63 +44,24 @@ interface GalleryInfo {
?: title?.let { generateSLangFromTitle(it) }
}
private fun generateSLangFromTags(simpleTags: List<String>): String? {
for (tag in simpleTags) {
for (i in S_LANGS.indices) {
if (S_LANG_TAGS[i] == tag) {
return S_LANGS[i]
}
}
}
return null
}
private fun generateSLangFromTitle(title: String): String? {
for (i in S_LANGS.indices) {
if (S_LANG_PATTERNS[i].matcher(title).find()) {
return S_LANGS[i]
}
}
return null
}
companion object {
/**
* ISO 639-1
*/
const val S_LANG_JA = "JA"
const val S_LANG_EN = "EN"
const val S_LANG_ZH = "ZH"
const val S_LANG_NL = "NL"
const val S_LANG_FR = "FR"
const val S_LANG_DE = "DE"
const val S_LANG_HU = "HU"
const val S_LANG_IT = "IT"
const val S_LANG_KO = "KO"
const val S_LANG_PL = "PL"
const val S_LANG_PT = "PT"
const val S_LANG_RU = "RU"
const val S_LANG_ES = "ES"
const val S_LANG_TH = "TH"
const val S_LANG_VI = "VI"
val S_LANGS = arrayOf(
S_LANG_EN,
S_LANG_ZH,
S_LANG_ES,
S_LANG_KO,
S_LANG_RU,
S_LANG_FR,
S_LANG_PT,
S_LANG_TH,
S_LANG_DE,
S_LANG_IT,
S_LANG_VI,
S_LANG_PL,
S_LANG_HU,
S_LANG_NL,
"EN",
"ZH",
"ES",
"KO",
"RU",
"FR",
"PT",
"TH",
"DE",
"IT",
"VI",
"PL",
"HU",
"NL",
)
val S_LANG_PATTERNS = arrayOf(
private val S_LANG_PATTERNS = arrayOf(
Pattern.compile(
"[(\\[]eng(?:lish)?[)\\]]|英訳",
Pattern.CASE_INSENSITIVE,
@@ -148,7 +110,39 @@ interface GalleryInfo {
"language:dutch",
)
private fun generateSLangFromTags(simpleTags: List<String>): String? {
for (tag in simpleTags) {
for (i in S_LANGS.indices) {
if (S_LANG_TAGS[i] == tag) {
return S_LANGS[i]
}
}
}
return null
}
private fun generateSLangFromTitle(title: String): String? {
for (i in S_LANGS.indices) {
if (S_LANG_PATTERNS[i].matcher(title).find()) {
return S_LANGS[i]
}
}
return null
}
const val NOT_FAVORITED = -2
const val LOCAL_FAVORITED = -1
}
}
fun GalleryInfo.findBaseInfo(): BaseGalleryInfo {
return when (this) {
is BaseGalleryInfo -> this
is GalleryDetail -> galleryInfo
else -> unreachable()
}
}
fun GalleryInfo.asGalleryDetail(): GalleryDetail? {
return this as? GalleryDetail
}
@@ -19,7 +19,7 @@ import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class GalleryTagGroup(
data class GalleryTagGroup(
val groupName: String,
private val mTagList: MutableList<String> = mutableListOf(),
) : Parcelable, MutableList<String> by mTagList
private val tagList: MutableList<String> = mutableListOf(),
) : Parcelable, MutableList<String> by tagList
@@ -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
@@ -35,6 +37,7 @@ data class ListUrlBuilder(
var mode: Int = MODE_NORMAL,
private var mPrev: String? = null,
var mNext: String? = null,
// Reset to null after initial loading
var mJumpTo: String? = null,
var category: Int = EhUtils.NONE,
private var mKeyword: String? = null,
@@ -48,23 +51,6 @@ data class ListUrlBuilder(
var isOnlySearchCovers: Boolean = false,
) : Parcelable {
fun reset() {
mode = MODE_NORMAL
mPrev = null
mNext = null
mJumpTo = null
this.category = EhUtils.NONE
mKeyword = null
advanceSearch = -1
minRating = -1
pageFrom = -1
pageTo = -1
imagePath = null
isUseSimilarityScan = false
isOnlySearchCovers = false
hash = null
}
fun setIndex(index: String?, isNext: Boolean = true) {
mNext = index?.takeIf { isNext }
mPrev = index?.takeUnless { isNext }
@@ -80,8 +66,7 @@ data class ListUrlBuilder(
mKeyword = keyword
}
fun set(q: QuickSearch) {
reset()
constructor(q: QuickSearch) : this() {
mode = q.mode
this.category = q.category
mKeyword = q.keyword
@@ -134,9 +119,8 @@ data class ListUrlBuilder(
/**
* @param query xxx=yyy&mmm=nnn
*/
fun setQuery(query: String?) {
constructor(query: String?) : this() {
// TODO page
reset()
if (query.isNullOrEmpty()) {
return
}
@@ -271,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")
@@ -313,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(),
)
}
}
@@ -374,9 +350,7 @@ data class ListUrlBuilder(
buildString {
append(EhUrl.HOST_E)
append("toplist.php?tl=")
mKeyword.orEmpty().let {
append(encodeUTF8(it))
}
append(mKeyword!!)
mJumpTo?.let {
append("&p=").append(it)
}
@@ -1,5 +0,0 @@
package com.hippo.ehviewer.client.exception
import com.hippo.ehviewer.R
class CloudflareBypassException : EhException(R.string.cloudflare_bypass_failed)
@@ -2,16 +2,16 @@ 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 moe.tarsin.coroutines.runSuspendCatching
object FavoritesParser {
suspend fun parse(body: String): Result {
if (body.contains("This page requires you to log on.</p>")) {
throw NotLoggedInException()
}
suspend fun parse(body: ByteBuffer): Result {
val catArray = arrayOfNulls<String>(10)
val countArray = runCatching {
parseFav(body, catArray).also { check(it.isNotEmpty()) }
val countArray = runSuspendCatching {
parseFav(body, favCat = catArray).also { check(it.isNotEmpty()) }
}.getOrElse {
if (it is RuntimeException && it.message == "Not logged in!") throw NotLoggedInException()
throw ParseException("Parse favorites error", it)
}
val result = GalleryListParser.parse(body)
@@ -29,4 +29,4 @@ object FavoritesParser {
}
}
private external fun parseFav(body: String, favCat: Array<String?>): IntArray
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit(), favCat: Array<String?>): IntArray
@@ -33,7 +33,7 @@ object GalleryApiParser {
titleJpn = item.titleJpn.unescapeXml()
category = getCategory(item.category)
thumbKey = getThumbKey(handleThumbUrlResolution(item.thumb)!!)
uploader = item.uploader.unescapeXml()
uploader = item.uploader?.unescapeXml()
posted = ParserUtils.formatDate(item.posted * 1000)
rating = item.rating
simpleTags = item.tags
@@ -54,7 +54,8 @@ object GalleryApiParser {
val titleJpn: String,
val category: String,
val thumb: String,
val uploader: String,
// Null in some old galleries
val uploader: String?,
val posted: Long,
@SerialName("filecount")
val pages: Int,
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.client.parser
import arrow.core.unzip
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhFilter
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.EhUtils.getCategory
@@ -75,8 +76,7 @@ object GalleryDetailParser {
private val PATTERN_NEWER_DATE = Regex(", added (.+?)<br />")
private val PATTERN_FAVORITE_SLOT =
Regex("/fav.png\\); background-position:0px -(\\d+)px")
private val EMPTY_GALLERY_TAG_GROUP_ARRAY = arrayOf<GalleryTagGroup>()
private val EMPTY_GALLERY_COMMENT_ARRAY = GalleryCommentList(arrayOf(), false)
private val EMPTY_GALLERY_COMMENT_LIST = GalleryCommentList(emptyList(), false)
private val WEB_COMMENT_DATE_FORMAT = DateTimeFormatter
.ofPattern("dd MMMM yyyy, HH:mm", Locale.US).withZone(ZoneOffset.UTC)
private const val OFFENSIVE_STRING =
@@ -307,7 +307,7 @@ object GalleryDetailParser {
/**
* Parse tag groups with html parser
*/
private fun parseTagGroups(document: Document): Array<GalleryTagGroup> {
private fun parseTagGroups(document: Document): List<GalleryTagGroup> {
return try {
val taglist = document.getElementById("taglist")!!
val tagGroups = taglist.child(0).child(0).children()
@@ -315,73 +315,69 @@ object GalleryDetailParser {
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
EMPTY_GALLERY_TAG_GROUP_ARRAY
emptyList()
}
}
private fun parseTagGroups(trs: Elements): Array<GalleryTagGroup> {
private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> {
return try {
trs.mapNotNull { parseTagGroup(it) }.toTypedArray()
trs.mapNotNull { parseTagGroup(it) }
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
EMPTY_GALLERY_TAG_GROUP_ARRAY
emptyList()
}
}
private suspend fun parseComment(element: Element): GalleryComment? {
return try {
val comment = GalleryComment()
// Id
val a = element.previousElementSibling()
val name = a!!.attr("name")
comment.id = name trimAnd { substring(1).toInt().toLong() }
val id = name trimAnd { substring(1).toInt().toLong() }
// Editable, vote up and vote down
val c4 = element.getElementsByClass("c4").first()
var voteUpAble = false
var voteUpEd = false
var voteDownAble = false
var voteDownEd = false
var editable = false
var uploader = false
if (null != c4) {
if ("Uploader Comment" == c4.text()) {
comment.uploader = true
uploader = true
}
for (e in c4.children()) {
when (e.text()) {
"Vote+" -> {
comment.voteUpAble = true
comment.voteUpEd = e.attr("style").trim().isNotEmpty()
voteUpAble = true
voteUpEd = e.attr("style").trim().isNotEmpty()
}
"Vote-" -> {
comment.voteDownAble = true
comment.voteDownEd = e.attr("style").trim().isNotEmpty()
voteDownAble = true
voteDownEd = e.attr("style").trim().isNotEmpty()
}
"Edit" -> comment.editable = true
"Edit" -> editable = true
}
}
}
// Vote state
val c7 = element.getElementsByClass("c7").first()
if (null != c7) {
comment.voteState = c7.text().trim()
}
val voteState = c7?.text()?.trim()
// Score
val c5 = element.getElementsByClass("c5").first()
if (null != c5) {
val es = c5.children()
if (!es.isEmpty()) {
comment.score = es[0].text().trim().toIntOrDefault(0)
}
}
val score = c5?.children()?.first()?.text()?.trim()?.toIntOrDefault(0) ?: 0
// time
val c3 = element.getElementsByClass("c3").first()
val temp = c3!!.ownText()
val time = if (temp.endsWith(':')) {
// user
comment.user = c3.child(0).text()
temp.substring("Posted on ".length, temp.length - " by:".length)
val (timeStr, user) = if (temp.endsWith(':')) {
temp.substring("Posted on ".length, temp.length - " by:".length) to c3.child(0).text()
} else {
temp.substring("Posted on ".length)
temp.substring("Posted on ".length) to null
}
comment.time = Instant.from(WEB_COMMENT_DATE_FORMAT.parse(time)).toEpochMilli()
val time = Instant.from(WEB_COMMENT_DATE_FORMAT.parse(timeStr)).toEpochMilli()
// comment
val c6 = element.getElementsByClass("c6").first()
// fix underline support
@@ -390,24 +386,21 @@ object GalleryDetailParser {
e.tagName("u")
}
}
comment.comment = c6.html()
val commentHtml = c6.html()
// filter comment
if (!comment.uploader) {
if (!uploader) {
val sEhFilter = EhFilter
if (sEhFilter.filterCommenter(comment.user!!) || sEhFilter.filterComment(comment.comment!!)) {
if (score <= Settings.commentThreshold || sEhFilter.filterCommenter(user.orEmpty()) || sEhFilter.filterComment(commentHtml)) {
return null
}
}
// last edited
val c8 = element.getElementsByClass("c8").first()
if (c8 != null) {
val e = c8.children().first()
if (e != null) {
comment.lastEdited =
Instant.from(WEB_COMMENT_DATE_FORMAT.parse(e.text())).toEpochMilli()
}
}
comment
val lastEdited = c8?.children()?.first()?.run { Instant.from(WEB_COMMENT_DATE_FORMAT.parse(text())).toEpochMilli() } ?: 0
GalleryComment(
id, score, editable, voteUpAble, voteUpEd, voteDownAble,
voteDownEd, uploader, voteState, time, user, commentHtml, lastEdited,
)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
@@ -437,11 +430,11 @@ object GalleryDetailParser {
},
chd!!,
)
GalleryCommentList(list.toTypedArray(), hasMore)
GalleryCommentList(list, hasMore)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
EMPTY_GALLERY_COMMENT_ARRAY
EMPTY_GALLERY_COMMENT_LIST
}
}
@@ -1,5 +1,6 @@
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
@@ -7,34 +8,41 @@ import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import moe.tarsin.coroutines.runSuspendCatching
object GalleryListParser {
suspend fun parse(body: String) = runCatching {
parseGalleryInfoList(body).apply {
galleryInfoList.onEach {
if (it.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(it.gid)) {
it.favoriteSlot = LOCAL_FAVORITED
}
it.generateSLang()
suspend fun parse(body: ByteBuffer) = runSuspendCatching {
parseGalleryInfoList(body)
}.onFailure {
if (it is RuntimeException) {
if (it.message?.startsWith("Your IP address") == true) {
throw EhException(it.message)
}
if (it.message == "No watched tags!") {
throw EhException(R.string.gallery_list_empty_hit_subscription)
}
if (it.message == "No hits found!") {
throw EhException(R.string.gallery_list_empty_hit)
}
}
}.getOrElse {
if (body.contains("<p>You do not have any watched tags")) {
throw EhException(R.string.gallery_list_empty_hit_subscription)
}
if (body.contains("No hits found</p>")) {
throw EhException(R.string.gallery_list_empty_hit)
}
throw ParseException("Can't parse gallery list", it)
}.getOrThrow().apply {
galleryInfoList.onEach {
if (it.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(it.gid)) {
it.favoriteSlot = LOCAL_FAVORITED
}
it.generateSLang()
}
}
val emptyResult = GalleryListResult(null, null, arrayListOf())
}
class GalleryListResult(
class GalleryListResult @Keep constructor(
val prev: String?,
val next: String?,
val galleryInfoList: ArrayList<BaseGalleryInfo>,
)
private external fun parseGalleryInfoList(e: String): GalleryListResult
private external fun parseGalleryInfoList(body: ByteBuffer, size: Int = body.limit()): GalleryListResult
@@ -42,9 +42,7 @@ object GalleryListUrlParser {
}
val path = url.path ?: return null
return if (PATH_NORMAL == path || path.isEmpty()) {
val builder = ListUrlBuilder()
builder.setQuery(url.query)
builder
ListUrlBuilder(url.query)
} else if (path.startsWith(PATH_UPLOADER)) {
parseUploader(path)
} else if (path.startsWith(PATH_TAG)) {
@@ -57,10 +55,7 @@ object GalleryListUrlParser {
} catch (e: NumberFormatException) {
return null
}
val builder = ListUrlBuilder()
builder.setQuery(url.query)
builder.category = category
builder
ListUrlBuilder(url.query).also { it.category = category }
} else {
null
}
@@ -1,56 +0,0 @@
/*
* Copyright 2019 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.parseAs
import com.hippo.ehviewer.util.unescapeXml
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
object GalleryPageApiParser {
private val PATTERN_IMAGE_URL = Regex("<img[^>]*src=\"([^\"]+)\" style")
private val PATTERN_SKIP_HATH_KEY = Regex("onclick=\"return nl\\('([^)]+)'\\)")
private val PATTERN_ORIGIN_IMAGE_URL = Regex("<a href=\"([^\"]+)fullimg.php([^\"]+)\">")
fun parse(body: String) = runCatching {
val res = body.parseAs<JsonResult>()
val imageUrl = PATTERN_IMAGE_URL.find(res.imageUrl)?.run {
groupValues[1].trim().unescapeXml()
}
val skipHathKey = PATTERN_SKIP_HATH_KEY.find(res.skipHathKeyAndOriginImageUrl)?.run {
groupValues[1].trim().unescapeXml()
}
val originImageUrl = PATTERN_ORIGIN_IMAGE_URL.find(res.skipHathKeyAndOriginImageUrl)?.run {
groupValues[1].unescapeXml() + "fullimg.php" + groupValues[2].unescapeXml()
}
check(!imageUrl.isNullOrEmpty())
Result(imageUrl, skipHathKey, originImageUrl)
}.getOrElse { e ->
runCatching { body.parseAs<Error>() }.onSuccess { throw ParseException(it.error) }
throw ParseException("Parse image url and skip hath key error", e)
}
@Serializable
private data class JsonResult(
@SerialName("i3")
val imageUrl: String,
@SerialName("i6")
val skipHathKeyAndOriginImageUrl: String,
)
class Result(val imageUrl: String, val skipHathKey: String?, val originImageUrl: String?)
}
@@ -17,46 +17,32 @@ package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.util.unescapeXml
import java.util.regex.Pattern
object GalleryPageParser {
private val PATTERN_IMAGE_URL = Pattern.compile("<img[^>]*src=\"([^\"]+)\" style")
private val PATTERN_SKIP_HATH_KEY = Pattern.compile("onclick=\"return nl\\('([^)]+)'\\)")
private val PATTERN_ORIGIN_IMAGE_URL =
Pattern.compile("<a href=\"([^\"]+)fullimg.php([^\"]+)\">")
private val PATTERN_IMAGE_URL = Regex("<img[^>]*src=\"([^\"]+)\" style")
private val PATTERN_SKIP_HATH_KEY = Regex("onclick=\"return nl\\('([\\d-]+)'\\)")
private val PATTERN_ORIGIN_IMAGE_URL = Regex("<a href=\"([^\"]+/fullimg/[^\"]+)\">")
// TODO Not sure about the size of show keys
private val PATTERN_SHOW_KEY = Pattern.compile("var showkey=\"([0-9a-z]+)\";")
private val PATTERN_SHOW_KEY = Regex("var showkey=\"([0-9a-z]+)\";")
fun parse(body: String): Result {
var m = PATTERN_IMAGE_URL.matcher(body)
val imageUrl = if (m.find()) {
m.group(1)!!.trim().unescapeXml()
} else {
null
val imageUrl = PATTERN_IMAGE_URL.find(body)?.run {
groupValues[1].unescapeXml()
}
m = PATTERN_SKIP_HATH_KEY.matcher(body)
val skipHathKey = if (m.find()) {
m.group(1)!!.trim().unescapeXml()
} else {
null
val skipHathKey = PATTERN_SKIP_HATH_KEY.find(body)?.run {
groupValues[1]
}
m = PATTERN_ORIGIN_IMAGE_URL.matcher(body)
val originImageUrl = if (m.find()) {
m.group(1)!!.unescapeXml() + "fullimg.php" + m.group(2)!!.unescapeXml()
} else {
null
val originImageUrl = PATTERN_ORIGIN_IMAGE_URL.find(body)?.run {
groupValues[1].unescapeXml()
}
m = PATTERN_SHOW_KEY.matcher(body)
val showKey = if (m.find()) {
m.group(1)
} else {
null
val showKey = PATTERN_SHOW_KEY.find(body)?.run {
groupValues[1]
}
return if (!imageUrl.isNullOrEmpty() && !showKey.isNullOrEmpty()) {
return if (!imageUrl.isNullOrEmpty()) {
Result(imageUrl, skipHathKey, originImageUrl, showKey)
} else {
throw ParseException("Parse image url and show error")
throw ParseException("Parse image url error")
}
}
@@ -64,6 +50,6 @@ object GalleryPageParser {
val imageUrl: String,
val skipHathKey: String?,
val originImageUrl: String?,
val showKey: String,
val showKey: String?,
)
}
@@ -1,6 +1,7 @@
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
@@ -35,6 +36,6 @@ object HomeParser {
}
@Parcelize
data class Limits(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
data class Limits @Keep constructor(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
private external fun parseLimit(body: ByteBuffer, limit: Int = body.limit()): Limits
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.client.parser
import androidx.annotation.Keep
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
@@ -9,7 +10,7 @@ object TorrentParser {
}.getOrElse { throw ParseException("Can't parse torrent list", it) }
}
class Torrent(
class Torrent @Keep constructor(
val posted: String,
val size: String,
val seeds: Int,
@@ -21,6 +22,6 @@ class Torrent(
fun Torrent.format() = "[$posted] $name [$size] [↑$seeds ↓$peers ✓$downloads]"
typealias TorrentResult = ArrayList<Torrent>
typealias TorrentResult = List<Torrent>
private external fun parseTorrent(body: ByteBuffer, size: Int = body.limit()): ArrayList<Torrent>
@@ -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
@@ -2,11 +2,12 @@ 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.size.Size
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
@@ -27,7 +28,8 @@ fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
if (preview is NormalGalleryPreview) size(Size.ORIGINAL)
}
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 {
@@ -2,45 +2,39 @@
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
import coil3.ComponentRegistry
import coil3.ImageLoader
import coil3.Uri
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.util.copyTo
import io.ktor.client.statement.bodyAsChannel
/**
* A HttpUriFetcher impl use Cronet for jvmheapless IO
* A HttpUriFetcher impl use Ktor (Will do jvmheapless IO when backing engine is Cronet)
* cacheHeader is never respected | recorded since thumb is immutable
*/
class CronetHttpUriFetcher(private val data: String, private val options: Options, private val imageLoader: ImageLoader) : Fetcher {
class KtorHttpUriFetcher(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 {
val success = ehRequest(data, verbose = false).execute {
diskCache.suspendEdit(diskCacheKey) {
RandomAccessFile(data.toFile(), "rw").use {
copyToChannel(it.channel)
}
it.bodyAsChannel().copyTo(data.toFile())
}
}
check(success)
// diskcache snapshot MUST exist here
requireNotNull(diskCache.openSnapshot(diskCacheKey))
}
return SourceResult(
return SourceFetchResult(
source = ImageSource(snapshot.data, diskCache.fileSystem, diskCacheKey, snapshot),
mimeType = getMimeType(data),
dataSource = DataSource.DISK,
@@ -48,9 +42,9 @@ class CronetHttpUriFetcher(private val data: String, private val options: Option
}
}
fun ComponentRegistry.Builder.installCronetHttpUriFetcher() = add { data: Uri, options, loader ->
fun ComponentRegistry.Builder.installKtorHttpUriFetcher() = add { data: Uri, options, loader ->
if (data.scheme != "http" && data.scheme != "https") return@add null
CronetHttpUriFetcher(data.toString(), options, loader)
KtorHttpUriFetcher(data.toString(), options, loader)
}
private fun getMimeType(url: String) = MimeTypeMap.getSingleton().getMimeTypeFromUrl(url)
@@ -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,33 +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
suspend inline fun CronetRequest.awaitBodyFully(crossinline callback: (ByteBuffer) -> Unit) {
return pool.useInstance { buffer ->
suspendCancellableCoroutine { cont ->
consumer = {
callback(it)
buffer.clear()
request.read(buffer)
}
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)
@@ -18,6 +18,15 @@ package com.hippo.ehviewer.download
import android.net.Uri
import android.util.Log
import android.util.SparseLongArray
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import com.google.android.material.math.MathUtils
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.Settings
@@ -32,51 +41,48 @@ import com.hippo.ehviewer.spider.readCompatFromUniFile
import com.hippo.ehviewer.spider.write
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ConcurrentPool
import com.hippo.ehviewer.util.LongList
import com.hippo.ehviewer.util.SimpleHandler
import com.hippo.ehviewer.util.assertNotMainThread
import com.hippo.ehviewer.util.mapNotNull
import com.hippo.ehviewer.util.runAssertingNotMainThread
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import java.util.LinkedList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.shareIn
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 {
// All download info list
val allInfoList: LinkedList<DownloadInfo> = runAssertingNotMainThread { LinkedList(EhDB.getAllDownloadInfo()) }
val allInfoList = runAssertingNotMainThread { EhDB.getAllDownloadInfo() }.toMutableStateList()
// All download info map
private val mAllInfoMap = allInfoList.associateBy { it.gid } as MutableMap<Long, DownloadInfo>
// label and info list map, without default label info list
private val map: MutableMap<String?, LinkedList<DownloadInfo>>
private val map: MutableMap<String?, SnapshotStateList<DownloadInfo>>
// All labels without default label
val labelList = runAssertingNotMainThread { EhDB.getAllDownloadLabelList() } as MutableList<DownloadLabel>
val labelList = runAssertingNotMainThread { EhDB.getAllDownloadLabelList() }.toMutableStateList()
// Store download info with default label
val defaultInfoList: LinkedList<DownloadInfo>
private val defaultInfoList: SnapshotStateList<DownloadInfo>
// Store download info wait to start
private val mWaitList = LinkedList<DownloadInfo>()
private val mWaitList = ArrayDeque<DownloadInfo>()
private val mSpeedReminder = SpeedReminder()
private var mDownloadInfoListener: DownloadInfoListener? = null
private val mNotifyTaskPool = ConcurrentPool<NotifyTask?>(5)
private var mDownloadListener: DownloadListener? = null
private var mCurrentTask: DownloadInfo? = null
private var mCurrentSpider: SpiderQueen? = null
private val mutableNotifyFlow = MutableSharedFlow<DownloadInfo>(extraBufferCapacity = 1)
val notifyFlow = mutableNotifyFlow.asSharedFlow()
init {
assertNotMainThread()
map = runAssertingNotMainThread {
@@ -87,16 +93,16 @@ object DownloadManager : OnSpiderListener {
labelList.add(EhDB.addDownloadLabel(DownloadLabel(label, labelList.size)))
}
}
it.key to LinkedList(it.value)
it.key to it.value.toMutableStateList()
}
} as MutableMap<String?, LinkedList<DownloadInfo>>
defaultInfoList = map.remove(null) ?: LinkedList()
} as MutableMap<String?, SnapshotStateList<DownloadInfo>>
defaultInfoList = map.remove(null) ?: mutableStateListOf()
labelList.forEach { (label) ->
map[label] ?: LinkedList<DownloadInfo>().also { map[label] = it }
map[label] ?: mutableStateListOf<DownloadInfo>().also { map[label] = it }
}
}
private fun getInfoListForLabel(label: String?) = if (label == null) {
fun getInfoListForLabel(label: String?) = if (label == null) {
defaultInfoList
} else {
map[label]
@@ -116,8 +122,6 @@ object DownloadManager : OnSpiderListener {
fun containDownloadInfo(gid: Long) = mAllInfoMap.containsKey(gid)
fun getLabelDownloadInfoList(label: String?) = map[label]
fun getDownloadInfo(gid: Long) = mAllInfoMap[gid]
fun getDownloadState(gid: Long): Int {
@@ -158,7 +162,7 @@ object DownloadManager : OnSpiderListener {
mDownloadListener!!.onStart(info)
}
// Notify state update
mDownloadInfoListener?.onUpdate(info)
mutableNotifyFlow.tryEmit(info)
}
}
@@ -179,7 +183,7 @@ object DownloadManager : OnSpiderListener {
// Update in DB
EhDB.putDownloadInfo(info)
// Notify state update
mDownloadInfoListener?.onUpdate(info)
mutableNotifyFlow.tryEmit(info)
// Make sure download is running
ensureDownload()
}
@@ -196,10 +200,10 @@ object DownloadManager : OnSpiderListener {
Log.e(TAG, "Can't find download info list with label: $label")
return
}
list.addFirst(info)
list.add(0, info)
// Add to all download list and map
allInfoList.addFirst(info)
allInfoList.add(0, info)
mAllInfoMap[galleryInfo.gid] = info
// Add to wait list
@@ -209,7 +213,7 @@ object DownloadManager : OnSpiderListener {
EhDB.putDownloadInfo(info)
// Notify
mDownloadInfoListener?.onUpdate(info)
mutableNotifyFlow.tryEmit(info)
// Make sure download is running
ensureDownload()
@@ -218,90 +222,71 @@ object DownloadManager : OnSpiderListener {
}
}
suspend fun startRangeDownload(gidList: LongList) {
var update = false
for (i in 0 until gidList.size) {
val gid = gidList[i]
val info = mAllInfoMap[gid]
if (null == info) {
Log.d(TAG, "Can't get download info with gid: $gid")
continue
suspend fun startRangeDownload(gidList: LongArray) {
val updateList = gidList.mapNotNull { mAllInfoMap[it] }
.filter {
when (it.state) {
DownloadInfo.STATE_NONE, DownloadInfo.STATE_FAILED, DownloadInfo.STATE_FINISH -> true
else -> false
}
}
if (info.state == DownloadInfo.STATE_NONE || info.state == DownloadInfo.STATE_FAILED || info.state == DownloadInfo.STATE_FINISH) {
update = true
// Set state DownloadInfo.STATE_WAIT
info.state = DownloadInfo.STATE_WAIT
// Add to wait list
mWaitList.add(info)
// Update in DB
EhDB.putDownloadInfo(info)
if (updateList.isNotEmpty()) {
updateList.onEach {
it.state = DownloadInfo.STATE_WAIT
EhDB.putDownloadInfo(it)
mutableNotifyFlow.tryEmit(it)
}
}
if (update) {
// Notify Listener
mDownloadInfoListener?.onUpdateAll()
// Ensure download
mWaitList.addAll(updateList)
ensureDownload()
}
}
private val callbackFlowScope = CoroutineScope(Dispatchers.IO)
@Stable
@Composable
fun collectDownloadState(gid: Long): State<Int> = remember {
notifyFlow.transform { if (it.gid == gid) emit(getDownloadState(gid)) }
}.collectAsState(getDownloadState(gid))
private val _stateFlow = callbackFlow {
val listener = object : DownloadInfoListener {
override fun onUpdate(info: DownloadInfo) {
trySend(info.gid)
}
@Stable
@Composable
fun collectContainDownloadInfo(gid: Long): State<Boolean> = remember {
notifyFlow.transform { if (it.gid == gid) emit(containDownloadInfo(gid)) }
}.collectAsState(containDownloadInfo(gid))
override fun onUpdateAll() {
trySend(0)
}
}
mDownloadInfoListener = listener
awaitClose {
mDownloadInfoListener = null
}
}.shareIn(callbackFlowScope, SharingStarted.Eagerly)
fun stateFlow(gid: Long) = _stateFlow.filter { it == gid }
fun stateFlow() = _stateFlow
@Stable
@Composable
inline fun <T> updatedDownloadInfo(info: DownloadInfo, crossinline transform: @DisallowComposableCalls DownloadInfo.() -> T): T = remember {
notifyFlow.transform { if (it.gid == info.gid) emit(transform(it)) }
}.collectAsState(transform(info)).value
suspend fun startAllDownload() {
var update = false
// Start all STATE_NONE and STATE_FAILED item
for (info in allInfoList) {
if (info.state == DownloadInfo.STATE_NONE || info.state == DownloadInfo.STATE_FAILED) {
update = true
// Set state DownloadInfo.STATE_WAIT
info.state = DownloadInfo.STATE_WAIT
// Add to wait list
mWaitList.add(info)
// Update in DB
EhDB.putDownloadInfo(info)
val updateList = allInfoList.filter {
when (it.state) {
DownloadInfo.STATE_NONE, DownloadInfo.STATE_FAILED -> true
else -> false
}
}
if (update) {
// Notify Listener
mDownloadInfoListener?.onUpdateAll()
// Ensure download
if (updateList.isNotEmpty()) {
updateList.onEach {
it.state = DownloadInfo.STATE_WAIT
EhDB.putDownloadInfo(it)
mutableNotifyFlow.tryEmit(it)
}
mWaitList.addAll(updateList)
ensureDownload()
}
}
suspend fun addDownload(downloadInfoList: List<DownloadInfo>, notify: Boolean = true) {
suspend fun addDownload(downloadInfoList: List<DownloadInfo>) {
downloadInfoList.filterNot { containDownloadInfo(it.gid) }.forEach { info ->
// Ensure download state
if (DownloadInfo.STATE_WAIT == info.state ||
DownloadInfo.STATE_DOWNLOAD == info.state
) {
if (DownloadInfo.STATE_WAIT == info.state || DownloadInfo.STATE_DOWNLOAD == info.state) {
info.state = DownloadInfo.STATE_NONE
}
info.position = allInfoList.size
// Add to label download list
val list = getInfoListForLabel(info.label)
?: addLabel(info.label).let { getInfoListForLabel(info.label)!! }
val list = getInfoListForLabel(info.label) ?: addLabel(info.label).let { getInfoListForLabel(info.label)!! }
list.add(info)
// Sort
list.sortByPositionDescending()
@@ -316,17 +301,12 @@ object DownloadManager : OnSpiderListener {
// Sort all download list
allInfoList.sortByPositionDescending()
// Notify
if (notify) {
mDownloadInfoListener?.onUpdateAll()
}
}
suspend fun addDownloadLabel(downloadLabelList: List<DownloadLabel>) {
val offset = downloadLabelList.size
downloadLabelList.filterNot { containLabel(it.label) }.forEachIndexed { index, label ->
map[label.label] = LinkedList()
map[label.label] = mutableStateListOf()
label.position = index + offset
labelList.add(EhDB.addDownloadLabel(label))
}
@@ -339,26 +319,24 @@ object DownloadManager : OnSpiderListener {
// Add to default label download list
val list = defaultInfoList
list.addFirst(info)
list.add(0, info)
// Add to all download list and map
allInfoList.addFirst(info)
allInfoList.add(0, info)
mAllInfoMap[galleryInfo.gid] = info
// Save to
EhDB.putDownloadInfo(info)
// Notify
withUIContext {
mDownloadInfoListener?.onUpdate(info)
}
mutableNotifyFlow.tryEmit(info)
}
suspend fun stopDownload(gid: Long) {
val info = stopDownloadInternal(gid)
if (info != null) {
// Update listener
mDownloadInfoListener?.onUpdate(info)
mutableNotifyFlow.tryEmit(info)
// Ensure download
ensureDownload()
}
@@ -368,17 +346,19 @@ object DownloadManager : OnSpiderListener {
val info = stopCurrentDownloadInternal()
if (info != null) {
// Update listener
mDownloadInfoListener?.onUpdate(info)
mutableNotifyFlow.tryEmit(info)
// Ensure download
ensureDownload()
}
}
suspend fun stopRangeDownload(gidList: LongList) {
suspend fun stopRangeDownload(gidList: LongArray) {
stopRangeDownloadInternal(gidList)
// Update listener
mDownloadInfoListener?.onUpdateAll()
gidList.mapNotNull { mAllInfoMap[it] }.forEach {
mutableNotifyFlow.tryEmit(it)
}
// Ensure download
ensureDownload()
@@ -390,14 +370,14 @@ object DownloadManager : OnSpiderListener {
info.state = DownloadInfo.STATE_NONE
// Update in DB
EhDB.putDownloadInfo(info)
// Notify
mutableNotifyFlow.tryEmit(info)
}
mWaitList.clear()
// Stop current
stopCurrentDownloadInternal()
// Notify mDownloadInfoListener
mDownloadInfoListener?.onUpdateAll()
}
suspend fun deleteDownload(gid: Long, deleteFiles: Boolean = false) {
@@ -418,7 +398,7 @@ object DownloadManager : OnSpiderListener {
if (index >= 0) {
list.remove(info)
// Update listener
mDownloadInfoListener?.onUpdate(info)
mutableNotifyFlow.tryEmit(info)
}
}
@@ -432,7 +412,7 @@ object DownloadManager : OnSpiderListener {
}
}
suspend fun deleteRangeDownload(gidList: LongList) {
suspend fun deleteRangeDownload(gidList: LongArray) {
stopRangeDownloadInternal(gidList)
val list = gidList.mapNotNull { gid ->
mAllInfoMap.remove(gid)?.also { getInfoListForLabel(it.label)?.remove(it) }
@@ -441,7 +421,7 @@ object DownloadManager : OnSpiderListener {
allInfoList.removeAll(list.toSet())
// Update listener
mDownloadInfoListener?.onUpdateAll()
list.forEach { mutableNotifyFlow.tryEmit(it) }
// Ensure download
ensureDownload()
@@ -546,11 +526,11 @@ object DownloadManager : OnSpiderListener {
// Update in DB
// Update mDownloadListener
private suspend fun stopRangeDownloadInternal(gidList: LongList) {
private suspend fun stopRangeDownloadInternal(gidList: LongArray) {
// Two way
if (gidList.size < mWaitList.size) {
for (i in 0 until gidList.size) {
stopDownloadInternal(gidList[i])
for (element in gidList) {
stopDownloadInternal(element)
}
} else {
// Check current task
@@ -578,12 +558,12 @@ object DownloadManager : OnSpiderListener {
/**
* @param label Not allow new label
*/
suspend fun changeLabel(list: List<DownloadInfo>, label: String?) {
suspend fun changeLabel(list: Collection<DownloadInfo>, label: String?) {
if (null != label && !containLabel(label)) {
Log.e(TAG, "Not exits label: $label")
return
}
val dstList: MutableList<DownloadInfo>? = getInfoListForLabel(label)
val dstList = getInfoListForLabel(label)
if (dstList == null) {
Log.e(TAG, "Can't find label with label: $label")
return
@@ -592,7 +572,7 @@ object DownloadManager : OnSpiderListener {
if (info.label == label) {
continue
}
val srcList: MutableList<DownloadInfo>? = getInfoListForLabel(info.label)
val srcList = getInfoListForLabel(info.label)
if (srcList == null) {
Log.e(TAG, "Can't find label with label: " + info.label)
continue
@@ -605,7 +585,6 @@ object DownloadManager : OnSpiderListener {
// Save to DB
EhDB.putDownloadInfo(info)
}
mDownloadInfoListener?.onUpdateAll()
}
suspend fun addLabel(label: String?) {
@@ -613,41 +592,30 @@ object DownloadManager : OnSpiderListener {
return
}
labelList.add(EhDB.addDownloadLabel(DownloadLabel(label, labelList.size)))
map[label] = LinkedList()
map[label] = mutableStateListOf()
}
suspend fun moveLabel(fromPosition: Int, toPosition: Int) {
val item = labelList.removeAt(fromPosition)
labelList.add(toPosition, item)
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)
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) {
// Find in label list
var found = false
for (raw in labelList) {
if (from == raw.label) {
found = true
raw.label = to
// Update in DB
EhDB.updateDownloadLabel(raw)
break
}
val index = labelList.indexOfFirst { it.label == from }
if (index != -1) {
val exist = labelList.removeAt(index)
val new = exist.copy(label = to)
labelList.add(index, new)
EhDB.updateDownloadLabel(new)
val list = map.remove(from)?.onEach { it.label = to }
if (list != null) map[to] = list
}
if (!found) {
return
}
val list = map.remove(from) ?: return
// Update info label
for (info in list) {
info.label = to
}
// Put list back with new label
map[to] = list
}
suspend fun deleteLabel(label: String) {
@@ -754,18 +722,6 @@ object DownloadManager : OnSpiderListener {
// Ignore
}
interface DownloadInfoListener {
/**
* The special info is changed
*/
fun onUpdate(info: DownloadInfo)
/**
* Maybe all data is changed, but size is the same
*/
fun onUpdateAll()
}
interface DownloadListener {
/**
* Get 509 error
@@ -870,7 +826,7 @@ object DownloadManager : OnSpiderListener {
Log.e(TAG, "Current task is null, but it should not be")
} else {
info.total = mPages
mDownloadInfoListener?.onUpdate(info)
mutableNotifyFlow.tryEmit(info)
}
}
@@ -899,7 +855,7 @@ object DownloadManager : OnSpiderListener {
if (mDownloadListener != null) {
mDownloadListener!!.onGetPage(info)
}
mDownloadInfoListener?.onUpdate(info)
mutableNotifyFlow.tryEmit(info)
}
}
@@ -912,7 +868,7 @@ object DownloadManager : OnSpiderListener {
info.finished = mFinished
info.downloaded = mDownloaded
info.total = mTotal
mDownloadInfoListener?.onUpdate(info)
mutableNotifyFlow.tryEmit(info)
}
}
@@ -951,7 +907,7 @@ object DownloadManager : OnSpiderListener {
if (mDownloadListener != null) {
mDownloadListener!!.onFinish(info)
}
mDownloadInfoListener?.onUpdate(info)
mutableNotifyFlow.tryEmit(info)
// Start next download
ensureDownload()
}
@@ -1037,7 +993,7 @@ object DownloadManager : OnSpiderListener {
if (mDownloadListener != null) {
mDownloadListener!!.onDownload(info)
}
mDownloadInfoListener?.onUpdate(info)
mutableNotifyFlow.tryEmit(info)
}
mBytesRead = 0
if (!mStop) {
@@ -35,9 +35,8 @@ import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.scene.DownloadsScene
import com.hippo.ehviewer.ui.screen.DownloadsFragment
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.LongList
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.SimpleHandler
import com.hippo.ehviewer.util.getParcelableExtraCompat
@@ -105,7 +104,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
}
ACTION_START_RANGE -> {
val gidList = intent.getParcelableExtraCompat<LongList>(KEY_GID_LIST)
val gidList = intent.getLongArrayExtra(KEY_GID_LIST)
if (gidList != null) {
deferredMgr.await().startRangeDownload(gidList)
}
@@ -125,7 +124,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
ACTION_STOP_CURRENT -> deferredMgr.await().stopCurrentDownload()
ACTION_STOP_RANGE -> {
val gidList = intent.getParcelableExtraCompat<LongList>(KEY_GID_LIST)
val gidList = intent.getLongArrayExtra(KEY_GID_LIST)
if (gidList != null) {
deferredMgr.await().stopRangeDownload(gidList)
}
@@ -141,7 +140,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
}
ACTION_DELETE_RANGE -> {
val gidList = intent.getParcelableExtraCompat<LongList>(KEY_GID_LIST)
val gidList = intent.getLongArrayExtra(KEY_GID_LIST)
if (gidList != null) {
deferredMgr.await().deleteRangeDownload(gidList)
}
@@ -188,7 +187,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
clearIntent.action = ACTION_CLEAR
val piClear = PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_IMMUTABLE)
val bundle = Bundle()
bundle.putString(DownloadsScene.KEY_ACTION, DownloadsScene.ACTION_CLEAR_DOWNLOAD_SERVICE)
bundle.putString(DownloadsFragment.KEY_ACTION, DownloadsFragment.ACTION_CLEAR_DOWNLOAD_SERVICE)
val activityIntent = Intent(this, MainActivity::class.java)
activityIntent.action = ACTION_START_DOWNLOADSCENE
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
@@ -244,7 +243,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
}
ensureDownloadingBuilder()
val bundle = Bundle()
bundle.putLong(DownloadsScene.KEY_GID, info.gid)
bundle.putLong(DownloadsFragment.KEY_GID, info.gid)
val activityIntent = Intent(this, MainActivity::class.java)
activityIntent.action = ACTION_START_DOWNLOADSCENE
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
@@ -43,14 +43,16 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import moe.tarsin.coroutines.NamedMutex
import moe.tarsin.coroutines.withLock
class ArchivePageLoader(context: Context, private val uri: Uri, passwdFlow: Flow<String>) : PageLoader2(), CoroutineScope {
typealias PasswdInvalidator = (String) -> Boolean
typealias PasswdProvider = suspend (PasswdInvalidator) -> String
class ArchivePageLoader(context: Context, private val uri: Uri, passwdProvider: PasswdProvider) : PageLoader2(), CoroutineScope {
override val coroutineContext = Dispatchers.IO + Job()
private lateinit var pfd: ParcelFileDescriptor
private val hostJob = launch(start = CoroutineStart.LAZY) {
@@ -65,12 +67,8 @@ class ArchivePageLoader(context: Context, private val uri: Uri, passwdFlow: Flow
it ?: return@forEach
if (providePassword(it)) return@launch
}
passwdFlow.collect {
if (providePassword(it)) {
archivePasswds = archivePasswds?.toMutableSet()?.apply { add(it) } ?: setOf(it)
currentCoroutineContext().cancel()
}
}
val toAdd = passwdProvider { providePassword(it) }
archivePasswds = archivePasswds?.toMutableSet()?.apply { add(toAdd) } ?: setOf(toAdd)
}
}
@@ -0,0 +1,32 @@
@file:Suppress("ktlint:standard:property-naming")
package com.hippo.ehviewer.icons
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
object EhIcons {
object Filled
object Big {
object Filled
val Default = Filled
}
val Default = Filled
}
inline fun bigIcon(
name: String,
block: ImageVector.Builder.() -> ImageVector.Builder,
): ImageVector = ImageVector.Builder(
name = name,
defaultWidth = BigIconDimension.dp,
defaultHeight = BigIconDimension.dp,
viewportWidth = MaterialIconDimension,
viewportHeight = MaterialIconDimension,
).block().build()
const val BigIconDimension = 120f
const val MaterialIconDimension = 24f
@@ -0,0 +1,44 @@
package com.hippo.ehviewer.icons.big
import androidx.compose.material.icons.materialPath
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.bigIcon
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Big.Filled.Download by unsafeLazy {
bigIcon(name = "Download") {
materialPath {
moveTo(0.0F, 19.0F)
verticalLineTo(24.0F)
horizontalLineTo(24.0F)
verticalLineTo(19.0F)
horizontalLineTo(0.0F)
close()
moveTo(16.0F, 22.0F)
horizontalLineTo(15.0F)
verticalLineTo(21.0F)
horizontalLineTo(16.0F)
verticalLineTo(22.0F)
close()
moveTo(19.0F, 22.0F)
horizontalLineTo(18.0F)
verticalLineTo(21.0F)
horizontalLineTo(19.0F)
verticalLineTo(22.0F)
close()
moveTo(22.0F, 22.0F)
horizontalLineTo(21.0F)
verticalLineTo(21.0F)
horizontalLineTo(22.0F)
verticalLineTo(22.0F)
moveTo(15.0F, 11.0F)
lineTo(15.0F, 0.0F)
lineTo(9.0F, 0.0F)
lineTo(9.0F, 11.0F)
lineTo(5.0F, 11.0F)
lineTo(12.0F, 18.0F)
lineTo(19.0F, 11.0F)
close()
}
}
}
@@ -0,0 +1,51 @@
package com.hippo.ehviewer.icons.big
import androidx.compose.material.icons.materialPath
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.bigIcon
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Big.Filled.History by unsafeLazy {
bigIcon(name = "History") {
materialPath {
moveTo(5.5F, 2.0F)
horizontalLineTo(22.0F)
verticalLineTo(0.0F)
horizontalLineTo(5.0F)
curveTo(3.3F, 0.0F, 2.0F, 1.3F, 2.0F, 3.0F)
verticalLineTo(21.0F)
curveTo(2.0F, 22.7F, 3.3F, 24.0F, 5.0F, 24.0F)
horizontalLineTo(22.0F)
verticalLineTo(4.0F)
horizontalLineTo(5.5F)
curveTo(4.1F, 4.0F, 4.1F, 2.0F, 5.5F, 2.0F)
close()
moveTo(20.0F, 6.0F)
verticalLineTo(22.0F)
horizontalLineTo(6.0F)
verticalLineTo(6.0F)
horizontalLineTo(20.0F)
moveTo(12.5F, 11.8F)
verticalLineTo(14.4F)
lineTo(14.9F, 15.8F)
lineTo(15.3F, 15.2F)
lineTo(13.3F, 14.0F)
verticalLineTo(11.9F)
moveTo(13.3F, 9.3F)
curveTo(11.5F, 9.3F, 10.0F, 10.3F, 9.2F, 11.7F)
lineTo(8.0F, 10.5F)
verticalLineTo(13.8F)
horizontalLineTo(11.3F)
lineTo(9.9F, 12.4F)
curveTo(10.5F, 11.1F, 11.8F, 10.3F, 13.3F, 10.3F)
curveTo(15.4F, 10.3F, 17.1F, 12.0F, 17.1F, 14.1F)
reflectiveCurveTo(15.4F, 17.9F, 13.3F, 17.9F)
curveTo(11.7F, 17.9F, 10.3F, 16.9F, 9.8F, 15.4F)
horizontalLineTo(8.7F)
curveTo(9.3F, 17.4F, 11.1F, 18.9F, 13.3F, 18.9F)
curveTo(15.9F, 18.9F, 18.1F, 16.8F, 18.1F, 14.1F)
curveTo(18.0F, 11.4F, 15.9F, 9.3F, 13.3F, 9.3F)
close()
}
}
}
@@ -0,0 +1,53 @@
package com.hippo.ehviewer.icons.big
import androidx.compose.material.icons.materialPath
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.bigIcon
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Big.Filled.SadAndroid by unsafeLazy {
bigIcon(name = "SadAndroid") {
materialPath {
moveTo(16.5F, 10.8F)
curveToRelative(-0.61F, 0.0F, -1.1F, 0.49F, -1.1F, 1.1F)
curveToRelative(0.0F, 0.61F, 0.49F, 1.1F, 1.1F, 1.1F)
reflectiveCurveToRelative(1.1F, -0.49F, 1.1F, -1.1F)
curveTo(17.6F, 11.29F, 17.11F, 10.8F, 16.5F, 10.8F)
close()
moveTo(8.2F, 10.1F)
curveToRelative(-0.61F, 0.0F, -1.1F, 0.49F, -1.1F, 1.1F)
reflectiveCurveToRelative(0.49F, 1.1F, 1.1F, 1.1F)
reflectiveCurveToRelative(1.1F, -0.49F, 1.1F, -1.1F)
reflectiveCurveTo(8.81F, 10.1F, 8.2F, 10.1F)
close()
moveTo(22.1F, 16.4F)
curveToRelative(-0.46F, -3.33F, -2.24F, -5.93F, -4.81F, -7.45F)
lineTo(19.5F, 6.3F)
curveToRelative(0.1F, -0.1F, 0.1F, -0.3F, 0.0F, -0.4F)
curveToRelative(-0.1F, -0.1F, -0.3F, -0.1F, -0.4F, 0.0F)
lineToRelative(-2.27F, 2.79F)
curveToRelative(-1.26F, -0.66F, -2.69F, -1.07F, -4.23F, -1.19F)
curveToRelative(-1.4F, -0.15F, -2.77F, -0.01F, -4.05F, 0.37F)
lineTo(6.7F, 4.7F)
curveTo(6.6F, 4.6F, 6.4F, 4.5F, 6.3F, 4.6F)
curveTo(6.2F, 4.7F, 6.1F, 4.9F, 6.2F, 5.0F)
lineToRelative(1.79F, 3.05F)
curveToRelative(-2.83F, 1.02F, -5.12F, 3.3F, -6.19F, 6.55F)
lineToRelative(15.44F, 1.37F)
curveToRelative(0.0F, 0.02F, -0.01F, 0.04F, -0.01F, 0.05F)
curveToRelative(0.0F, 0.59F, 0.48F, 1.07F, 1.07F, 1.07F)
curveToRelative(0.54F, 0.0F, 0.97F, -0.41F, 1.04F, -0.93F)
lineTo(22.1F, 16.4F)
close()
moveTo(2.63F, 14.07F)
curveToRelative(1.51F, -3.74F, 4.83F, -6.03F, 8.84F, -6.03F)
curveToRelative(0.35F, 0.0F, 0.71F, 0.02F, 1.07F, 0.06F)
curveToRelative(4.55F, 0.36F, 7.94F, 3.33F, 8.84F, 7.64F)
lineToRelative(-2.12F, -0.19F)
curveToRelative(-0.27F, -0.68F, -0.95F, -1.46F, -0.95F, -1.46F)
reflectiveCurveToRelative(-0.58F, 0.66F, -0.89F, 1.29F)
lineTo(2.63F, 14.07F)
close()
}
}
}
@@ -0,0 +1,33 @@
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.GoTo by unsafeLazy {
materialIcon(name = "GoTo") {
materialPath {
moveTo(13.0F, 13.0F)
lineTo(17.0F, 17.0F)
lineTo(21.0F, 13.0F)
horizontalLineTo(18.0F)
verticalLineTo(10.0F)
curveTo(18.0F, 6.1F, 14.9F, 3.0F, 11.0F, 3.0F)
reflectiveCurveTo(4.0F, 6.1F, 4.0F, 10.0F)
verticalLineTo(17.0F)
horizontalLineTo(6.0F)
verticalLineTo(10.0F)
curveTo(6.0F, 7.2F, 8.2F, 5.0F, 11.0F, 5.0F)
reflectiveCurveTo(16.0F, 7.2F, 16.0F, 10.0F)
verticalLineTo(13.0F)
horizontalLineTo(13.0F)
moveTo(16.0F, 19.0F)
horizontalLineTo(18.0F)
verticalLineTo(21.0F)
horizontalLineTo(16.0F)
verticalLineTo(19.0F)
close()
}
}
}
@@ -0,0 +1,47 @@
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.SadPanda by unsafeLazy {
materialIcon(name = "SadPanda") {
materialPath {
moveTo(21.1F, 7.8F)
curveTo(22.2F, 7.4F, 23.0F, 6.3F, 23.0F, 5.0F)
curveTo(23.0F, 3.3F, 21.7F, 2.0F, 20.0F, 2.0F)
curveTo(18.9F, 2.0F, 17.9F, 2.6F, 17.4F, 3.6F)
lineTo(21.1F, 7.8F)
moveTo(6.6F, 3.6F)
curveTo(6.1F, 2.6F, 5.1F, 2.0F, 4.0F, 2.0F)
curveTo(2.3F, 2.0F, 1.0F, 3.3F, 1.0F, 5.0F)
curveTo(1.0F, 6.3F, 1.8F, 7.4F, 2.9F, 7.8F)
lineTo(6.6F, 3.6F)
moveTo(10.1F, 13.0F)
lineTo(8.0F, 15.1F)
curveTo(7.4F, 15.7F, 6.5F, 15.7F, 5.9F, 15.1F)
lineTo(5.9F, 15.1F)
curveTo(5.3F, 14.5F, 5.3F, 13.6F, 5.9F, 13.0F)
lineTo(8.0F, 10.9F)
curveTo(8.6F, 10.3F, 9.5F, 10.3F, 10.1F, 10.9F)
lineTo(10.1F, 10.9F)
curveTo(10.7F, 11.5F, 10.7F, 12.4F, 10.1F, 13.0F)
moveTo(16.0F, 15.1F)
lineTo(13.9F, 13.0F)
curveTo(13.3F, 12.4F, 13.3F, 11.5F, 13.9F, 10.9F)
lineTo(13.9F, 10.9F)
curveTo(14.5F, 10.3F, 15.4F, 10.3F, 16.0F, 10.9F)
lineTo(18.1F, 13.0F)
curveTo(18.7F, 13.6F, 18.7F, 14.5F, 18.1F, 15.1F)
lineTo(18.1F, 15.1F)
curveTo(17.5F, 15.7F, 16.6F, 15.7F, 16.0F, 15.1F)
moveTo(12.0F, 17.0F)
curveTo(12.8F, 17.0F, 13.5F, 17.4F, 13.5F, 18.0F)
reflectiveCurveTo(12.8F, 19.0F, 12.0F, 19.0F)
reflectiveCurveTo(10.5F, 18.6F, 10.5F, 18.0F)
reflectiveCurveTo(11.2F, 17.0F, 12.0F, 17.0F)
close()
}
}
}
@@ -0,0 +1,61 @@
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.Subscriptions by unsafeLazy {
materialIcon(name = "Subscriptions") {
materialPath {
moveTo(20.0F, 8.0F)
horizontalLineTo(4.0F)
verticalLineTo(6.0F)
horizontalLineToRelative(16.0F)
verticalLineTo(8.0F)
moveTo(18.0F, 2.0F)
horizontalLineTo(6.0F)
verticalLineToRelative(2.0F)
horizontalLineToRelative(12.0F)
verticalLineTo(2.0F)
moveTo(20.0F, 10.0F)
horizontalLineTo(4.0F)
curveToRelative(-1.1F, 0.0F, -2.0F, 0.9F, -2.0F, 2.0F)
verticalLineToRelative(8.0F)
curveToRelative(0.0F, 1.1F, 0.9F, 2.0F, 2.0F, 2.0F)
horizontalLineToRelative(16.0F)
curveToRelative(1.1F, 0.0F, 2.0F, -0.9F, 2.0F, -2.0F)
verticalLineToRelative(-8.0F)
curveTo(22.0F, 10.9F, 21.1F, 10.0F, 20.0F, 10.0F)
close()
moveTo(11.0F, 14.0F)
horizontalLineTo(7.0F)
verticalLineToRelative(1.0F)
horizontalLineToRelative(4.0F)
verticalLineToRelative(2.0F)
horizontalLineTo(7.0F)
verticalLineToRelative(1.0F)
horizontalLineToRelative(4.0F)
verticalLineToRelative(2.0F)
horizontalLineTo(5.0F)
verticalLineToRelative(-8.0F)
horizontalLineToRelative(6.0F)
verticalLineTo(14.0F)
close()
moveTo(19.0F, 20.0F)
horizontalLineToRelative(-2.0F)
verticalLineToRelative(-3.0F)
horizontalLineToRelative(-2.0F)
verticalLineToRelative(3.0F)
horizontalLineToRelative(-2.0F)
verticalLineToRelative(-8.0F)
horizontalLineToRelative(2.0F)
verticalLineToRelative(3.0F)
horizontalLineToRelative(2.0F)
verticalLineToRelative(-3.0F)
horizontalLineToRelative(2.0F)
verticalLineTo(20.0F)
close()
}
}
}
@@ -30,24 +30,24 @@ 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 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.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.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
@@ -56,8 +56,7 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
var mObtainedDrawable: Drawable? = drawable
private set
val size: Int
get() = mObtainedDrawable!!.run { intrinsicHeight * intrinsicWidth * 4 * if (this is Animatable) 4 else 1 }
val size = drawable.run { intrinsicHeight * intrinsicWidth * 4 * if (this is Animatable) 4 else 1 }
@Synchronized
fun recycle() {
@@ -87,8 +86,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 }
@@ -123,12 +122,15 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
} 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)
@@ -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()
}
}
@@ -22,30 +22,24 @@ 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.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
@@ -59,7 +53,7 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
suspend fun setMode(value: Int) {
mode = value
if (mode == SpiderQueen.MODE_DOWNLOAD && downloadDir == null) {
downloadDir = getGalleryDownloadDir(mGid)!!.apply { check(ensureDir()) { "Download directory $uri is not valid directory!" } }
downloadDir = getGalleryDownloadDir(mGid)?.takeIf { it.ensureDir() }
}
}
@@ -132,36 +126,25 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
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
@@ -178,19 +161,14 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
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)
}
}
}
@@ -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
@@ -366,12 +367,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 +384,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 +416,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 +519,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 +556,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,31 +605,32 @@ 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
var originImageUrl: String? = null
var error: String? = null
var forceHtml = false
runCatching {
repeat(2) {
val original = Settings.downloadOriginImage || orgImg
runSuspendCatching {
repeat(2) { retries ->
var imageUrl: String? = null
var localShowKey: String?
@@ -630,11 +640,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
var pageUrl = EhUrl.getPageUrl(mSpiderInfo.gid, index, pToken)
// Skipping H@H costs 50 points, only use it as last resort
if (skipHathKey != null) {
pageUrl += if ("?" in pageUrl) {
"&nl=$skipHathKey"
} else {
"?nl=$skipHathKey"
}
pageUrl += "?nl=$skipHathKey"
}
EhEngine.getGalleryPage(pageUrl, mSpiderInfo.gid, mSpiderInfo.token)
.let { result ->
@@ -671,7 +677,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
val targetImageUrl: String?
val referer: String?
if ((Settings.downloadOriginImage || orgImg) && !originImageUrl.isNullOrBlank()) {
if (original && originImageUrl != null) {
if (retries == 1 && skipHathKey != null) {
originImageUrl += "?nl=$skipHathKey"
}
val pageUrl = EhUrl.getPageUrl(mSpiderInfo.gid, index, pToken)
targetImageUrl = EhEngine.getOriginalImageUrl(originImageUrl!!, pageUrl)
referer = EhUrl.referer
@@ -682,7 +691,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 +721,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 +752,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
}
}
}
}
@@ -771,14 +777,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private val PTOKEN_FAILED_MESSAGE = appCtx.getString(R.string.error_get_ptoken_error)
private val ERROR_TIMEOUT = appCtx.getString(R.string.error_timeout)
private val DECODE_ERROR = appCtx.getString(R.string.error_decoding_failed)
private val URL_509_ARRAY = arrayOf(
"https://ehgt.org/g/509.gif",
"https://ehgt.org/g/509s.gif",
"https://exhentai.org/img/509.gif",
"https://exhentai.org/img/509s.gif",
)
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) {
if (url in URL_509_ARRAY) throw QuotaExceededException()
if (URL_509_PATTERN in url) throw QuotaExceededException()
}
@@ -18,10 +18,10 @@ package com.hippo.ehviewer.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.DriveFileMove
import androidx.compose.material.icons.automirrored.filled.MenuBook
@@ -30,13 +30,15 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.HeartBroken
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
@@ -52,137 +54,32 @@ import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.DownloadService
import com.hippo.ehviewer.download.downloadDir
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.ui.legacy.ListCheckBoxDialogBuilder
import com.hippo.ehviewer.ui.scene.BaseScene
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.LongList
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.isAtLeastT
import com.hippo.ehviewer.util.mapToLongArray
import com.hippo.ehviewer.util.requestPermission
import com.hippo.ehviewer.util.toEpochMillis
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.lang.launchNow
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Clock
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.todayIn
import moe.tarsin.coroutines.runSuspendCatching
import rikka.core.util.ContextUtils.requireActivity
import splitties.init.appCtx
object CommonOperations {
fun startDownload(activity: MainActivity?, galleryInfo: BaseGalleryInfo, forceDefault: Boolean) {
startDownload(activity!!, listOf(galleryInfo), forceDefault)
}
@OptIn(DelicateCoroutinesApi::class)
fun startDownload(
activity: MainActivity,
galleryInfos: List<BaseGalleryInfo>,
forceDefault: Boolean,
) {
launchNow {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.requestPermission(Manifest.permission.POST_NOTIFICATIONS)
}
doStartDownload(activity, galleryInfos, forceDefault)
}
}
private fun doStartDownload(
activity: MainActivity,
galleryInfos: List<BaseGalleryInfo>,
forceDefault: Boolean,
) {
val toStart = LongList()
val toAdd: MutableList<BaseGalleryInfo> = ArrayList()
for (gi in galleryInfos) {
if (DownloadManager.containDownloadInfo(gi.gid)) {
toStart.add(gi.gid)
} else {
toAdd.add(gi)
}
}
if (!toStart.isEmpty()) {
val intent = Intent(activity, DownloadService::class.java)
intent.action = DownloadService.ACTION_START_RANGE
intent.putExtra(DownloadService.KEY_GID_LIST, toStart)
ContextCompat.startForegroundService(activity, intent)
}
if (toAdd.isEmpty()) {
activity.showTip(R.string.added_to_download_list, BaseScene.LENGTH_SHORT)
return
}
var justStart = forceDefault
var label: String? = null
// Get default download label
if (!justStart && Settings.hasDefaultDownloadLabel) {
label = Settings.defaultDownloadLabel
justStart = label == null || DownloadManager.containLabel(label)
}
// If there is no other label, just use null label
if (!justStart && DownloadManager.labelList.isEmpty()) {
justStart = true
label = null
}
if (justStart) {
// Got default label
for (gi in toAdd) {
val intent = Intent(activity, DownloadService::class.java)
intent.action = DownloadService.ACTION_START
intent.putExtra(DownloadService.KEY_LABEL, label)
intent.putExtra(DownloadService.KEY_GALLERY_INFO, gi)
ContextCompat.startForegroundService(activity, intent)
}
// Notify
activity.showTip(R.string.added_to_download_list, BaseScene.LENGTH_SHORT)
} else {
// Let use chose label
val list = DownloadManager.labelList
val items = mutableListOf<String>()
items.add(activity.getString(R.string.default_download_label_name))
items.addAll(list.map { it.label })
ListCheckBoxDialogBuilder(
activity,
items,
{ builder: ListCheckBoxDialogBuilder?, _: AlertDialog?, position: Int ->
var label1: String?
if (position == 0) {
label1 = null
} else {
label1 = items[position]
if (!DownloadManager.containLabel(label1)) {
label1 = null
}
}
// Start download
for (gi in toAdd) {
val intent = Intent(activity, DownloadService::class.java)
intent.action = DownloadService.ACTION_START
intent.putExtra(DownloadService.KEY_LABEL, label1)
intent.putExtra(DownloadService.KEY_GALLERY_INFO, gi)
ContextCompat.startForegroundService(activity, intent)
}
// Save settings
if (builder?.isChecked == true) {
Settings.hasDefaultDownloadLabel = true
Settings.defaultDownloadLabel = label1
} else {
Settings.hasDefaultDownloadLabel = false
}
// Notify
activity.showTip(R.string.added_to_download_list, BaseScene.LENGTH_SHORT)
},
activity.getString(R.string.remember_download_label),
false,
)
.setTitle(R.string.download)
.show()
}
}
}
private fun removeNoMediaFile(downloadDir: UniFile) {
val noMedia = downloadDir.subFile(".nomedia") ?: return
noMedia.delete()
@@ -207,6 +104,86 @@ suspend fun keepNoMediaFileStatus() {
fun getFavoriteIcon(favorited: Boolean) =
if (favorited) Icons.Default.Favorite else Icons.Default.FavoriteBorder
suspend fun DialogState.startDownload(
context: Context,
forceDefault: Boolean,
vararg galleryInfos: BaseGalleryInfo,
) = with(context) {
if (isAtLeastT) {
requestPermission(Manifest.permission.POST_NOTIFICATIONS)
}
val (toStart, toAdd) = galleryInfos.partition { DownloadManager.containDownloadInfo(it.gid) }
if (toStart.isNotEmpty()) {
val intent = Intent(context, DownloadService::class.java)
intent.action = DownloadService.ACTION_START_RANGE
val list = toStart.mapToLongArray(GalleryInfo::gid)
intent.putExtra(DownloadService.KEY_GID_LIST, list)
ContextCompat.startForegroundService(context, intent)
}
if (toAdd.isEmpty()) {
return with(findActivity<MainActivity>()) {
showTip(R.string.added_to_download_list)
}
}
var justStart = forceDefault
var label: String? = null
// Get default download label
if (!justStart && Settings.hasDefaultDownloadLabel) {
label = Settings.defaultDownloadLabel
justStart = label == null || DownloadManager.containLabel(label)
}
// If there is no other label, just use null label
if (!justStart && DownloadManager.labelList.isEmpty()) {
justStart = true
label = null
}
if (justStart) {
// Got default label
for (gi in toAdd) {
val intent = Intent(context, DownloadService::class.java)
intent.action = DownloadService.ACTION_START
intent.putExtra(DownloadService.KEY_LABEL, label)
intent.putExtra(DownloadService.KEY_GALLERY_INFO, gi)
ContextCompat.startForegroundService(context, intent)
}
// Notify
with(findActivity<MainActivity>()) {
showTip(R.string.added_to_download_list)
}
} else {
// Let use chose label
val list = DownloadManager.labelList
val items = arrayOf(
getString(R.string.default_download_label_name),
*list.map { it.label }.toTypedArray(),
)
val (selected, checked) = showSelectItemWithCheckBox(
*items,
title = R.string.download,
checkBoxText = R.string.remember_download_label,
)
val label1 = if (selected == 0) null else items[selected].takeIf { DownloadManager.containLabel(it) }
// Start download
for (gi in toAdd) {
val intent = Intent(context, DownloadService::class.java)
intent.action = DownloadService.ACTION_START
intent.putExtra(DownloadService.KEY_LABEL, label1)
intent.putExtra(DownloadService.KEY_GALLERY_INFO, gi)
ContextCompat.startForegroundService(context, intent)
}
// Save settings
if (checked) {
Settings.hasDefaultDownloadLabel = true
Settings.defaultDownloadLabel = label1
} else {
Settings.hasDefaultDownloadLabel = false
}
with(context.findActivity<MainActivity>()) {
showTip(R.string.added_to_download_list)
}
}
}
suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
val localFavorited = EhDB.containLocalFavorites(galleryInfo.gid)
if (EhCookieStore.hasSignedIn()) {
@@ -285,7 +262,10 @@ private suspend fun doModifyFavorites(
return add
}
suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorites(galleryInfo)
suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorites(
galleryInfo = galleryInfo,
localFavorited = EhDB.containLocalFavorites(galleryInfo.gid),
)
fun Context.navToReader(info: BaseGalleryInfo, page: Int = -1) {
val intent = Intent(this, ReaderActivity::class.java)
@@ -314,30 +294,30 @@ suspend fun DialogState.doGalleryInfoAction(info: BaseGalleryInfo, context: Cont
title = EhUtils.getSuitableTitle(info),
)
}
with(requireActivity<MainActivity>(context)) {
with(context.findActivity<MainActivity>()) {
when (selected) {
0 -> navToReader(info)
1 -> withUIContext {
if (downloaded) {
confirmRemoveDownload(info)
} else {
CommonOperations.startDownload(this@with, info, false)
startDownload(context, false, info)
}
}
2 -> if (favourite) {
runSuspendCatching {
removeFromFavorites(info)
showTip(R.string.remove_from_favorite_success, BaseScene.LENGTH_SHORT)
showTip(R.string.remove_from_favorite_success)
}.onFailure {
showTip(R.string.remove_from_favorite_failure, BaseScene.LENGTH_LONG)
showTip(R.string.remove_from_favorite_failure)
}
} else {
runSuspendCatching {
modifyFavorites(info)
showTip(R.string.add_to_favorite_success, BaseScene.LENGTH_SHORT)
showTip(R.string.add_to_favorite_success)
}.onFailure {
showTip(R.string.add_to_favorite_failure, BaseScene.LENGTH_LONG)
showTip(R.string.add_to_favorite_failure)
}
}
@@ -352,17 +332,22 @@ private const val MAX_FAVNOTE_CHAR = 200
suspend fun DialogState.confirmRemoveDownload(info: GalleryInfo, onDismiss: () -> Unit = {}) {
var checked by mutableStateOf(Settings.removeImageFiles)
awaitPermissionOrCancel(
confirmText = android.R.string.ok,
dismissText = android.R.string.cancel,
title = R.string.download_remove_dialog_title,
onDismiss = onDismiss,
text = {
Column {
Text(text = stringResource(id = R.string.download_remove_dialog_message, info.title.orEmpty()))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = checked, onCheckedChange = { checked = it })
Text(text = stringResource(id = R.string.download_remove_dialog_check_text))
}
Text(
text = stringResource(id = R.string.download_remove_dialog_message, info.title.orEmpty()),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(16.dp))
LabeledCheckbox(
modifier = Modifier.fillMaxWidth(),
checked = checked,
onCheckedChange = { checked = it },
label = stringResource(id = R.string.download_remove_dialog_check_text),
indication = null,
)
}
},
)
@@ -372,6 +357,43 @@ suspend fun DialogState.confirmRemoveDownload(info: GalleryInfo, onDismiss: () -
}
}
suspend fun DialogState.confirmRemoveDownloadRange(list: Collection<DownloadInfo>) {
var checked by mutableStateOf(Settings.removeImageFiles)
awaitPermissionOrCancel(
title = R.string.download_remove_dialog_title,
text = {
Column {
Text(
text = stringResource(id = R.string.download_remove_dialog_message_2, list.size),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(16.dp))
LabeledCheckbox(
modifier = Modifier.fillMaxWidth(),
checked = checked,
onCheckedChange = { checked = it },
label = stringResource(id = R.string.download_remove_dialog_check_text),
indication = null,
)
}
},
)
Settings.removeImageFiles = checked
withIOContext {
// Delete
DownloadManager.deleteRangeDownload(list.mapToLongArray(DownloadInfo::gid))
// Delete image files
if (checked) {
list.forEach { info ->
// Delete file
info.downloadDir?.delete()
// Remove download path
EhDB.removeDownloadDirname(info.gid)
}
}
}
}
suspend fun DialogState.showMoveDownloadLabel(info: GalleryInfo) {
val defaultLabel = appCtx.getString(R.string.default_download_label_name)
val labels = DownloadManager.labelList.map { it.label }.toTypedArray()
@@ -380,3 +402,33 @@ suspend fun DialogState.showMoveDownloadLabel(info: GalleryInfo) {
val label = if (selected == 0) null else labels[selected - 1]
withUIContext { DownloadManager.changeLabel(listOf(downloadInfo), label) }
}
suspend fun DialogState.showMoveDownloadLabelList(list: Collection<DownloadInfo>): String? {
val defaultLabel = appCtx.getString(R.string.default_download_label_name)
val labels = DownloadManager.labelList.map { it.label }.toTypedArray()
val selected = showSelectItem(defaultLabel, *labels, title = R.string.download_move_dialog_title)
val label = if (selected == 0) null else labels[selected - 1]
withUIContext { DownloadManager.changeLabel(list, label) }
return label
}
suspend fun DialogState.showDatePicker(): String? {
val initial = LocalDate(2007, 3, 21)
val yesterday = Clock.System.todayIn(TimeZone.UTC).minus(1, DateTimeUnit.DAY)
val initialMillis = initial.toEpochMillis()
val yesterdayMillis = yesterday.toEpochMillis()
val dateRange = initialMillis..yesterdayMillis
val dateMillis = showDatePicker(
title = R.string.go_to,
yearRange = initial.year..yesterday.year,
selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
return utcTimeMillis in dateRange
}
},
)
val date = dateMillis?.let {
kotlinx.datetime.Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date.toString()
}
return date
}
@@ -1,126 +1,51 @@
package com.hippo.ehviewer.ui
import android.app.Activity
import android.os.Bundle
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.compose.ui.platform.LocalContext
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.ui.login.CookieSignInScene
import com.hippo.ehviewer.ui.login.SelectSiteScreen
import com.hippo.ehviewer.ui.login.SignInScreen
import com.hippo.ehviewer.ui.login.WebViewSignInScreen
import com.hippo.ehviewer.ui.settings.AboutScreen
import com.hippo.ehviewer.ui.settings.AdvancedScreen
import com.hippo.ehviewer.ui.settings.BaseScreen
import com.hippo.ehviewer.ui.settings.DownloadScreen
import com.hippo.ehviewer.ui.settings.EhScreen
import com.hippo.ehviewer.ui.settings.FilterScreen
import com.hippo.ehviewer.ui.settings.LicenseScreen
import com.hippo.ehviewer.ui.settings.MyTagsScreen
import com.hippo.ehviewer.ui.settings.PrivacyScreen
import com.hippo.ehviewer.ui.settings.UConfigScreen
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)
WindowCompat.setDecorFitsSystemWindows(window, false)
setMD3Content {
val navController = rememberNavController()
val windowSizeClass = calculateWindowSizeClass(this)
CompositionLocalProvider(LocalNavController provides navController) {
NavHost(
navController = navController,
startDestination = if (Settings.needSignIn) {
if (EhCookieStore.hasSignedIn()) SELECT_SITE_ROUTE_NAME else SIGN_IN_ROUTE_NAME
} else {
BASE_SETTINGS_SCREEN
},
CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) {
DestinationsNavHost(
navGraph = NavGraphs.root,
modifier = Modifier.imePadding(),
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Up, tween(200)) },
exitTransition = { fadeOut(tween(200)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Down, tween(200)) },
) {
composable(SIGN_IN_ROUTE_NAME) {
SignInScreen(windowSizeClass)
}
composable(WEBVIEW_SIGN_IN_ROUTE_NAME) {
WebViewSignInScreen()
}
composable(COOKIE_SIGN_IN_ROUTE_NAME) {
CookieSignInScene(windowSizeClass)
}
composable(SELECT_SITE_ROUTE_NAME) {
SelectSiteScreen()
}
composable(BASE_SETTINGS_SCREEN) {
BaseScreen()
}
composable(ABOUT_SETTINGS_SCREEN) {
AboutScreen()
}
composable(LICENSE_SCREEN) {
LicenseScreen()
}
composable(PRIVACY_SETTINGS_SCREEN) {
PrivacyScreen()
}
composable(ADVANCED_SETTINGS_SCREEN) {
AdvancedScreen()
}
composable(DOWNLOAD_SETTINGS_SCREEN) {
DownloadScreen()
}
composable(EH_SETTINGS_SCREEN) {
EhScreen()
}
composable(UCONFIG_SCREEN) {
UConfigScreen()
}
composable(MYTAGS_SCREEN) {
MyTagsScreen()
}
composable(FILTER_SCREEN) {
FilterScreen()
}
composable(FINISH_ROUTE_NAME) {
SideEffect {
finish()
}
}
}
startRoute = if (Settings.needSignIn) {
if (EhCookieStore.hasSignedIn()) SelectSiteScreenDestination else SignInScreenDestination
} else {
BaseScreenDestination
},
engine = rememberNavHostEngine(rootDefaultAnimations = ehNavAnim),
)
}
}
}
}
val LocalNavController = compositionLocalOf<NavController> { error("CompositionLocal LocalNavController not present!") }
const val BASE_SETTINGS_SCREEN = "Base"
const val EH_SETTINGS_SCREEN = "Eh"
const val DOWNLOAD_SETTINGS_SCREEN = "Download"
const val PRIVACY_SETTINGS_SCREEN = "Privacy"
const val ADVANCED_SETTINGS_SCREEN = "Advanced"
const val ABOUT_SETTINGS_SCREEN = "About"
const val LICENSE_SCREEN = "License"
const val UCONFIG_SCREEN = "UConfig"
const val MYTAGS_SCREEN = "Mytags"
const val FILTER_SCREEN = "Filter"
const val SIGN_IN_ROUTE_NAME = "SignIn"
const val WEBVIEW_SIGN_IN_ROUTE_NAME = "WebViewSignIn"
const val COOKIE_SIGN_IN_ROUTE_NAME = "CookieSignIn"
const val SELECT_SITE_ROUTE_NAME = "SelectSite"
const val FINISH_ROUTE_NAME = "Finish"
@Destination
@Composable
fun Finish() {
val context = LocalContext.current
SideEffect {
context.findActivity<Activity>().finish()
}
}
@@ -15,36 +15,17 @@
*/
package com.hippo.ehviewer.ui
import android.content.res.Resources.Theme
import android.os.Bundle
import androidx.annotation.StyleRes
import androidx.activity.enableEdgeToEdge
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
import rikka.insets.WindowInsetsHelper
import rikka.layoutinflater.view.LayoutInflaterFactory
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) {
theme.applyStyle(resid, true)
theme.applyStyle(getThemeStyleRes(), true)
}
override fun onNightModeChanged(mode: Int) {
theme.applyStyle(getThemeStyleRes(), true)
}
override fun onCreate(savedInstanceState: Bundle?) {
layoutInflater.factory2 = LayoutInflaterFactory(delegate).addOnViewCreatedListener(WindowInsetsHelper.LISTENER)
super.onCreate(savedInstanceState)
enableEdgeToEdge()
}
override fun onResume() {
@@ -1,19 +1,22 @@
package com.hippo.ehviewer.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -21,8 +24,6 @@ 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 androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUrl
@@ -30,9 +31,9 @@ import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
import com.hippo.ehviewer.client.thumbUrl
import com.hippo.ehviewer.databinding.InfoSheetBinding
import com.hippo.ehviewer.ui.scene.navWithUrl
import com.hippo.ehviewer.util.tellClipboardWithToast
import com.hippo.ehviewer.ui.screen.navWithUrl
import com.hippo.ehviewer.util.addTextToClipboard
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
private const val INDEX_URL = 2
private const val INDEX_PARENT = 9
@@ -61,43 +62,39 @@ private fun GalleryDetail.generateContent() = arrayOf(
R.string.favorite_name to favoriteName,
)
class GalleryInfoBottomSheet(private val detail: GalleryDetail) : BottomSheetDialogFragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = InfoSheetBinding.inflate(layoutInflater, container, false).apply {
content.setMD3Content {
Column(modifier = Modifier.fillMaxWidth()) {
val context = LocalContext.current
val navController = remember { findNavController() }
Text(
text = stringResource(id = R.string.gallery_info),
modifier = Modifier.align(Alignment.CenterHorizontally),
style = MaterialTheme.typography.titleLarge,
)
val data = remember(detail) { detail.generateContent() }
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
LazyColumn {
itemsIndexed(data) { index, (key, content) ->
Row(
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)).clickable {
if (index == INDEX_PARENT) {
if (content != null) {
navController.navWithUrl(content)
}
} else {
context tellClipboardWithToast content
if (index == INDEX_URL) {
// Save it to avoid detect the gallery
Settings.clipboardTextHashCode = data[index].hashCode()
}
}
}.fillMaxWidth(),
) {
Text(stringResource(id = key), modifier = Modifier.width(90.dp).padding(8.dp))
Text(content.orEmpty(), modifier = Modifier.padding(8.dp))
@Composable
fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigator) {
Column(modifier = Modifier.fillMaxWidth()) {
val context = LocalContext.current
Text(
text = stringResource(id = R.string.gallery_info),
modifier = Modifier.align(Alignment.CenterHorizontally),
style = MaterialTheme.typography.titleLarge,
)
val data = remember(detail) { detail.generateContent() }
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
LazyColumn(contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues()) {
itemsIndexed(data) { index, (key, content) ->
Row(
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)).clickable {
if (index == INDEX_PARENT) {
if (content != null) {
navigator.navWithUrl(content)
}
} else {
context.addTextToClipboard(content, true)
if (index == INDEX_URL) {
// Save it to avoid detect the gallery
Settings.clipboardTextHashCode = data[index].hashCode()
}
}
}
}.fillMaxWidth(),
) {
Text(stringResource(id = key), modifier = Modifier.width(90.dp).padding(8.dp))
Text(content.orEmpty(), modifier = Modifier.padding(8.dp))
}
}
}
}
}.root
}
}
@@ -26,59 +26,152 @@ import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
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.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
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.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.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.currentCompositeKeyHash
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.customview.widget.Openable
import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.NavigationUI
import androidx.navigation.ui.onNavDestinationSelected2
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.navigation.compose.rememberNavController
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.Settings.launchPage
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
import com.hippo.ehviewer.databinding.ActivityMainBinding
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.download.DownloadService
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.filled.Subscriptions
import com.hippo.ehviewer.image.Image.Companion.decodeBitmap
import com.hippo.ehviewer.ui.destinations.DownloadsScreenDestination
import com.hippo.ehviewer.ui.destinations.FavouritesScreenDestination
import com.hippo.ehviewer.ui.destinations.GalleryDetailScreenDestination
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.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.legacy.EditTextDialogBuilder
import com.hippo.ehviewer.ui.scene.BaseScene
import com.hippo.ehviewer.ui.scene.GalleryDetailScene
import com.hippo.ehviewer.ui.scene.GalleryListScene
import com.hippo.ehviewer.ui.scene.GalleryListScene.Companion.toStartArgs
import com.hippo.ehviewer.ui.scene.ProgressScene
import com.hippo.ehviewer.ui.scene.navAnimated
import com.hippo.ehviewer.ui.scene.navWithUrl
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.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalTouchSlopProvider
import com.hippo.ehviewer.updater.AppUpdater
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.getUrlFromClipboard
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withUIContext
import com.hippo.ehviewer.util.isAtLeastQ
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
import splitties.systemservices.connectivityManager
private val navItems = arrayOf(
Triple(HomePageScreenDestination, R.string.homepage, Icons.Default.Home),
Triple(SubscriptionScreenDestination, R.string.subscription, EhIcons.Default.Subscriptions),
Triple(WhatshotScreenDestination, R.string.whats_hot, Icons.Default.Whatshot),
Triple(ToplistScreenDestination, R.string.toplist, Icons.Default.FormatListNumbered),
Triple(FavouritesScreenDestination, R.string.favourite, Icons.Default.Favorite),
Triple(HistoryScreenDestination, R.string.history, Icons.Default.History),
Triple(DownloadsScreenDestination, R.string.downloads, Icons.Default.Download),
)
class MainActivity : EhActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var navController: NavController
private val sideSheet = mutableStateListOf<@Composable ColumnScope.(DrawerState2) -> Unit>()
@Composable
fun ProvideSideSheetContent(content: @Composable ColumnScope.(DrawerState2) -> Unit) {
DisposableEffect(content) {
sideSheet.add(0, content)
onDispose { sideSheet.remove(content) }
}
}
private var shareUrl: String? = null
@Composable
fun ProvideAssistContent(url: String) {
val urlState by rememberUpdatedState(url)
DisposableEffect(urlState) {
shareUrl = urlState
onDispose {
shareUrl = null
}
}
}
private fun saveImageToTempFile(uri: Uri): File? {
val bitmap = runCatching {
@@ -94,134 +187,233 @@ class MainActivity : EhActivity() {
}
}
override fun onNewIntent(intent: Intent?) {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
lifecycleScope.launchUI {
if (!handleIntent(intent)) {
if (intent != null && Intent.ACTION_VIEW == intent.action) {
if (intent.data != null) {
val url = intent.data.toString()
EditTextDialogBuilder(this@MainActivity, url, "")
.setTitle(R.string.error_cannot_parse_the_url)
.setPositiveButton(android.R.string.copy) { _: DialogInterface?, _: Int ->
this@MainActivity.addTextToClipboard(
url,
false,
)
}
.show()
}
}
}
}
intentChannel.trySend(intent)
}
private fun handleIntent(intent: Intent?): Boolean {
when (intent?.action) {
Intent.ACTION_VIEW -> {
val uri = intent.data ?: return false
return navController.navWithUrl(uri.toString())
private val tipFlow = MutableSharedFlow<String>(extraBufferCapacity = 1)
private val intentChannel = Channel<Intent>(capacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val intentFlow = intentChannel.receiveAsFlow()
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
setMD3Content {
val configuration = LocalConfiguration.current
val dialogState = LocalDialogState.current
val navDrawerState = rememberDrawerState(DrawerValue.Closed)
val sideSheetState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val navController = rememberNavController()
fun closeDrawer(callback: () -> Unit = {}) = scope.launch {
navDrawerState.close()
callback()
}
Intent.ACTION_SEND -> {
val type = intent.type
if ("text/plain" == type) {
val builder = ListUrlBuilder()
builder.keyword = intent.getStringExtra(Intent.EXTRA_TEXT)
navController.navAnimated(
R.id.galleryListScene,
builder.toStartArgs(),
)
return true
} else if (type != null && type.startsWith("image/")) {
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
if (null != uri) {
val temp = saveImageToTempFile(uri)
if (null != temp) {
val builder = ListUrlBuilder()
builder.mode = ListUrlBuilder.MODE_IMAGE_SEARCH
builder.imagePath = temp.path
builder.isUseSimilarityScan = true
navController.navAnimated(
R.id.galleryListScene,
builder.toStartArgs(),
)
return true
val cannotParse = stringResource(R.string.error_cannot_parse_the_url)
LaunchedEffect(Unit) {
intentFlow.collect { intent ->
when (intent.action) {
Intent.ACTION_VIEW -> {
val url = intent.data?.toString()
if (url != null && !navController.navWithUrl(url)) {
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
addTextToClipboard(new)
}
}
Intent.ACTION_SEND -> {
val type = intent.type
if ("text/plain" == type) {
val keyword = intent.getStringExtra(Intent.EXTRA_TEXT)
navController.navigate(GalleryListScreenDestination(ListUrlBuilder(mKeyword = keyword)))
} else if (type != null && type.startsWith("image/")) {
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
if (null != uri) {
val temp = saveImageToTempFile(uri)
if (null != temp) {
val builder = ListUrlBuilder(
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
imagePath = temp.path,
isUseSimilarityScan = true,
)
navController.navigate(GalleryListScreenDestination(builder))
}
}
}
}
DownloadService.ACTION_START_DOWNLOADSCENE -> {
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)
navController.navigate(DownloadsScreenDestination)
}
}
}
}
DownloadService.ACTION_START_DOWNLOADSCENE -> {
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)
navController.navAnimated(R.id.nav_downloads, args)
}
}
return false
}
private fun setNavGraph() {
navController.apply {
graph = navInflater.inflate(R.navigation.nav_graph).apply {
when (
when (val value = launchPage) {
0 -> GalleryListScene.ACTION_HOMEPAGE
1 -> GalleryListScene.ACTION_SUBSCRIPTION
2 -> GalleryListScene.ACTION_WHATS_HOT
3 -> GalleryListScene.ACTION_TOP_LIST
else -> throw IllegalStateException("Unexpected value: $value")
LaunchedEffect(Unit) {
runSuspendCatching {
withIOContext {
AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
}
}
) {
GalleryListScene.ACTION_HOMEPAGE -> setStartDestination(R.id.nav_homepage)
GalleryListScene.ACTION_SUBSCRIPTION -> setStartDestination(R.id.nav_subscription)
GalleryListScene.ACTION_WHATS_HOT -> setStartDestination(R.id.nav_whats_hot)
GalleryListScene.ACTION_TOP_LIST -> setStartDestination(R.id.nav_toplist)
}.onFailure {
showTip(getString(R.string.update_failed, ExceptionUtils.getReadableString(it)))
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
navController = navHostFragment.navController
if (!EhUtils.needSignedIn()) setNavGraph()
binding.drawView.addDrawerListener(mDrawerOnBackPressedCallback)
binding.navView.setupWithNavController(navController)
// Trick: Tweak NavigationUI to disable multiple backstack
binding.navView.setNavigationItemSelectedListener {
val navigationView = binding.navView
val handled = navigationView.checkedItem?.itemId == it.itemId || onNavDestinationSelected2(it, navController)
if (handled) {
val parent = navigationView.parent
if (parent is Openable) {
parent.close()
} else {
@SuppressLint("RestrictedApi")
val bottomSheetBehavior = NavigationUI.findBottomSheetBehavior(navigationView)
if (bottomSheetBehavior != null) {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
val snackbarState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
tipFlow.collectLatest {
snackbarState.showSnackbar(it)
}
}
val warning = stringResource(R.string.metered_network_warning)
val settings = stringResource(R.string.settings)
val checkMeteredNetwork by Settings.meteredNetworkWarning.collectAsState()
if (checkMeteredNetwork) {
LaunchedEffect(Unit) {
if (connectivityManager.isActiveNetworkMetered) {
if (isAtLeastQ) {
val ret = snackbarState.showSnackbar(warning, settings, true)
if (ret == SnackbarResult.ActionPerformed) {
val panelIntent = Intent(android.provider.Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
startActivity(panelIntent)
}
} else {
snackbarState.showSnackbar(warning)
}
}
}
}
val snackMessage = stringResource(R.string.clipboard_gallery_url_snack_message)
val snackAction = stringResource(R.string.clipboard_gallery_url_snack_action)
LifecycleResumeEffect {
scope.launch {
delay(300)
val text = clipboardManager.getUrlFromClipboard(applicationContext)
val hashCode = text?.hashCode() ?: 0
if (text != null && hashCode != 0 && Settings.clipboardTextHashCode != hashCode) {
val result1 = GalleryDetailUrlParser.parse(text, false)
var launch: (() -> Unit)? = null
if (result1 != null) {
launch = { navController.navigate(GalleryDetailScreenDestination(TokenArgs(result1.gid, result1.token))) }
}
val result2 = GalleryPageUrlParser.parse(text, false)
if (result2 != null) {
launch = { navController.navigate(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
}
launch?.let {
val ret = snackbarState.showSnackbar(snackMessage, snackAction, true)
if (ret == SnackbarResult.ActionPerformed) it()
}
}
Settings.clipboardTextHashCode = hashCode
}
onPauseOrDispose { }
}
val currentDestination by navController.currentDestinationAsState()
val lockDrawerHandle = remember { mutableStateListOf<Int>() }
val drawerLocked = lockDrawerHandle.isNotEmpty()
val viewConfiguration = LocalViewConfiguration.current
CompositionLocalProvider(
LocalNavDrawerState provides navDrawerState,
LocalSideSheetState provides sideSheetState,
LocalDrawerLockHandle provides lockDrawerHandle,
LocalSnackbarHostState provides snackbarState,
) {
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) {
LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) {
ModalNavigationDrawer(
drawerContent = {
ModalDrawerSheet(
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 56).dp),
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Start),
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.verticalScroll(scrollState)
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)),
) {
Image(
painter = painterResource(id = R.drawable.sadpanda_low_poly),
contentDescription = null,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
contentScale = ContentScale.FillWidth,
)
navItems.forEach { (direction, stringId, icon) ->
NavigationDrawerItem(
label = {
Text(text = stringResource(id = stringId))
},
selected = currentDestination === direction,
onClick = {
navController.navigate(direction.route)
closeDrawer()
},
modifier = Modifier.padding(horizontal = 12.dp),
icon = {
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)
},
)
}
}
},
drawerState = navDrawerState,
gesturesEnabled = !drawerLocked || navDrawerState.isOpen,
) {
val sheet = sideSheet.firstOrNull()
SideDrawer(
drawerContent = {
if (sheet != null) {
ModalDrawerSheet(
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End),
) {
CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
sheet(sideSheetState)
}
}
}
},
drawerState = sideSheetState,
gesturesEnabled = sheet != null && !drawerLocked,
) {
CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = navItems[Settings.launchPage].first,
engine = rememberNavHostEngine(rootDefaultAnimations = ehNavAnim),
navController = navController,
)
}
}
}
}
}
}
handled
}
// Trick End
if (savedInstanceState == null) {
if (intent.action != Intent.ACTION_MAIN) {
onNewIntent(intent)
}
checkDownloadLocation()
if (Settings.meteredNetworkWarning) {
checkMeteredNetwork()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (!Settings.appLinkVerifyTip) {
try {
@@ -278,161 +470,45 @@ class MainActivity : EhActivity() {
.show()
}
private fun checkMeteredNetwork() {
if (connectivityManager.isActiveNetworkMetered) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Snackbar.make(
binding.drawView,
R.string.metered_network_warning,
Snackbar.LENGTH_LONG,
)
.setAction(R.string.settings) {
val panelIntent =
Intent(android.provider.Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
startActivity(panelIntent)
}
.show()
} else {
showTip(R.string.metered_network_warning, BaseScene.LENGTH_LONG)
}
}
}
private val loginLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
setNavGraph()
}
override fun onResume() {
if (EhUtils.needSignedIn()) {
loginLauncher.launch(Intent(this, ConfigureActivity::class.java))
if (Settings.needSignIn) {
startActivity(Intent(this, ConfigureActivity::class.java))
}
super.onResume()
lifecycleScope.launch {
delay(300)
mDrawerOnBackPressedCallback.remove()
onBackPressedDispatcher.addCallback(mDrawerOnBackPressedCallback)
checkClipboardUrl()
}
fun showTip(@StringRes id: Int, useToast: Boolean = false) {
showTip(getString(id), useToast)
}
fun showTip(message: CharSequence, useToast: Boolean = false) {
if (useToast || !tipFlow.tryEmit(message.toString())) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
private suspend fun checkClipboardUrl() {
val text = clipboardManager.getUrlFromClipboard(this)
val hashCode = text?.hashCode() ?: 0
if (text != null && hashCode != 0 && Settings.clipboardTextHashCode != hashCode) {
val result1 = GalleryDetailUrlParser.parse(text, false)
var launch: (() -> Unit)? = null
if (result1 != null) {
val args = Bundle()
args.putString(GalleryDetailScene.KEY_ACTION, GalleryDetailScene.ACTION_GID_TOKEN)
args.putLong(GalleryDetailScene.KEY_GID, result1.gid)
args.putString(GalleryDetailScene.KEY_TOKEN, result1.token)
launch = { navController.navAnimated(R.id.galleryDetailScene, args) }
}
val result2 = GalleryPageUrlParser.parse(text, false)
if (result2 != null) {
val args = Bundle()
args.putString(ProgressScene.KEY_ACTION, ProgressScene.ACTION_GALLERY_TOKEN)
args.putLong(ProgressScene.KEY_GID, result2.gid)
args.putString(ProgressScene.KEY_PTOKEN, result2.pToken)
args.putInt(ProgressScene.KEY_PAGE, result2.page)
launch = { navController.navAnimated(R.id.progressScene, args) }
}
launch?.let {
withUIContext {
val snackbar = Snackbar.make(
binding.drawView,
R.string.clipboard_gallery_url_snack_message,
Snackbar.LENGTH_SHORT,
)
snackbar.setAction(R.string.clipboard_gallery_url_snack_action) {
it()
}
snackbar.show()
}
}
}
Settings.clipboardTextHashCode = hashCode
}
fun addAboveSnackView(view: View) {
binding.absnacker.addAboveSnackView(view)
}
fun removeAboveSnackView(view: View) {
binding.absnacker.removeAboveSnackView(view)
}
fun setDrawerLockMode(lockMode: Int, edgeGravity: Int) {
binding.drawView.setDrawerLockMode(lockMode, edgeGravity)
}
fun getDrawerLockMode(edgeGravity: Int): Int {
return binding.drawView.getDrawerLockMode(edgeGravity)
}
fun isDrawerOpen(drawerGravity: Int) = binding.drawView.isDrawerOpen(drawerGravity)
fun openDrawer(drawerGravity: Int) {
binding.drawView.openDrawer(drawerGravity)
}
fun closeDrawer(drawerGravity: Int) {
binding.drawView.closeDrawer(drawerGravity)
}
fun toggleDrawer(drawerGravity: Int) {
if (isDrawerOpen(drawerGravity)) {
closeDrawer(drawerGravity)
} else {
openDrawer(drawerGravity)
}
}
fun clearNavCheckedItem() {
binding.navView.setCheckedItem(R.id.nav_stub)
}
fun showTip(@StringRes id: Int, length: Int, useToast: Boolean = false) {
showTip(getString(id), length, useToast)
}
/**
* If activity is running, show snack bar, otherwise show toast
*/
fun showTip(message: CharSequence, length: Int, useToast: Boolean = false) {
findViewById<View>(R.id.snackbar)?.takeUnless { useToast }?.apply {
Snackbar.make(
this,
message,
if (length == BaseScene.LENGTH_LONG) Snackbar.LENGTH_LONG else Snackbar.LENGTH_SHORT,
).show()
} ?: Toast.makeText(
this,
message,
if (length == BaseScene.LENGTH_LONG) Toast.LENGTH_LONG else Toast.LENGTH_SHORT,
).show()
}
var mShareUrl: String? = null
override fun onProvideAssistContent(outContent: AssistContent?) {
super.onProvideAssistContent(outContent)
mShareUrl?.let { outContent?.webUri = Uri.parse(mShareUrl) }
shareUrl?.let { outContent?.webUri = Uri.parse(shareUrl) }
}
}
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) {
val updated by rememberUpdatedState(value)
if (updated) {
val current = currentCompositeKeyHash
val handle = LocalDrawerLockHandle.current
DisposableEffect(current) {
handle.add(current)
onDispose {
handle.remove(current)
}
}
}
private val mDrawerOnBackPressedCallback =
object : OnBackPressedCallback(false), DrawerLayout.DrawerListener {
val slideThreshold = 0.05
override fun handleOnBackPressed() {
binding.drawView.closeDrawers()
}
override fun onDrawerSlide(drawerView: View, slideOffset: Float) {
isEnabled = slideOffset > slideThreshold
}
override fun onDrawerOpened(drawerView: View) {}
override fun onDrawerClosed(drawerView: View) {}
override fun onDrawerStateChanged(newState: Int) {}
}
}
@@ -2,10 +2,52 @@ 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
fun ComposeView.setMD3Content(content: @Composable () -> Unit) = setContent { Mdc3Theme(content = content) }
fun ComponentActivity.setMD3Content(content: @Composable () -> Unit) = setContent { Mdc3Theme(content = content) }
inline fun ComposeView.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
EhTheme {
val dialogState = remember { DialogState() }
dialogState.Intercept()
CompositionLocalProvider(
LocalDialogState provides dialogState,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
content()
}
}
}
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
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)) },
)
}
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff. Show more