Compare commits

...
372 Commits
Author SHA1 Message Date
FooIbar 19d14e1d0c Release 1.9.0
Releases / Build (push) Has been cancelled
2023-12-01 22:46:11 +08: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
FooIbar ab8671cc3b Revert "Exclude some libraries from aboutLibraries"
This reverts commit bc34f25a3c.

Reason for revert: Licenses should be included as per Apache 2.0
2023-10-24 23:53:25 +08:00
FooIbar 2e52dda208 Downgrade AGP to stable version but use newer lint (#81)
* Downgrade AGP to stable version but use newer lint

* Downgrade Java to 17 and use newer r8
2023-10-24 13:08:23 +00:00
renovate[bot] 1ab08d0ae1 Update Rust crate quick-xml to 0.31 2023-10-23 20:22:16 +00:00
FooIbar 702be0ed43 Use eh API as well if forceEhThumb is enabled 2023-10-23 22:37:55 +08:00
FooIbar 4067543701 Cleanup code 2023-10-23 16:03:01 +08:00
FooIbar 074689c60e Fall back if failed to retrieve data from API 2023-10-23 15:10:18 +08:00
FooIbar 2beb715df3 Cleanup code 2023-10-23 14:36:28 +08:00
FooIbar 7ea2597899 Fix large torrent list parsing 2023-10-23 02:25:17 +08:00
FooIbar 34a8f92865 Show meaningful error message if it's available 2023-10-23 02:25:17 +08:00
FooIbar 6e2a2de13f Reformat imports alphabetically
https://developer.android.com/kotlin/style-guide#import_statements
2023-10-22 22:13:45 +08:00
FooIbar e073232352 Fix cronet support detection 2023-10-22 17:52:16 +08:00
FooIbar 6f49b4d23e Fix deprecated 2023-10-21 18:22:45 +08:00
FooIbar 97cad20ab1 It's affected by AGP and NDK versions as well 2023-10-21 17:38:29 +08:00
FooIbar 9300a3d9ce Don't write cmake cache every time 2023-10-21 16:26:14 +08:00
FooIbar 28b7f80a8f Fix content behind navigation bar 2023-10-21 16:13:27 +08:00
FooIbar a06d23753a Lock navi drawer on where doesn't have navi icon 2023-10-21 13:10:46 +08:00
FooIbar 98d66b3e6c Release 1.8.10.1
Releases / Build (push) Has been cancelled
2023-10-21 00:14:03 +08:00
FooIbar 6e59583dea Fix reader bug on certain orientation settings
Resolve #68
2023-10-21 00:12:52 +08:00
FooIbar 2f0571e384 Implement splash screen 2023-10-20 17:36:59 +08:00
renovate[bot] 64a6074e02 Update dependency org.jsoup:jsoup to v1.16.2 2023-10-20 06:11:31 +00:00
renovate[bot] 0b7cb4cf41 Update dependency com.android.application to v8.3.0-alpha09 2023-10-20 01:48:36 +00:00
renovate[bot] e620832e33 Update dependency com.google.android.material:material to v1.11.0-beta01 2023-10-19 23:03:41 +00:00
renovate[bot] f82e4af0e3 Update dependency com.android.application to v8.3.0-alpha08 2023-10-19 20:36:34 +00:00
326 changed files with 13495 additions and 15975 deletions

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
[*.{kt,kts}]
ij_kotlin_imports_layout = *
ktlint_code_style = intellij_idea
ktlint_function_naming_ignore_when_annotated_with = Composable
+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:
+4 -7
View File
@@ -18,11 +18,11 @@ jobs:
with:
submodules: 'recursive'
- name: Setup Java 21
uses: actions/setup-java@v3
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
@@ -48,16 +48,13 @@ jobs:
- name: Spotless Check
run: ./gradlew spotlessCheck
- name: Lint Check
run: ./gradlew lintMarshmallowGmsRelease
- name: CMake Cache
uses: actions/cache@v3
with:
path: |
app/.cxx
app/src/main/cpp/nettle/nettle
key: ${{ runner.os }}-cmake-${{ github.run_id }}
key: ${{ runner.os }}-cmake-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
restore-keys: ${{ runner.os }}-cmake-
- name: Build OSS
+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'
+4 -4
View File
@@ -15,11 +15,11 @@ jobs:
with:
submodules: 'recursive'
- name: Setup Java 21
uses: actions/setup-java@v3
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android
@@ -35,7 +35,7 @@ jobs:
path: |
app/.cxx
app/src/main/cpp/nettle/nettle
key: ${{ runner.os }}-cmake-${{ github.run_id }}
key: ${{ runner.os }}-cmake-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
restore-keys: ${{ runner.os }}-cmake-
- name: Gradle Build
+40
View File
@@ -0,0 +1,40 @@
# 变更日志
## 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)
+20 -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,13 @@ 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/)
- [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!
+29 -28
View File
@@ -64,8 +64,8 @@ android {
applicationId = "moe.tarsin.ehviewer"
minSdk = 28
targetSdk = 34
versionCode = 180044
versionName = "1.8.10.0"
versionCode = 180046
versionName = "1.9.0"
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"
@@ -149,13 +145,15 @@ 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",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:intrinsicRemember=true",
)
}
lint {
abortOnError = true
checkReleaseBuilds = false
disable.add("MissingTranslation")
disable += setOf("MissingTranslation", "MissingQuantity")
fatal += setOf("NewApi", "InlineApi")
}
packaging {
@@ -202,19 +200,23 @@ 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)
@@ -222,15 +224,13 @@ dependencies {
// https://developer.android.com/jetpack/androidx/releases/paging
implementation(libs.bundles.androidx.paging)
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
@@ -245,10 +245,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)
@@ -262,20 +265,28 @@ 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 {
// Workaround IME won't show again once hidden.
// Google issue-tracker link?
force("androidx.compose.ui:ui-text-android:1.6.0-alpha08")
exclude("androidx.navigation", "navigation-compose")
}
}
kotlin {
jvmToolchain(21)
jvmToolchain(17)
}
ksp {
@@ -286,16 +297,6 @@ ksp {
aboutLibraries {
duplicationMode = MERGE
duplicationRule = GROUP
exclusionPatterns = listOf(
"androidx\\..*".toPattern(),
".*annotations".toPattern(),
"com\\.google\\.code\\..*".toPattern(),
"com\\.google\\.guava:listenablefuture".toPattern(),
"com\\.google\\.protobuf:protobuf-javalite".toPattern(),
"org\\.checkerframework:checker-qual".toPattern(),
"org\\.chromium\\.net:cronet-embedded".toPattern(),
"org\\.slf4j:slf4j-api".toPattern(),
)
}
cargo {
+11 -21
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; }
-keepclassmembers class * implements android.os.Parcelable {
public static final ** CREATOR;
}
-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(); }
# https://issuetracker.google.com/issues/307323842
-keepclassmembers class androidx.compose.ui.platform.WindowInfoImpl {
setWindowFocused(boolean);
}
# Ktor logger
-dontwarn org.slf4j.impl.StaticLoggerBinder
-keepattributes LineNumberTable,SourceFile
-renamesourcefileattribute SourceFile
-repackageclasses
-allowaccessmodification
-overloadaggressively
-repackageclasses
@@ -1,44 +1,38 @@
package com.hippo.ehviewer.cronet
import com.hippo.ehviewer.EhApplication
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.CHROME_ACCEPT
import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
import com.hippo.ehviewer.client.CHROME_USER_AGENT
import com.hippo.ehviewer.client.EhCookieStore
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.utils.io.pool.DirectByteBufferPool
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.HttpUrl.Companion.toHttpUrl
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
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private const val TAG = "CronetRequest"
val pool = DirectByteBufferPool(32)
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.RequestBody
import okio.Buffer
import okio.Path.Companion.toOkioPath
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException
import org.chromium.net.UploadDataProvider
import org.chromium.net.UploadDataSink
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
import splitties.init.appCtx
val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
enableBrotli(true)
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)
setUserAgent(Settings.userAgent)
}.build()
val cronetHttpClientExecutor = EhApplication.baseOkHttpClient.dispatcher.executorService
// TODO: Rewrite this to use android.net.http.HttpEngine and make it Android 14 only when released
class CronetRequest {
lateinit var consumer: (ByteBuffer) -> Unit
lateinit var onResponse: CronetRequest.(UrlResponseInfo) -> Unit
@@ -47,7 +41,6 @@ class CronetRequest {
lateinit var readerCont: Continuation<Unit>
val callback = object : UrlRequest.Callback() {
override fun onRedirectReceived(req: UrlRequest, info: UrlResponseInfo, url: String) {
logcat(tag = TAG) { "Redirected to $url" }
req.followRedirect()
}
@@ -56,7 +49,6 @@ class CronetRequest {
}
override fun onReadCompleted(req: UrlRequest, info: UrlResponseInfo, data: ByteBuffer) {
data.flip()
consumer(data)
}
@@ -70,38 +62,16 @@ class CronetRequest {
}
}
inline fun cronetRequest(url: String, referer: String? = null, conf: UrlRequest.Builder.() -> Unit = {}) = CronetRequest().apply {
inline fun cronetRequest(url: String, referer: String? = null, origin: String? = null, conf: UrlRequest.Builder.() -> Unit = {}) = CronetRequest().apply {
request = cronetHttpClient.newUrlRequestBuilder(url, callback, cronetHttpClientExecutor).apply {
addHeader("Cookie", EhCookieStore.getCookieHeader(url.toHttpUrl()))
addHeader("Accept", CHROME_ACCEPT)
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
referer?.let { addHeader("Referer", it) }
origin?.let { addHeader("Origin", it) }
}.apply(conf).build()
}
suspend inline fun CronetRequest.awaitBodyFully(crossinline callback: (ByteBuffer) -> Unit) {
val buffer = pool.borrow()
return try {
suspendCancellableCoroutine { cont ->
consumer = {
callback(it)
buffer.clear()
request.read(buffer)
}
onError = { readerCont.resumeWithException(it) }
readerCont = cont
request.read(buffer)
}
} finally {
pool.recycle(buffer)
}
}
suspend inline fun CronetRequest.copyToChannel(chan: FileChannel, crossinline listener: ((Int) -> Unit) = {}) = awaitBodyFully {
val bytes = chan.write(it)
listener(bytes)
}
suspend inline fun <R> CronetRequest.execute(crossinline callback: suspend CronetRequest.(UrlResponseInfo) -> R): R {
contract {
callsInPlace(callback, InvocationKind.EXACTLY_ONCE)
@@ -116,5 +86,21 @@ suspend inline fun <R> CronetRequest.execute(crossinline callback: suspend Crone
}
}
fun UrlRequest.Builder.withRequestBody(body: RequestBody) {
addHeader("Content-Type", body.contentType().toString())
val buffer = Buffer().apply { body.writeTo(this) }
val provider = object : UploadDataProvider() {
override fun getLength() = body.contentLength()
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
buffer.read(byteBuffer)
uploadDataSink.onReadSucceeded(false)
}
override fun rewind(uploadDataSink: UploadDataSink) {
error("OneShot!")
}
}
setUploadDataProvider(provider, cronetHttpClientExecutor)
}
fun UrlRequest.Builder.noCache(): UrlRequest.Builder = disableCache()
fun UrlResponseInfo.getHeadersMap(): Map<String, List<String>> = allHeaders
@@ -2,10 +2,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 && CronetProviderInstaller.isInstalled().also {
get() = Settings.enableCronet && CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
if (!it) {
CronetProviderInstaller.installProvider(appCtx)
}
+6 -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"
@@ -44,6 +48,7 @@
android:configChanges="screenSize"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/AppTheme.Splash"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -134,8 +139,7 @@
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<activity
android:name=".ui.WebViewActivity" />
<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,525 @@
@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.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.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 blurModifier = Modifier.graphicsLayer {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
val radius = lerp(0.dp, 10.dp, step)
if (radius != 0.dp) {
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
shape = RectangleShape
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 blurModifier = Modifier.graphicsLayer {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
val radius = lerp(0.dp, 10.dp, step)
if (radius != 0.dp) {
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
shape = RectangleShape
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
@@ -20,6 +20,7 @@ import android.app.Application
import android.content.ComponentCallbacks2
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.LruCache
import androidx.compose.runtime.snapshots.Snapshot
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil.ImageLoaderFactory
@@ -49,9 +50,13 @@ 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.isAtLeastS
import com.hippo.ehviewer.util.isCronetSupported
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
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 kotlinx.coroutines.launch
import moe.tarsin.kt.unreachable
import okhttp3.AsyncDns
@@ -67,7 +72,12 @@ class EhApplication : Application(), ImageLoaderFactory {
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()
@@ -91,7 +101,7 @@ class EhApplication : Application(), ImageLoaderFactory {
migrateCookies()
}
launchIO {
EhTagDatabase.update()
EhTagDatabase
}
launchIO {
EhDB
@@ -109,6 +119,11 @@ class EhApplication : Application(), ImageLoaderFactory {
}
}
cleanObsoleteCache(this)
if (BuildConfig.DEBUG) {
Snapshot.registerApplyObserver { anies, _ ->
logcat { anies.toString() }
}
}
}
private suspend fun cleanupDownload() {
@@ -170,6 +185,7 @@ class EhApplication : Application(), ImageLoaderFactory {
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
}
chunker { alwaysReadResponseBody(false) }
addInterceptor(UncaughtExceptionInterceptor())
addInterceptor(CloudflareInterceptor(appCtx))
}
}
@@ -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,18 +2,95 @@
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 java.util.Locale
import kotlin.reflect.KProperty
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
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
import java.util.Locale
import kotlin.reflect.KProperty
@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 +101,14 @@ 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 meteredNetworkWarning = boolPref("cellular_network_warning", false)
val predictiveNavAnim = boolPref("predictive_nav_anim", true)
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 +121,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 +132,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,22 +142,22 @@ 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 needSignIn by boolPref("need_sign_in", true).also { needSignInFlow = it.valueFlow() }
var blackDarkTheme by boolPref("black_dark_theme", false).observed { updateWhenAmoledModeChanges() }
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 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)
@@ -82,6 +165,10 @@ object Settings : DataStorePreferences(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 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)
init {
if ("CN" == Locale.getDefault().country) {
@@ -1,5 +1,6 @@
package com.hippo.ehviewer
import android.app.UiModeManager
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
import androidx.core.os.LocaleListCompat
@@ -8,6 +9,7 @@ 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 +19,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,6 +47,14 @@ fun updateWhenKeepMediaStatusChanges() {
fun updateWhenThemeChanges() {
collectScope.launch {
delay(100) // Avoid recompose being cancelled
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)
}
}
@@ -51,7 +62,7 @@ fun updateWhenThemeChanges() {
fun updateWhenAmoledModeChanges() {
collectScope.launch {
delay(100) // Avoid recompose being cancelled
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_AUTO_BATTERY)
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_AUTO_BATTERY) // Trigger onNightModeChanged
AppCompatDelegate.setDefaultNightMode(Settings.theme)
}
}
@@ -16,6 +16,11 @@
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
@@ -24,13 +29,15 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
object EhCookieStore : CookieJar {
private val manager = CookieManager.getInstance()
fun signOut() = manager.removeAllCookies(null)
fun contains(url: HttpUrl, name: String) = get(url).any { it.name == name }
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 != null && cookies.isNotEmpty()) {
cookies.split(";").mapNotNull { Cookie.parse(url, it) }.filterNot { it.name == KEY_UTMP_NAME }
return if (!cookies.isNullOrEmpty()) {
cookies.split(";").fastMapNotNull { setCookie ->
Cookie.parse(url, setCookie)?.takeUnless { it.name == KEY_UTMP_NAME }
}
} else {
emptyList()
}
@@ -57,9 +64,12 @@ object EhCookieStore : CookieJar {
}.build()
fun copyNecessaryCookies() {
val cookie = get(EhUrl.HOST_E.toHttpUrl()).filter { it.name == KEY_STAR || it.name == KEY_IPB_MEMBER_ID || it.name == KEY_IPB_PASS_HASH || it.name == KEY_IGNEOUS }
cookie.forEach { manager.setCookie(EhUrl.HOST_EX, it.toString()) }
flush()
val cookies = get(EhUrl.HOST_E.toHttpUrl())
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())
}
}
}
fun deleteCookie(url: HttpUrl, name: String) {
@@ -67,35 +77,29 @@ object EhCookieStore : CookieJar {
}
fun addCookie(cookie: Cookie) {
if (EhUrl.DOMAIN_E in cookie.domain) manager.setCookie(EhUrl.HOST_E, cookie.toString()) else manager.setCookie(EhUrl.DOMAIN_EX, cookie.toString())
manager.setCookie(if (EhUrl.DOMAIN_E == cookie.domain) EhUrl.HOST_E else EhUrl.HOST_EX, cookie.toString())
}
fun flush() = manager.flush()
fun getCookieHeader(url: HttpUrl): String {
val cookies = loadForRequest(url)
val cookieHeader = StringBuilder()
var i = 0
val size = cookies.size
while (i < size) {
if (i > 0) {
cookieHeader.append("; ")
}
val cookie = cookies[i]
cookieHeader.append(cookie.name).append('=').append(cookie.value)
i++
return loadForRequest(url).fastJoinToString("; ") {
"${it.name}=${it.value}"
}
return cookieHeader.toString()
}
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = cookies.filterNot { it.name == KEY_UTMP_NAME }.forEach { manager.setCookie(url.toString(), it.toString()) }.also { flush() }
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = cookies.fastForEach {
if (it.name != KEY_UTMP_NAME) {
manager.setCookie(url.toString(), it.toString())
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val checkTips = EhUrl.DOMAIN_E in url.host
return get(url).run {
if (checkTips) {
filterNot { it.name == KEY_CONTENT_WARNING }.toMutableList().apply { add(sTipsCookie) }
fastFilter { it.name != KEY_CONTENT_WARNING }.toMutableList().apply { add(sTipsCookie) }
} else {
this
}
@@ -21,6 +21,7 @@ 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
@@ -34,7 +35,6 @@ 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.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
@@ -48,14 +48,20 @@ import com.hippo.ehviewer.client.parser.VoteTagParser
import com.hippo.ehviewer.cronet.awaitBodyFully
import com.hippo.ehviewer.cronet.cronetRequest
import com.hippo.ehviewer.cronet.execute
import com.hippo.ehviewer.cronet.withRequestBody
import com.hippo.ehviewer.dailycheck.showEventNotification
import com.hippo.ehviewer.dailycheck.today
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.isCronetSupported
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import java.io.File
import java.nio.ByteBuffer
import kotlin.math.ceil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonArray
import kotlinx.serialization.json.put
@@ -65,12 +71,10 @@ import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import org.jsoup.Jsoup
import splitties.init.appCtx
import java.io.File
import java.nio.ByteBuffer
import kotlin.math.ceil
private const val TAG = "EhEngine"
private const val MAX_REQUEST_SIZE = 25
@@ -103,7 +107,7 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
throw StatusCodeException(code)
}
if (e is ParseException) {
if (e is ParseException || e is SerializationException) {
if ("<" !in body) {
throw EhException(body)
} else {
@@ -116,33 +120,53 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
throw e
}
val httpContentPool = DirectByteBufferPool(16, 16384)
val httpContentPool = DirectByteBufferPool(8, 0x80000)
suspend inline fun <T> fetchCompat(url: String, referer: String? = null, crossinline parser: (ByteBuffer) -> T): T {
suspend fun <T> fetchCompat(
url: String,
referer: String? = null,
origin: String? = null,
reqBody: RequestBody? = null,
parser: suspend (ByteBuffer) -> T,
): T {
Log.d(TAG, url)
fun commonChecks(body: ByteBuffer) {
// Check sad panda(without panda)
if (!body.hasRemaining()) {
if (EhUtils.isExHentai) {
throw EhException("Sad Panda\n(without panda)")
} else {
throw EhException("IP banned")
}
}
}
return if (isCronetSupported) {
cronetRequest(url, referer).execute {
val buffer = httpContentPool.borrow()
try {
awaitBodyFully {
buffer.put(it)
}
cronetRequest(url, referer, origin) {
reqBody?.let { withRequestBody(it) }
}.execute {
@Suppress("NewApi")
val code = it.httpStatusCode
if (code >= 400) {
throw StatusCodeException(code)
}
httpContentPool.useInstance { buffer ->
awaitBodyFully(buffer)
buffer.flip()
commonChecks(buffer)
parser(buffer)
} finally {
httpContentPool.recycle(buffer)
}
}
} else {
ehRequest(url, referer).execute {
val buffer = httpContentPool.borrow()
try {
ehRequest(url, referer, origin) {
reqBody?.let { post(it) }
}.execute {
httpContentPool.useInstance { buffer ->
while (true) {
if (body.source().read(buffer) == -1) break
}
buffer.flip()
commonChecks(buffer)
parser(buffer)
} finally {
httpContentPool.recycle(buffer)
}
}
}
@@ -175,7 +199,7 @@ object EhEngine {
suspend fun getTorrentList(url: String, gid: Long, token: String?): TorrentResult {
val referer = EhUrl.getGalleryDetailUrl(gid, token)
return fetchCompat(url, referer, TorrentParser::parse)
return fetchCompat(url, referer, parser = TorrentParser::parse)
}
suspend fun getArchiveList(url: String, gid: Long, token: String?) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
@@ -219,8 +243,7 @@ object EhEngine {
return ehRequest(url, referer).executeAndParsingWith(GalleryPageParser::parse)
}
suspend fun getGalleryList(url: String) = ehRequest(url, EhUrl.referer)
.executeAndParsingWith(GalleryListParser::parse)
suspend fun getGalleryList(url: String) = fetchCompat(url, EhUrl.referer, parser = GalleryListParser::parse)
.apply { fillGalleryList(galleryInfoList, url, true) }
.takeUnless { it.galleryInfoList.isEmpty() } ?: GalleryListParser.emptyResult
@@ -236,8 +259,7 @@ object EhEngine {
GalleryDetailParser.parsePreviewList(this) to GalleryDetailParser.parsePreviewPages(this)
}
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer)
.executeAndParsingWith(FavoritesParser::parse)
suspend fun getFavorites(url: String) = fetchCompat(url, EhUrl.referer, parser = FavoritesParser::parse)
.apply { fillGalleryList(galleryInfoList, url) }
suspend fun signIn(username: String, password: String): String {
@@ -256,13 +278,13 @@ object EhEngine {
}.executeAndParsingWith(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) {
if (id == -1L) {
add("commenttext_new", comment)
} else {
add("commenttext_edit", comment)
add("edit_comment", id)
add("edit_comment", id.toString())
}
}
}.executeAndParsingWith {
@@ -274,31 +296,21 @@ 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) {
formBody {
add("favcat", catStr)
add("favnote", note)
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
add("apply", "Apply Changes")
add("update", "1")
}
}.executeAndParsingWith { }
val body = formBody {
add("favcat", catStr)
add("favnote", note)
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
add("apply", "Apply Changes")
add("update", "1")
}
fetchCompat(url, url, EhUrl.origin, body) { }
}
suspend fun downloadArchive(
@@ -351,18 +363,24 @@ object EhEngine {
}
}.executeAndParsingWith(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 {
addPathSegments(EhUrl.FAV_PATH)
if (FavListUrlBuilder.isValidFavCat(srcCat)) {
addQueryParameter("favcat", srcCat.toString())
}
}.toString()
val catStr: String = when (dstCat) {
-1 -> "delete"
in 0..9 -> "fav$dstCat"
else -> throw EhException("Invalid dstCat: $dstCat")
}
return ehRequest(url, url, EhUrl.origin) {
formBody {
add("ddact", catStr)
gidArray.forEach { add("modifygids[]", it.toString()) }
}
}.executeAndParsingWith(FavoritesParser::parse).apply { fillGalleryList(galleryInfoList, url) }
val body = formBody {
add("ddact", catStr)
gidArray.forEach { add("modifygids[]", it.toString()) }
}
return fetchCompat(url, url, EhUrl.origin, body, FavoritesParser::parse)
.apply { fillGalleryList(galleryInfoList, url) }
}
suspend fun getGalleryPageApi(
@@ -371,7 +389,7 @@ object EhEngine {
pToken: String,
showKey: String?,
previousPToken: String?,
): GalleryPageApiParser.Result {
): 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 {
@@ -381,7 +399,7 @@ object EhEngine {
put("imgkey", pToken)
put("showkey", showKey)
}
}.executeAndParsingWith(GalleryPageApiParser::parse)
}.executeAndParsingWith { GalleryPageParser.parse(filterNot { it == '\\' }) }
}
suspend fun rateGallery(
@@ -401,22 +419,23 @@ object EhEngine {
}
}.executeAndParsingWith(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).parMap(concurrency = Settings.multiThreadDownload) {
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
put("method", "gdata")
array("gidlist") {
it.forEach {
addJsonArray {
add(it.gid)
add(it.token)
}
}
}
put("namespace", 1)
}
put("namespace", 1)
}
}.executeAndParsingWith { GalleryApiParser.parse(this, it) }
}
}.executeAndParsingWith { GalleryApiParser.parse(this, it) }
}
suspend fun voteComment(
apiUid: Long,
@@ -472,15 +491,19 @@ object EhEngine {
/**
* @param image Must be jpeg
*/
suspend fun imageSearch(image: File, uss: Boolean, osc: Boolean) = ehRequest(EhUrl.imageSearchUrl, EhUrl.referer, EhUrl.origin) {
suspend fun imageSearch(image: File, uss: Boolean, osc: Boolean) = fetchCompat(
EhUrl.imageSearchUrl,
EhUrl.referer,
EhUrl.origin,
multipartBody {
setType(MultipartBody.FORM)
addFormDataPart("sfile", "a.jpg", image.asRequestBody(MEDIA_TYPE_JPEG))
if (uss) addFormDataPart("fs_similar", "on")
if (osc) addFormDataPart("fs_covers", "on")
addFormDataPart("f_sfile", "File Search")
}
}.executeAndParsingWith(GalleryListParser::parse).apply { fillGalleryList(galleryInfoList, EhUrl.imageSearchUrl) }
},
parser = GalleryListParser::parse,
).apply { fillGalleryList(galleryInfoList, EhUrl.imageSearchUrl) }
private suspend fun fillGalleryList(list: MutableList<BaseGalleryInfo>, url: String, filter: Boolean = false) {
// Filter title and uploader
@@ -490,7 +513,7 @@ object EhEngine {
var hasPages = false
var hasRated = false
for (gi in list) {
if (gi.simpleTags != null) {
if (!gi.simpleTags.isNullOrEmpty()) {
hasTags = true
}
if (gi.pages != 0) {
@@ -507,7 +530,7 @@ object EhEngine {
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) }
suspend fun addFavorites(galleryList: List<Pair<Long, String>>, dstCat: Int) {
galleryList.parMap(concurrency = Settings.multiThreadDownload) { (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)
}
@@ -19,6 +19,11 @@ import android.util.Log
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectOkHttpClient
import com.hippo.ehviewer.EhApplication.Companion.okHttpClient
import com.hippo.ehviewer.Settings
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -38,22 +43,19 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.executeAsync
import okio.BufferedSource
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
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("User-Agent", Settings.userAgent)
addHeader("Accept", CHROME_ACCEPT)
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
referer?.let { addHeader("Referer", it) }
origin?.let { addHeader("Origin", it) }
}.apply(builder).build()
inline fun formBody(builder: FormBody.Builder.() -> Unit) = FormBody.Builder().apply(builder).build()
inline fun Request.Builder.formBody(builder: FormBody.Builder.() -> Unit) = post(FormBody.Builder().apply(builder).build())
inline fun Request.Builder.multipartBody(builder: MultipartBody.Builder.() -> Unit) = post(MultipartBody.Builder().apply(builder).build())
inline fun multipartBody(builder: MultipartBody.Builder.() -> Unit) = MultipartBody.Builder().apply(builder).build()
val MEDIA_TYPE_JSON: MediaType = "application/json; charset=utf-8".toMediaType()
@@ -24,6 +24,8 @@ import com.hippo.ehviewer.R
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.copyToFile
import java.io.File
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -42,8 +44,6 @@ import okio.blackholeSink
import okio.buffer
import okio.source
import splitties.init.appCtx
import java.io.File
import java.nio.charset.StandardCharsets
object EhTagDatabase : CoroutineScope {
private const val NAMESPACE_PREFIX = "n"
@@ -55,7 +55,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)
@@ -202,42 +201,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)) {
@@ -254,7 +253,7 @@ object EhTagDatabase : CoroutineScope {
if (tempSha1 == sha1) {
// The data is the same
FileUtils.delete(tempSha1File)
return@runCatching
return
}
// Save new data
@@ -265,7 +264,7 @@ object EhTagDatabase : CoroutineScope {
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 +274,7 @@ object EhTagDatabase : CoroutineScope {
tempDataFile.renameTo(dataFile)
// Read new EhTagDatabase
issueUpdateInMemoryData()
issueUpdateInMemoryData(dataFile)
}.onFailure {
it.printStackTrace()
}
@@ -64,10 +64,14 @@ object EhUrl {
}
val apiUrl: String
get() = when (Settings.gallerySite) {
SITE_E -> API_E
SITE_EX -> API_EX
else -> API_E
get() = if (Settings.forceEhThumb) {
API_E
} else {
when (Settings.gallerySite) {
SITE_E -> API_E
SITE_EX -> API_EX
else -> API_E
}
}
val referer: String
@@ -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,
@@ -22,16 +22,16 @@ 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 {
@@ -42,8 +42,8 @@ class FavListUrlBuilder(
addQueryParameter("favcat", "all")
}
addQueryParameterIfNotBlank("f_search", keyword)
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("prev", prev)
addQueryParameterIfNotBlank("next", next)
addQueryParameterIfNotBlank("seek", jumpTo)
}.toString()
@@ -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,
@@ -131,7 +93,7 @@ interface GalleryInfo {
Pattern.compile("[(\\[]hun(?:garian)?[)\\]]|ハンガリー翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]dutch[)\\]]|オランダ翻訳", Pattern.CASE_INSENSITIVE),
)
val S_LANG_TAGS = arrayOf(
private val S_LANG_TAGS = arrayOf(
"language:english",
"language:chinese",
"language:spanish",
@@ -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
@@ -25,9 +25,9 @@ import com.hippo.ehviewer.dao.QuickSearch
import com.hippo.ehviewer.ui.main.AdvanceTable
import com.hippo.ehviewer.util.encodeUTF8
import com.hippo.ehviewer.util.toIntOrDefault
import kotlinx.parcelize.Parcelize
import java.io.UnsupportedEncodingException
import java.net.URLDecoder
import kotlinx.parcelize.Parcelize
@Parcelize
data class ListUrlBuilder(
@@ -35,6 +35,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 +49,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 +64,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 +117,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
}
@@ -320,66 +302,65 @@ data class ListUrlBuilder(
// Pages
if (pageFrom != -1 || pageTo != -1) {
addQueryParameter("f_sp", "on")
addQueryParameter(
addQueryParameterIfNotBlank(
"f_spf",
(if (pageFrom != -1) pageFrom.toString() else "").toString(),
pageFrom.takeUnless { it == -1 }?.toString(),
)
addQueryParameter(
addQueryParameterIfNotBlank(
"f_spt",
(if (pageTo != -1) pageTo.toString() else "").toString(),
pageTo.takeUnless { it == -1 }?.toString(),
)
}
}
}.toString()
MODE_UPLOADER -> {
val sb = StringBuilder(EhUrl.host)
mKeyword?.let {
sb.append("uploader/")
sb.append(encodeUTF8(it))
buildString {
append(EhUrl.host)
mKeyword?.let {
append("uploader/").append(encodeUTF8(it))
}
mPrev?.let {
append("?prev=").append(it)
}
mNext?.let {
append("?next=").append(it)
}
mJumpTo?.let {
append("&seek=").append(it)
}
}
mPrev?.let {
sb.append("?prev=").append(it)
}
mNext?.let {
sb.append("?next=").append(it)
}
mJumpTo?.let {
sb.append("&seek=").append(it)
}
sb.toString()
}
MODE_TAG -> {
val sb = StringBuilder(EhUrl.host)
mKeyword?.let {
sb.append("tag/")
sb.append(encodeUTF8(it))
buildString {
append(EhUrl.host)
mKeyword?.let {
append("tag/").append(encodeUTF8(it))
}
mPrev?.let {
append("?prev=").append(it)
}
mNext?.let {
append("?next=").append(it)
}
mJumpTo?.let {
append("&seek=").append(it)
}
}
mPrev?.let {
sb.append("?prev=").append(it)
}
mNext?.let {
sb.append("?next=").append(it)
}
mJumpTo?.let {
sb.append("&seek=").append(it)
}
sb.toString()
}
MODE_WHATS_HOT -> EhUrl.popularUrl
MODE_IMAGE_SEARCH -> EhUrl.imageSearchUrl
MODE_TOPLIST -> {
val sb = StringBuilder(EhUrl.HOST_E)
sb.append("toplist.php?tl=")
mKeyword.orEmpty().let {
sb.append(encodeUTF8(it))
buildString {
append(EhUrl.HOST_E)
append("toplist.php?tl=")
append(mKeyword!!)
mJumpTo?.let {
append("&p=").append(it)
}
}
mJumpTo?.let {
sb.append("&p=").append(it)
}
sb.toString()
}
else -> throw IllegalStateException("Unexpected value: $mode")
@@ -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
@@ -41,6 +42,10 @@ import com.hippo.ehviewer.util.toFloatOrDefault
import com.hippo.ehviewer.util.toIntOrDefault
import com.hippo.ehviewer.util.trimAnd
import com.hippo.ehviewer.util.unescapeXml
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
@@ -48,10 +53,6 @@ import org.jsoup.nodes.Node
import org.jsoup.select.Elements
import org.jsoup.select.NodeTraversor
import org.jsoup.select.NodeVisitor
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
object GalleryDetailParser {
private val PATTERN_ERROR = Regex("<div class=\"d\">\n<p>([^<]+)</p>")
@@ -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,58 +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.skipHathKey)?.run {
groupValues[1].trim().unescapeXml()
}
val originImageUrl = PATTERN_ORIGIN_IMAGE_URL.find(res.originImageUrl)?.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 skipHathKey: String,
@SerialName("i7")
val originImageUrl: 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,10 +1,11 @@
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 kotlinx.parcelize.Parcelize
import java.nio.ByteBuffer
import kotlinx.parcelize.Parcelize
object HomeParser {
private val PATTERN_FUNDS = Regex("Available: ([\\d,]+) Credits.*Available: ([\\d,]+) kGP", RegexOption.DOT_MATCHES_ALL)
@@ -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>
@@ -3,6 +3,8 @@ package com.hippo.ehviewer.coil
import coil.intercept.Interceptor
import coil.request.ImageResult
import com.hippo.ehviewer.Settings
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -11,8 +13,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
object MergeInterceptor : Interceptor {
private val pendingContinuationMap: HashMap<String, MutableList<CancellableContinuation<Unit>>> = hashMapOf()
@@ -0,0 +1,49 @@
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
val pool = DirectByteBufferPool(32)
val cronetHttpClientExecutor = EhApplication.baseOkHttpClient.dispatcher.executorService
@Suppress("NewApi")
suspend inline fun CronetRequest.awaitBodyFully(crossinline callback: (ByteBuffer) -> Unit) {
return pool.useInstance { buffer ->
suspendCancellableCoroutine { cont ->
consumer = {
check(it === buffer)
buffer.flip()
callback(it)
buffer.clear()
request.read(buffer)
}
onError = { readerCont.resumeWithException(it) }
readerCont = cont
request.read(buffer)
}
}
}
@Suppress("NewApi")
suspend inline fun CronetRequest.awaitBodyFully(buffer: ByteBuffer) {
return suspendCancellableCoroutine { cont ->
consumer = {
request.read(buffer)
check(buffer.hasRemaining())
}
onError = { readerCont.resumeWithException(it) }
readerCont = cont
request.read(buffer)
}
}
suspend inline fun CronetRequest.copyToChannel(chan: FileChannel, crossinline listener: ((Int) -> Unit) = {}) = awaitBodyFully {
val bytes = chan.write(it)
listener(bytes)
}
@@ -24,12 +24,12 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import eu.kanade.tachiyomi.util.lang.withIOContext
import splitties.init.appCtx
import java.time.Duration
import java.time.Instant
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.concurrent.TimeUnit
import splitties.init.appCtx
private val signedIn
get() = EhCookieStore.hasSignedIn()
@@ -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 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
import java.util.LinkedList
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)
@@ -35,6 +35,7 @@ import com.hippo.ehviewer.jni.providePassword
import com.hippo.ehviewer.jni.releaseByteBuffer
import com.hippo.ehviewer.util.FileUtils
import com.hippo.unifile.UniFile
import java.nio.ByteBuffer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
@@ -42,15 +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
import java.nio.ByteBuffer
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)
}
}
@@ -28,8 +28,10 @@ import java.util.Locale
class EhPageLoader(private val mGalleryInfo: GalleryInfo) : PageLoader2(), OnSpiderListener {
private lateinit var mSpiderQueen: SpiderQueen
override fun start() {
mSpiderQueen = obtainSpiderQueen(mGalleryInfo, SpiderQueen.MODE_READ)
mSpiderQueen.addOnSpiderListener(this)
if (!::mSpiderQueen.isInitialized) {
mSpiderQueen = obtainSpiderQueen(mGalleryInfo, SpiderQueen.MODE_READ)
mSpiderQueen.addOnSpiderListener(this)
}
}
override fun stop() {
@@ -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()
}
}
}
@@ -46,18 +46,17 @@ 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.buffer
import okio.source
import splitties.init.appCtx
import java.nio.ByteBuffer
import kotlin.math.min
class Image private constructor(drawable: Drawable, private val src: AutoCloseable) {
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() {
@@ -126,6 +125,7 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
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
@@ -2,8 +2,8 @@ package com.hippo.ehviewer.legacy
import android.app.Application
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.DelicateCoroutinesApi
import java.io.File
import kotlinx.coroutines.DelicateCoroutinesApi
private val OBSOLETE_CACHE_DIRS = arrayOf(
"image",
@@ -1,9 +1,9 @@
package com.hippo.ehviewer.legacy
import com.hippo.ehviewer.spider.SpiderInfo
import java.io.InputStream
import okio.buffer
import okio.source
import java.io.InputStream
fun readLegacySpiderInfo(inputStream: InputStream): SpiderInfo {
val source = inputStream.source().buffer()
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.spider
import android.os.ParcelFileDescriptor
import android.os.ParcelFileDescriptor.MODE_READ_WRITE
import com.hippo.ehviewer.EhApplication.Companion.imageCache as sCache
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.client.EhUtils.getSuitableTitle
import com.hippo.ehviewer.client.data.GalleryInfo
@@ -38,14 +39,13 @@ import com.hippo.ehviewer.util.isCronetSupported
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import com.hippo.unifile.openOutputStream
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
import java.util.Locale
import kotlin.io.path.readText
import com.hippo.ehviewer.EhApplication.Companion.imageCache as sCache
class SpiderDen(mGalleryInfo: GalleryInfo) {
private val mGid = mGalleryInfo.gid
@@ -59,7 +59,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() }
}
}
@@ -127,6 +127,7 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
return dir.createFile(perFilename(index, ext))
}
@Suppress("NewApi")
suspend fun makeHttpCallAndSaveImage(
index: Int,
url: String,
@@ -7,13 +7,13 @@ 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 splitties.init.appCtx
import java.io.File
@Serializable
class SpiderInfo(
@@ -30,7 +30,6 @@ import com.hippo.ehviewer.client.EhUrl.getGalleryMultiPageViewerUrl
import com.hippo.ehviewer.client.EhUrl.referer
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.exception.QuotaExceededException
import com.hippo.ehviewer.client.execute
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePages
@@ -42,6 +41,9 @@ 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.seconds
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -59,9 +61,6 @@ import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeout
import moe.tarsin.coroutines.runSuspendCatching
import splitties.init.appCtx
import java.time.Instant
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration.Companion.seconds
class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineScope {
override val coroutineContext = Dispatchers.IO + Job()
@@ -620,8 +619,9 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
var originImageUrl: String? = null
var error: String? = null
var forceHtml = false
val original = Settings.downloadOriginImage || orgImg
runCatching {
repeat(2) {
repeat(2) { retries ->
var imageUrl: String? = null
var localShowKey: String?
@@ -631,11 +631,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 ->
@@ -650,7 +646,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
if (imageUrl == null) {
runCatching {
runSuspendCatching {
EhEngine.getGalleryPageApi(
mSpiderInfo.gid,
index,
@@ -664,20 +660,18 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
originImageUrl = it.originImageUrl
}
}.onFailure {
if (it is ParseException && "Key mismatch" == it.message) {
// Show key is wrong, enter a new loop to get the new show key
if (showKey == localShowKey) showKey = null
return@repeat
} else {
throw it
}
forceHtml = true
return@repeat
}
}
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
@@ -777,14 +771,9 @@ 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 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()
}
}
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.ui
import android.content.res.Resources.Theme
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.ActivityNavigator
@@ -24,8 +25,6 @@ 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
@@ -34,7 +33,7 @@ abstract class EhActivity : AppCompatActivity() {
}
override fun onApplyThemeResource(theme: Theme, resid: Int, first: Boolean) {
theme.applyStyle(resid, true)
super.onApplyThemeResource(theme, resid, first)
theme.applyStyle(getThemeStyleRes(), true)
}
@@ -43,8 +42,8 @@ abstract class EhActivity : AppCompatActivity() {
}
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,58 +26,150 @@ 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.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.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.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.IconFix
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 kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import splitties.systemservices.clipboardManager
import splitties.systemservices.connectivityManager
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.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
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 {
@@ -93,133 +185,231 @@ 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()
}
}
}
}
intentFlow.tryEmit(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 intentFlow = MutableSharedFlow<Intent>(extraBufferCapacity = 4)
@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 {
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?) {
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)
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)
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,
) {
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 = {
IconFix(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 {
@@ -276,161 +466,44 @@ 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!") }
@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,43 @@ 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.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.tools.DialogState
import com.hippo.ehviewer.ui.tools.LocalDialogState
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 {
Mdc3Theme {
val dialogState = remember { DialogState() }
dialogState.Intercept()
CompositionLocalProvider(LocalDialogState provides dialogState) {
content()
}
}
}
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
Mdc3Theme {
val dialogState = remember { DialogState() }
dialogState.Intercept()
CompositionLocalProvider(LocalDialogState provides dialogState) {
content()
}
}
}
val ehNavAnim by lazy {
RootNavGraphDefaultAnimations(
enterTransition = { fadeIn(animationSpec = tween(500)) },
exitTransition = { fadeOut(animationSpec = tween(500)) },
)
}
@@ -1,217 +0,0 @@
package com.hippo.ehviewer.ui.legacy
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout.AttachedBehavior
import androidx.core.util.ObjectsCompat
import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.drawerlayout.widget.DrawerLayout.DrawerListener
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.drakeet.drawer.FullDraggableHelper
import com.google.android.material.snackbar.Snackbar.SnackbarLayout
import com.hippo.ehviewer.util.dp2px
class AboveSnackerLayout : FrameLayout, AttachedBehavior, FullDraggableHelper.Callback {
private val helper: FullDraggableHelper
private var drawerLayout: DrawerLayout? = null
private var mAboveSnackViewList: MutableList<View>? = null
constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) {
helper = FullDraggableHelper(context, this)
}
constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) :
super(context!!, attrs, defStyle) {
helper = FullDraggableHelper(context, this)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
ensureDrawerLayout()
}
private fun ensureDrawerLayout() {
val parent = parent.parent
check(parent is DrawerLayout) { "This $this must be added to a DrawerLayout" }
drawerLayout = parent
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
return helper.onInterceptTouchEvent(event)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
return helper.onTouchEvent(event)
}
override fun getDrawerMainContainer(): View {
return this
}
override fun isDrawerOpen(gravity: Int): Boolean {
return drawerLayout!!.isDrawerOpen(gravity)
}
override fun hasEnabledDrawer(gravity: Int): Boolean {
return drawerLayout!!.getDrawerLockMode(gravity) == DrawerLayout.LOCK_MODE_UNLOCKED &&
findDrawerWithGravity(gravity) != null
}
override fun offsetDrawer(gravity: Int, offset: Float) {
setDrawerToOffset(gravity, offset)
drawerLayout!!.invalidate()
}
override fun smoothOpenDrawer(gravity: Int) {
drawerLayout!!.openDrawer(gravity, true)
}
override fun smoothCloseDrawer(gravity: Int) {
drawerLayout!!.closeDrawer(gravity, true)
}
override fun onDrawerDragging() {
val drawerListeners = drawerListeners
if (drawerListeners != null) {
val listenerCount = drawerListeners.size
for (i in listenerCount - 1 downTo 0) {
drawerListeners[i].onDrawerStateChanged(DrawerLayout.STATE_DRAGGING)
}
}
}
private val drawerListeners: List<DrawerListener>?
get() = try {
val listeners = DrawerLayout::class.java.getDeclaredField("mListeners")
listeners.isAccessible = true
listeners[drawerLayout] as List<DrawerListener>
} catch (e: Exception) {
// throw to let developer know the api is changed
throw RuntimeException(e)
}
private fun setDrawerToOffset(gravity: Int, offset: Float) {
val drawerView = findDrawerWithGravity(gravity)
val slideOffsetPercent =
(offset / ObjectsCompat.requireNonNull(drawerView).width).coerceIn(0f, 1f)
try {
val method = DrawerLayout::class.java.getDeclaredMethod(
"moveDrawerToOffset",
View::class.java,
Float::class.javaPrimitiveType,
)
method.isAccessible = true
method.invoke(drawerLayout, drawerView, slideOffsetPercent)
drawerView!!.visibility = VISIBLE
} catch (e: Exception) {
// throw to let developer know the api is changed
throw RuntimeException(e)
}
}
// Copied from DrawerLayout
private fun findDrawerWithGravity(gravity: Int): View? {
val absHorizontalGravity = GravityCompat.getAbsoluteGravity(
gravity,
ViewCompat.getLayoutDirection(drawerLayout!!),
) and Gravity.HORIZONTAL_GRAVITY_MASK
val childCount = drawerLayout!!.childCount
for (i in 0 until childCount) {
val child = drawerLayout!!.getChildAt(i)
val childAbsGravity = getDrawerViewAbsoluteGravity(child)
if (childAbsGravity and Gravity.HORIZONTAL_GRAVITY_MASK == absHorizontalGravity) {
return child
}
}
return null
}
// Copied from DrawerLayout
private fun getDrawerViewAbsoluteGravity(drawerView: View): Int {
val gravity = (drawerView.layoutParams as DrawerLayout.LayoutParams).gravity
return GravityCompat.getAbsoluteGravity(
gravity,
ViewCompat.getLayoutDirection(drawerLayout!!),
)
}
fun addAboveSnackView(view: View) {
if (null == mAboveSnackViewList) {
mAboveSnackViewList = ArrayList()
}
mAboveSnackViewList!!.add(view)
}
fun removeAboveSnackView(view: View) {
if (null == mAboveSnackViewList) {
return
}
mAboveSnackViewList!!.remove(view)
}
val aboveSnackViewCount: Int
get() = if (null == mAboveSnackViewList) 0 else mAboveSnackViewList!!.size
fun getAboveSnackViewAt(index: Int): View? {
return if (null == mAboveSnackViewList || index < 0 || index >= mAboveSnackViewList!!.size) {
null
} else {
mAboveSnackViewList!![index]
}
}
override fun getBehavior(): Behavior {
return Behavior()
}
class Behavior : CoordinatorLayout.Behavior<AboveSnackerLayout>() {
override fun layoutDependsOn(
parent: CoordinatorLayout,
child: AboveSnackerLayout,
dependency: View,
): Boolean {
return dependency is SnackbarLayout
}
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: AboveSnackerLayout,
dependency: View,
): Boolean {
for (i in 0 until child.aboveSnackViewCount) {
val view = child.getAboveSnackViewAt(i)
if (view != null) {
val translationY =
(dependency.translationY - dependency.height - dp2px(view.context, 8f))
.coerceAtMost(0f)
ViewCompat.animate(view).setInterpolator(FastOutSlowInInterpolator())
.translationY(translationY).setDuration(150).start()
}
}
return false
}
override fun onDependentViewRemoved(
parent: CoordinatorLayout,
child: AboveSnackerLayout,
dependency: View,
) {
for (i in 0 until child.aboveSnackViewCount) {
val view = child.getAboveSnackViewAt(i)
if (view != null) {
ViewCompat.animate(view).setInterpolator(FastOutSlowInInterpolator())
.translationY(0f).setDuration(75).start()
}
}
}
}
}
@@ -1,149 +0,0 @@
/*
* Copyright (C) 2015 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.ui.legacy
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import androidx.annotation.Keep
import com.google.android.material.math.MathUtils
import com.hippo.ehviewer.R
import kotlin.math.roundToInt
/**
* @param context used to get the configuration for the drawable from
*/
class AddDeleteDrawable(context: Context, color: Int) : Drawable() {
private val mPaint = Paint()
private val mPath = Path()
private val mSize: Int
private var mProgress = 0f
private var mAutoUpdateMirror = false
private var mVerticalMirror = false
init {
val resources = context.resources
mSize = resources.getDimensionPixelSize(R.dimen.add_size)
val barThickness = resources.getDimension(R.dimen.add_thickness).roundToInt().toFloat()
mPaint.color = color
mPaint.style = Paint.Style.STROKE
mPaint.strokeJoin = Paint.Join.MITER
mPaint.strokeCap = Paint.Cap.BUTT
mPaint.strokeWidth = barThickness
val halfSize = (mSize / 2).toFloat()
mPath.moveTo(0f, -halfSize)
mPath.lineTo(0f, halfSize)
mPath.moveTo(-halfSize, 0f)
mPath.lineTo(halfSize, 0f)
}
override fun draw(canvas: Canvas) {
val bounds = bounds
val canvasRotate = if (mVerticalMirror) {
MathUtils.lerp(270f, 135f, mProgress)
} else {
MathUtils.lerp(0f, 135f, mProgress)
}
canvas.save()
canvas.translate(bounds.centerX().toFloat(), bounds.centerY().toFloat())
canvas.rotate(canvasRotate)
canvas.drawPath(mPath, mPaint)
canvas.restore()
}
fun setColor(color: Int) {
mPaint.color = color
invalidateSelf()
}
override fun setAlpha(alpha: Int) {
mPaint.alpha = alpha
}
override fun setColorFilter(cf: ColorFilter?) {
mPaint.colorFilter = cf
}
override fun getIntrinsicHeight(): Int {
return mSize * 6 / 5
}
override fun getIntrinsicWidth(): Int {
return mSize * 6 / 5
}
/**
* If set, canvas is flipped when progress reached to end and going back to start.
*/
private fun setVerticalMirror(verticalMirror: Boolean) {
mVerticalMirror = verticalMirror
}
fun setAutoUpdateMirror(autoUpdateMirror: Boolean) {
mAutoUpdateMirror = autoUpdateMirror
}
@get:Keep
@set:Keep
var progress: Float
get() = mProgress
set(progress) {
if (mAutoUpdateMirror) {
if (progress == 1f) {
setVerticalMirror(true)
} else if (progress == 0f) {
setVerticalMirror(false)
}
}
mProgress = progress
invalidateSelf()
}
fun setAdd(duration: Long) {
setShape(false, duration)
}
fun setDelete(duration: Long) {
setShape(true, duration)
}
private fun setShape(delete: Boolean, duration: Long) {
if (!(!delete && mProgress == 0f || delete && mProgress == 1f)) {
val endProgress = if (delete) 1f else 0f
if (duration <= 0) {
progress = endProgress
} else {
val oa = ObjectAnimator.ofFloat(this, "progress", endProgress)
oa.duration = duration
oa.setAutoCancel(true)
oa.start()
}
}
}
@Deprecated(
"Deprecated in Java",
ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat"),
)
override fun getOpacity(): Int {
return PixelFormat.TRANSLUCENT
}
}
@@ -1,112 +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.ui.legacy
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import com.hippo.ehviewer.Settings
import kotlin.math.roundToInt
class AutoStaggeredGridLayoutManager(columnSize: Int, orientation: Int) :
StaggeredGridLayoutManager(1, orientation) {
private var mColumnSize = columnSize
private var mColumnSizeChanged = true
private var mStrategy = 0
var supportsPredictiveItemAnimations = true
fun setColumnSize(columnSize: Int) {
if (columnSize == mColumnSize) {
return
}
mColumnSize = columnSize
mColumnSizeChanged = true
}
fun setStrategy(strategy: Int) {
if (strategy == mStrategy) {
return
}
mStrategy = strategy
mColumnSizeChanged = true
}
override fun supportsPredictiveItemAnimations(): Boolean {
return supportsPredictiveItemAnimations && super.supportsPredictiveItemAnimations()
}
override fun onMeasure(
recycler: RecyclerView.Recycler,
state: RecyclerView.State,
widthSpec: Int,
heightSpec: Int,
) {
if (mColumnSizeChanged && mColumnSize > 0) {
val totalSpace = if (orientation == VERTICAL) {
check(
View.MeasureSpec.EXACTLY == View.MeasureSpec.getMode(
widthSpec,
),
) { "RecyclerView need a fixed width for AutoStaggeredGridLayoutManager" }
View.MeasureSpec.getSize(widthSpec) - paddingRight - paddingLeft
} else {
check(
View.MeasureSpec.EXACTLY == View.MeasureSpec.getMode(
heightSpec,
),
) { "RecyclerView need a fixed height for AutoStaggeredGridLayoutManager" }
View.MeasureSpec.getSize(heightSpec) - paddingTop - paddingBottom
}
val spanCount = when (mStrategy) {
STRATEGY_MIN_SIZE -> getSpanCountForMinSize(
totalSpace,
mColumnSize,
)
STRATEGY_SUITABLE_SIZE -> getSpanCountForSuitableSize(
totalSpace,
mColumnSize,
)
else -> getSpanCountForMinSize(
totalSpace,
mColumnSize,
)
}
setSpanCount(spanCount)
mColumnSizeChanged = false
}
super.onMeasure(recycler, state, widthSpec, heightSpec)
}
}
const val STRATEGY_MIN_SIZE = 0
const val STRATEGY_SUITABLE_SIZE = 1
fun getSpanCountForSuitableSize(total: Int, single: Int): Int {
return (total / single.toFloat()).roundToInt().coerceAtLeast(1)
}
@Composable
fun calculateSuitableSpanCount(): Int {
val totalSpace = LocalConfiguration.current.screenWidthDp
return getSpanCountForSuitableSize(totalSpace, Settings.thumbSizeDp)
}
fun getSpanCountForMinSize(total: Int, single: Int): Int {
return (total / single).coerceAtLeast(1)
}
@@ -1,46 +0,0 @@
/*
* Copyright (C) 2015 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.ui.legacy
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.view.View
import android.widget.FrameLayout
class BringOutTransition(contentLayout: FrameLayout, mSearchLayout: SearchLayout) :
ViewTransition(contentLayout, mSearchLayout) {
override fun startAnimations(hiddenView: View, shownView: View) {
mAnimator1 = hiddenView.animate().alpha(0f).scaleY(0.7f).scaleX(0.7f).apply {
setDuration(ANIMATE_TIME).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
hiddenView.visibility = View.GONE
mAnimator1 = null
}
}).start()
}
shownView.alpha = 0f
shownView.scaleX = 0.7f
shownView.scaleY = 0.7f
shownView.visibility = View.VISIBLE
mAnimator2 = shownView.animate().alpha(1f).scaleX(1f).scaleY(1f).apply {
setDuration(ANIMATE_TIME).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
mAnimator2 = null
}
}).start()
}
}
}
@@ -1,44 +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.ui.legacy
import android.content.Context
import android.view.LayoutInflater
import android.widget.CheckBox
import com.hippo.ehviewer.R
class CheckBoxDialogBuilder(
context: Context,
message: String?,
checkText: String?,
checked: Boolean,
) : BaseDialogBuilder(
context,
) {
private val mCheckBox: CheckBox
val isChecked: Boolean
get() = mCheckBox.isChecked
init {
val view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_checkbox_builder, null)
setView(view)
setMessage(message)
mCheckBox = view.findViewById(R.id.checkbox)
mCheckBox.text = checkText
mCheckBox.isChecked = checked
view.setOnClickListener { mCheckBox.toggle() }
}
}
@@ -1,53 +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.ui.legacy
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
class DrawerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs) {
private var mMaxWidth = 0
init {
val a = context.obtainStyledAttributes(attrs, SIZE_ATTRS)
mMaxWidth = a.getDimensionPixelOffset(0, 0)
a.recycle()
}
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
var widthSpec = widthSpec
when (MeasureSpec.getMode(widthSpec)) {
MeasureSpec.EXACTLY -> {}
MeasureSpec.AT_MOST -> widthSpec = MeasureSpec.makeMeasureSpec(
MeasureSpec.getSize(widthSpec).coerceAtMost(mMaxWidth),
MeasureSpec.EXACTLY,
)
MeasureSpec.UNSPECIFIED ->
widthSpec = MeasureSpec.makeMeasureSpec(mMaxWidth, MeasureSpec.EXACTLY)
}
// Let super sort out the height
super.onMeasure(widthSpec, heightSpec)
}
companion object {
private val SIZE_ATTRS = intArrayOf(android.R.attr.maxWidth)
}
}
@@ -1,72 +0,0 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.ui.legacy
import android.content.Context
import android.content.DialogInterface
import android.view.KeyEvent
import android.view.LayoutInflater
import android.widget.EditText
import android.widget.TextView
import android.widget.TextView.OnEditorActionListener
import androidx.appcompat.app.AlertDialog
import com.google.android.material.textfield.TextInputLayout
import com.hippo.ehviewer.R
class EditTextDialogBuilder(
context: Context,
text: String?,
hint: String?,
) : BaseDialogBuilder(
context,
),
OnEditorActionListener {
private val mTextInputLayout: TextInputLayout
val editText: EditText
private var mDialog: AlertDialog? = null
val text: String
get() = editText.text.toString()
fun setError(error: CharSequence?) {
mTextInputLayout.error = error
}
override fun create(): AlertDialog {
mDialog = super.create()
return mDialog as AlertDialog
}
override fun onEditorAction(v: TextView?, p1: Int, event: KeyEvent?): Boolean {
return if (mDialog != null) {
val button = mDialog!!.getButton(DialogInterface.BUTTON_POSITIVE)
button?.performClick()
true
} else {
false
}
}
init {
val view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_edittext_builder, null)
setView(view)
mTextInputLayout = view as TextInputLayout
editText = view.findViewById(R.id.edit_text)
editText.setText(text)
editText.setSelection(editText.text.length)
editText.setOnEditorActionListener(this)
mTextInputLayout.hint = hint
}
}
@@ -1,432 +0,0 @@
/*
* Copyright (C) 2015 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.ui.legacy
import android.animation.Animator
import android.content.Context
import android.os.Bundle
import android.os.Parcelable
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.animation.Interpolator
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.hippo.ehviewer.R
import com.hippo.ehviewer.util.AnimationUtils
import com.hippo.ehviewer.util.SimpleAnimatorListener
import com.hippo.ehviewer.util.getParcelableCompat
class FabLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : ViewGroup(context, attrs, defStyleAttr), View.OnClickListener {
private var mFabSize = 0
private var mFabMiniSize = 0
private var mIntervalPrimary = 0
private var mIntervalSecondary = 0
private var mExpanded = true
private var mAutoCancel = true
private var mHidePrimaryFab = false
private var mMainFabCenterY = -1f
private var mOnExpandListeners = arrayListOf<OnExpandListener>()
private var mOnClickFabListener: OnClickFabListener? = null
private val mOnBackPressedCallback = object : OnBackPressedCallback(false), OnExpandListener {
override fun handleOnBackPressed() {
isExpanded = false
}
override fun onExpand(expanded: Boolean) {
isEnabled = expanded
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
(context as ComponentActivity).onBackPressedDispatcher.addCallback(mOnBackPressedCallback)
mOnExpandListeners.add(mOnBackPressedCallback)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
mOnBackPressedCallback.remove()
mOnExpandListeners.remove(mOnBackPressedCallback)
}
init {
isSoundEffectsEnabled = false
clipToPadding = false
mFabSize = context.resources.getDimensionPixelOffset(R.dimen.fab_size)
mFabMiniSize = context.resources.getDimensionPixelOffset(R.dimen.fab_min_size)
mIntervalPrimary =
context.resources.getDimensionPixelOffset(R.dimen.fab_layout_primary_margin)
mIntervalSecondary =
context.resources.getDimensionPixelOffset(R.dimen.fab_layout_secondary_margin)
}
override fun addView(child: View, index: Int, params: LayoutParams) {
check(child is FloatingActionButton) { "FloatingActionBarLayout should only " + "contain FloatingActionButton, but try to add " + child.javaClass.name }
super.addView(child, index, params)
}
val primaryFab: FloatingActionButton?
get() = getChildAt(childCount - 1) as? FloatingActionButton
private val secondaryFabCount: Int
get() = 0.coerceAtLeast(childCount - 1)
private fun getSecondaryFabAt(index: Int): FloatingActionButton? {
return if (index < 0 || index >= secondaryFabCount) {
null
} else {
getChildAt(index) as FloatingActionButton
}
}
fun setSecondaryFabVisibilityAt(index: Int, visible: Boolean) {
getSecondaryFabAt(index)?.run {
if (visible && visibility == GONE) {
animate().cancel()
visibility = if (mExpanded) VISIBLE else INVISIBLE
} else if (!visible && visibility != GONE) {
animate().cancel()
visibility = GONE
}
}
}
private fun getChildMeasureSpec(parentMeasureSpec: Int): Int {
val parentMode = MeasureSpec.getMode(parentMeasureSpec)
val parentSize = MeasureSpec.getSize(parentMeasureSpec)
val childMode: Int
val childSize: Int
when (parentMode) {
MeasureSpec.AT_MOST, MeasureSpec.EXACTLY -> {
childMode = MeasureSpec.AT_MOST
childSize = parentSize
}
MeasureSpec.UNSPECIFIED -> {
childMode = MeasureSpec.UNSPECIFIED
childSize = parentSize
}
else -> {
childMode = MeasureSpec.AT_MOST
childSize = parentSize
}
}
return MeasureSpec.makeMeasureSpec(childSize, childMode)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec)
val childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec)
measureChildren(childWidthMeasureSpec, childHeightMeasureSpec)
var maxWidth = 0
var maxHeight = 0
val count = childCount
for (i in 0 until count) {
val child = getChildAt(i)
if (child.visibility == GONE) {
continue
}
maxWidth = maxWidth.coerceAtLeast(child.measuredWidth)
maxHeight += child.measuredHeight
}
maxWidth += paddingLeft + paddingRight
maxHeight += paddingTop + paddingBottom
maxHeight = maxHeight.coerceAtLeast(suggestedMinimumHeight)
maxWidth = maxWidth.coerceAtLeast(suggestedMinimumWidth)
setMeasuredDimension(
resolveSizeAndState(maxWidth, widthMeasureSpec, 0),
resolveSizeAndState(maxHeight, heightMeasureSpec, 0),
)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var centerX = 0
var bottom = measuredHeight - paddingBottom
val count = childCount
var i = count
while (--i >= 0) {
val child = getChildAt(i)
if (child.visibility == GONE) {
continue
}
val childWidth = child.measuredWidth
val childHeight = child.measuredHeight
var layoutBottom: Int
var layoutRight: Int
if (i == count - 1) {
layoutBottom = bottom + (childHeight - mFabSize) / 2
layoutRight = measuredWidth - paddingRight + (childWidth - mFabSize) / 2
bottom -= mFabSize + mIntervalPrimary
centerX = layoutRight - childWidth / 2
mMainFabCenterY = layoutBottom - childHeight / 2f
} else {
layoutBottom = bottom + (childHeight - mFabMiniSize) / 2
layoutRight = centerX + childWidth / 2
bottom -= mFabMiniSize + mIntervalSecondary
}
child.layout(
layoutRight - childWidth,
layoutBottom - childHeight,
layoutRight,
layoutBottom,
)
}
}
fun addOnExpandListener(listener: OnExpandListener) {
mOnExpandListeners.add(listener)
}
fun setOnClickFabListener(listener: OnClickFabListener?) {
mOnClickFabListener = listener
if (listener != null) {
var i = 0
val n = childCount
while (i < n) {
getChildAt(i).setOnClickListener(this)
i++
}
} else {
var i = 0
val n = childCount
while (i < n) {
getChildAt(i).isClickable = false
i++
}
}
}
fun setHidePrimaryFab(hidePrimaryFab: Boolean) {
if (mHidePrimaryFab != hidePrimaryFab) {
mHidePrimaryFab = hidePrimaryFab
val expanded = mExpanded
val count = childCount
if (!expanded && count > 0) {
getChildAt(count - 1).visibility = if (hidePrimaryFab) INVISIBLE else VISIBLE
}
}
}
fun setAutoCancel(autoCancel: Boolean) {
if (mAutoCancel != autoCancel) {
mAutoCancel = autoCancel
if (mExpanded) {
if (autoCancel) {
setOnClickListener(this)
} else {
isClickable = false
}
}
}
}
fun toggle() {
isExpanded = !mExpanded
}
var isExpanded: Boolean
get() = mExpanded
set(expanded) {
setExpanded(expanded, true)
}
fun setExpanded(expanded: Boolean, animation: Boolean) {
if (mExpanded != expanded) {
mExpanded = expanded
if (mAutoCancel) {
if (expanded) {
setOnClickListener(this)
} else {
isClickable = false
}
}
val count = childCount
if (count > 0) {
if (mMainFabCenterY == -1f || !animation) {
// It is before first onLayout
val checkCount = if (mHidePrimaryFab) count else count - 1
for (i in 0 until checkCount) {
val child = getChildAt(i)
if (child.visibility == GONE) {
continue
}
child.visibility = if (expanded) VISIBLE else INVISIBLE
if (expanded) {
child.alpha = 1f
}
}
} else {
if (mHidePrimaryFab) {
setPrimaryFabAnimation(getChildAt(count - 1), expanded, !expanded)
}
for (i in 0 until count - 1) {
val child = getChildAt(i)
if (child.visibility == GONE) {
continue
}
setSecondaryFabAnimation(child, expanded, expanded)
}
}
}
mOnExpandListeners.forEach { it.onExpand(expanded) }
}
}
private fun setPrimaryFabAnimation(child: View, expanded: Boolean, delay: Boolean) {
val startRotation: Float
val endRotation: Float
val startScale: Float
val endScale: Float
val interpolator: Interpolator
if (expanded) {
startRotation = -45.0f
endRotation = 0.0f
startScale = 0.0f
endScale = 1.0f
interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR
} else {
startRotation = 0.0f
endRotation = 0.0f
startScale = 1.0f
endScale = 0.0f
interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR
}
child.scaleX = startScale
child.scaleY = startScale
child.rotation = startRotation
child.animate()
.scaleX(endScale)
.scaleY(endScale)
.rotation(endRotation)
.setStartDelay(if (delay) ANIMATE_TIME else 0L)
.setDuration(ANIMATE_TIME)
.setInterpolator(interpolator)
.setListener(object : SimpleAnimatorListener() {
override fun onAnimationStart(animation: Animator) {
if (expanded) {
child.visibility = VISIBLE
}
}
override fun onAnimationEnd(animation: Animator) {
if (!expanded) {
child.visibility = INVISIBLE
}
}
}).start()
}
private fun setSecondaryFabAnimation(child: View, expanded: Boolean, delay: Boolean) {
val startTranslationY: Float
val endTranslationY: Float
val startAlpha: Float
val endAlpha: Float
val interpolator: Interpolator
if (expanded) {
startTranslationY = mMainFabCenterY - child.top - child.height / 2
endTranslationY = 0f
startAlpha = 0f
endAlpha = 1f
interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR
} else {
startTranslationY = 0f
endTranslationY = mMainFabCenterY - child.top - child.height / 2
startAlpha = 1f
endAlpha = 0f
interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR
}
child.alpha = startAlpha
child.translationY = startTranslationY
child.animate()
.alpha(endAlpha)
.translationY(endTranslationY)
.setStartDelay(if (delay) ANIMATE_TIME else 0L)
.setDuration(ANIMATE_TIME)
.setInterpolator(interpolator)
.setListener(object : SimpleAnimatorListener() {
override fun onAnimationStart(animation: Animator) {
if (expanded) {
child.visibility = VISIBLE
}
}
override fun onAnimationEnd(animation: Animator) {
if (!expanded) {
child.visibility = INVISIBLE
}
}
}).start()
}
override fun onClick(v: View) {
if (this === v) {
isExpanded = false
} else {
mOnClickFabListener?.let {
val position = indexOfChild(v)
if (position == childCount - 1) {
it.onClickPrimaryFab(this, v as FloatingActionButton)
} else if (position >= 0 && mExpanded) {
it.onClickSecondaryFab(this, v as FloatingActionButton, position)
}
}
}
}
override fun dispatchSetPressed(pressed: Boolean) {
// Don't dispatch it to children
}
override fun onSaveInstanceState(): Parcelable {
val state = Bundle()
state.putParcelable(STATE_KEY_SUPER, super.onSaveInstanceState())
state.putBoolean(STATE_KEY_AUTO_CANCEL, mAutoCancel)
state.putBoolean(STATE_KEY_EXPANDED, mExpanded)
return state
}
override fun onRestoreInstanceState(state: Parcelable) {
if (state is Bundle) {
super.onRestoreInstanceState(state.getParcelableCompat(STATE_KEY_SUPER))
setAutoCancel(state.getBoolean(STATE_KEY_AUTO_CANCEL))
setExpanded(state.getBoolean(STATE_KEY_EXPANDED), false)
}
}
fun interface OnExpandListener {
fun onExpand(expanded: Boolean)
}
interface OnClickFabListener {
fun onClickPrimaryFab(view: FabLayout, fab: FloatingActionButton)
fun onClickSecondaryFab(view: FabLayout, fab: FloatingActionButton, position: Int)
}
companion object {
private const val ANIMATE_TIME = 300L
private const val STATE_KEY_SUPER = "super"
private const val STATE_KEY_AUTO_CANCEL = "auto_cancel"
private const val STATE_KEY_EXPANDED = "expanded"
}
}
@@ -1,336 +0,0 @@
/*
* Copyright 2015 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.ui.legacy
import android.animation.Animator
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
import com.google.android.material.math.MathUtils.dist
import com.hippo.ehviewer.R
import com.hippo.ehviewer.util.AnimationUtils
import com.hippo.ehviewer.util.SimpleAnimatorListener
import com.hippo.ehviewer.util.SimpleHandler
import com.hippo.ehviewer.util.dp2px
import kotlin.math.abs
class FastScroller : View {
private var mDraggable = false
private var mMinHandlerHeight = 0
private var mRecyclerView: RecyclerView? = null
private var mOnScrollChangeListener: RecyclerView.OnScrollListener? = null
private var mAdapter: RecyclerView.Adapter<*>? = null
private var mAdapterDataObserver: AdapterDataObserver? = null
private var mHandler: Drawable? = null
private var mHandlerOffset = INVALID
private var mHandlerHeight = INVALID
private var mDownX = INVALID.toFloat()
private var mDownY = INVALID.toFloat()
private var mLastMotionY = INVALID.toFloat()
private var mDragged = false
private var mCantDrag = false
private var mTouchSlop = 0
private var mListener: OnDragHandlerListener? = null
private var mShowAnimator: ObjectAnimator? = null
private var mHideAnimator: ObjectAnimator? = null
private val mHideRunnable = Runnable { mHideAnimator!!.start() }
constructor(context: Context) : super(context) {
init(context, null, 0)
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
init(context, attrs, 0)
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr,
) {
init(context, attrs, defStyleAttr)
}
private fun init(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
val a = context.obtainStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr, 0)
mHandler = a.getDrawable(R.styleable.FastScroller_handler)
mDraggable = a.getBoolean(R.styleable.FastScroller_draggable, true)
a.recycle()
alpha = 0.0f
visibility = INVISIBLE
mMinHandlerHeight = dp2px(context, MIN_HANDLER_HEIGHT_DP.toFloat())
mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
mShowAnimator = ObjectAnimator.ofFloat(this, "alpha", 1.0f).apply {
interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR
duration = SCROLL_BAR_FADE_DURATION.toLong()
}
mHideAnimator = ObjectAnimator.ofFloat(this, "alpha", 0.0f).apply {
interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR
duration = SCROLL_BAR_FADE_DURATION.toLong()
addListener(object : SimpleAnimatorListener() {
private var mCancel = false
override fun onAnimationCancel(animation: Animator) {
mCancel = true
}
override fun onAnimationEnd(animation: Animator) {
if (mCancel) {
mCancel = false
} else {
visibility = INVISIBLE
}
}
})
}
}
fun setOnDragHandlerListener(listener: OnDragHandlerListener?) {
mListener = listener
}
private fun updatePosition(show: Boolean) {
if (mRecyclerView == null) {
return
}
val paddingTop = paddingTop
val paddingBottom = paddingBottom
val height = height - paddingTop - paddingBottom
val offset = mRecyclerView!!.computeVerticalScrollOffset()
val extent = mRecyclerView!!.computeVerticalScrollExtent()
val range = mRecyclerView!!.computeVerticalScrollRange()
if (height <= 0 || extent >= range || extent <= 0) {
return
}
var endOffset = height * offset / range
var endHeight = height * extent / range
endHeight = endHeight.coerceAtLeast(mMinHandlerHeight)
endOffset = endOffset.coerceAtMost(height - endHeight)
mHandlerOffset = endOffset + paddingTop
mHandlerHeight = endHeight
if (show) {
if (mHideAnimator!!.isRunning) {
mHideAnimator!!.cancel()
mShowAnimator!!.start()
} else if (visibility != VISIBLE && !mShowAnimator!!.isRunning) {
visibility = VISIBLE
mShowAnimator!!.start()
}
SimpleHandler.removeCallbacks(mHideRunnable)
if (!mDragged) {
SimpleHandler.postDelayed(mHideRunnable, SCROLL_BAR_DELAY.toLong())
}
}
}
fun setHandlerDrawable(drawable: Drawable?) {
mHandler = drawable
invalidate()
}
var isDraggable: Boolean
get() = mDraggable
set(draggable) {
mDraggable = draggable
if (mDragged) {
mDragged = false
}
SimpleHandler.removeCallbacks(mHideRunnable)
mHideRunnable.run()
}
val isAttached: Boolean
get() = mRecyclerView != null
fun attachToRecyclerView(recyclerView: RecyclerView?) {
if (recyclerView == null) {
return
}
check(mRecyclerView == null) {
"The FastScroller is already attached to a RecyclerView, " +
"call detachedFromRecyclerView first"
}
mRecyclerView = recyclerView
mOnScrollChangeListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
updatePosition(true)
invalidate()
}
}
recyclerView.addOnScrollListener(mOnScrollChangeListener!!)
mAdapter = recyclerView.adapter
if (mAdapter != null) {
mAdapterDataObserver = object : AdapterDataObserver() {
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
super.onItemRangeChanged(positionStart, itemCount)
updatePosition(false)
invalidate()
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
super.onItemRangeChanged(positionStart, itemCount, payload)
updatePosition(false)
invalidate()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
updatePosition(false)
invalidate()
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
super.onItemRangeRemoved(positionStart, itemCount)
updatePosition(false)
invalidate()
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
super.onItemRangeMoved(fromPosition, toPosition, itemCount)
updatePosition(false)
invalidate()
}
}
mAdapter!!.registerAdapterDataObserver(mAdapterDataObserver!!)
}
}
fun detachedFromRecyclerView() {
if (mRecyclerView != null && mOnScrollChangeListener != null) {
mRecyclerView!!.removeOnScrollListener(mOnScrollChangeListener!!)
}
mRecyclerView = null
mOnScrollChangeListener = null
if (mAdapter != null && mAdapterDataObserver != null) {
mAdapter!!.unregisterAdapterDataObserver(mAdapterDataObserver!!)
}
mAdapter = null
mAdapterDataObserver = null
alpha = 0.0f
visibility = INVISIBLE
}
override fun onDraw(canvas: Canvas) {
if (mRecyclerView == null || mHandler == null) {
return
}
if (mHandlerHeight == INVALID) {
updatePosition(false)
}
if (mHandlerHeight == INVALID) {
return
}
val paddingLeft = paddingLeft
val saved = canvas.save()
canvas.translate(paddingLeft.toFloat(), mHandlerOffset.toFloat())
mHandler!!.setBounds(0, 0, width - paddingLeft - paddingRight, mHandlerHeight)
mHandler!!.draw(canvas)
canvas.restoreToCount(saved)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (!mDraggable || visibility != VISIBLE || mRecyclerView == null || mHandlerHeight == INVALID) {
return false
}
val action = event.action
if (action == MotionEvent.ACTION_DOWN) {
mCantDrag = false
}
if (mCantDrag) {
return false
}
when (action) {
MotionEvent.ACTION_DOWN -> {
mDragged = false
mDownX = event.x
mDownY = event.y
if (mDownY < mHandlerOffset || mDownY > mHandlerOffset + mHandlerHeight) {
mCantDrag = true
return false
}
}
MotionEvent.ACTION_MOVE -> {
if (!mDragged) {
val x = event.x
val y = event.y
// Check touch slop
if (dist(x, y, mDownX, mDownY) < mTouchSlop) {
return true
}
if (abs(x - mDownX) > abs(y - mDownY) || y < mHandlerOffset || y > mHandlerOffset + mHandlerHeight) {
mCantDrag = true
return false
} else {
mDragged = true
SimpleHandler.removeCallbacks(mHideRunnable)
// Update mLastMotionY
mLastMotionY =
if (mDownY < mHandlerOffset || mDownY >= mHandlerOffset + mHandlerHeight) {
// the point out of handler, make the point in handler center
(mHandlerOffset + mHandlerHeight / 2).toFloat()
} else {
mDownY
}
// Notify
if (mListener != null) {
mListener!!.onStartDragHandler()
}
}
}
val range = mRecyclerView!!.computeVerticalScrollRange()
if (range <= 0) {
return true
}
val y = event.y
val scroll =
(range * (y - mLastMotionY) / (height - paddingTop - paddingBottom)).toInt()
mRecyclerView!!.scrollBy(0, scroll)
mLastMotionY = y
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
// Notify
if (mDragged && mListener != null) {
mListener!!.onEndDragHandler()
}
mDragged = false
SimpleHandler.postDelayed(mHideRunnable, SCROLL_BAR_DELAY.toLong())
}
}
return true
}
interface OnDragHandlerListener {
fun onStartDragHandler()
fun onEndDragHandler()
}
companion object {
private const val INVALID = -1
private const val SCROLL_BAR_FADE_DURATION = 500
private const val SCROLL_BAR_DELAY = 1000
private const val MIN_HANDLER_HEIGHT_DP = 32
}
}
@@ -1,82 +0,0 @@
/*
* Copyright 2015 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.ui.legacy
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.RectF
import android.graphics.drawable.Drawable
class HandlerDrawable : Drawable() {
private val mPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val mTemp = RectF()
private var mColor = Color.BLACK
init {
mPaint.color = mColor
mPaint.style = Paint.Style.FILL
}
override fun draw(canvas: Canvas) {
val width = bounds.width()
val height = bounds.height()
if (width > height) {
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), mPaint)
} else {
mTemp[0f, 0f, width.toFloat()] = width.toFloat()
canvas.drawArc(mTemp, -180f, 180f, true, mPaint)
mTemp[0f, (height - width).toFloat(), width.toFloat()] = height.toFloat()
canvas.drawArc(mTemp, 0f, 180f, true, mPaint)
val halfWidth = width.toFloat() / 2.0f
canvas.drawRect(0f, halfWidth, width.toFloat(), height - halfWidth, mPaint)
}
}
fun setColor(color: Int) {
if (mColor != color) {
mColor = color
mPaint.color = color
}
}
override fun setAlpha(alpha: Int) {
mPaint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
mPaint.colorFilter = colorFilter
}
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int {
return when (Color.alpha(mColor)) {
0xff -> {
PixelFormat.OPAQUE
}
0x00 -> {
PixelFormat.TRANSPARENT
}
else -> {
PixelFormat.TRANSLUCENT
}
}
}
}
@@ -1,81 +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.ui.legacy
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.util.AttributeSet
import android.widget.ListView
import com.hippo.ehviewer.R
@SuppressLint("CustomViewStyleable")
class IndicatingListView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : ListView(context, attrs) {
private val mPaint = Paint()
private val mTemp = Rect()
private var mIndicatorHeight = 0
init {
val a = context.obtainStyledAttributes(attrs, R.styleable.Indicating)
mIndicatorHeight = a.getDimensionPixelOffset(R.styleable.Indicating_indicatorHeight, 1)
mPaint.color = a.getColor(
R.styleable.Indicating_indicatorColor,
Color.BLACK,
)
mPaint.style = Paint.Style.FILL
a.recycle()
}
private fun fillTopIndicatorDrawRect() {
mTemp[0, 0, width] = mIndicatorHeight
}
private fun fillBottomIndicatorDrawRect() {
mTemp[0, height - mIndicatorHeight, width] = height
}
private fun needShowTopIndicator(): Boolean {
return canScrollVertically(-1)
}
private fun needShowBottomIndicator(): Boolean {
return canScrollVertically(1)
}
override fun draw(canvas: Canvas) {
super.draw(canvas)
val restoreCount = canvas.save()
canvas.translate(scrollX.toFloat(), scrollY.toFloat())
// Draw top indicator
if (needShowTopIndicator()) {
fillTopIndicatorDrawRect()
canvas.drawRect(mTemp, mPaint)
}
// Draw bottom indicator
if (needShowBottomIndicator()) {
fillBottomIndicatorDrawRect()
canvas.drawRect(mTemp, mPaint)
}
canvas.restoreToCount(restoreCount)
}
}
@@ -1,153 +0,0 @@
/*
* Copyright 2015 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.ui.legacy
import android.content.Context
import android.graphics.PointF
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView.LayoutManager
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import com.google.android.material.math.MathUtils
import com.hippo.ehviewer.util.SimpleHandler
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import kotlin.math.abs
object LayoutManagerUtils {
private var sCsdfp: Method? = null
init {
try {
sCsdfp = StaggeredGridLayoutManager::class.java.getDeclaredMethod(
"calculateScrollDirectionForPosition",
Int::class.javaPrimitiveType,
).apply { isAccessible = true }
} catch (e: NoSuchMethodException) {
// Ignore
e.printStackTrace()
}
}
fun scrollToPositionWithOffset(
layoutManager: LayoutManager,
position: Int,
offset: Int,
) {
when (layoutManager) {
is LinearLayoutManager -> layoutManager.scrollToPositionWithOffset(position, offset)
is StaggeredGridLayoutManager ->
layoutManager.scrollToPositionWithOffset(position, offset)
else -> throw IllegalStateException("Can't do scrollToPositionWithOffset for " + layoutManager.javaClass.name)
}
}
fun smoothScrollToPosition(
layoutManager: LayoutManager,
context: Context,
position: Int,
millisecondsPerInch: Int = -1,
) {
val smoothScroller: SimpleSmoothScroller
when (layoutManager) {
is LinearLayoutManager -> {
smoothScroller =
object : SimpleSmoothScroller(context, millisecondsPerInch.toFloat()) {
override fun computeScrollVectorForPosition(targetPosition: Int): PointF? {
return layoutManager.computeScrollVectorForPosition(targetPosition)
}
}
}
is StaggeredGridLayoutManager -> {
smoothScroller =
object : SimpleSmoothScroller(context, millisecondsPerInch.toFloat()) {
override fun computeScrollVectorForPosition(targetPosition: Int): PointF? {
var direction = 0
try {
direction = sCsdfp!!.invoke(layoutManager, targetPosition) as Int
} catch (e: IllegalAccessException) {
e.printStackTrace()
} catch (e: InvocationTargetException) {
e.printStackTrace()
}
if (direction == 0) {
return null
}
return if (layoutManager.orientation == StaggeredGridLayoutManager.HORIZONTAL) {
PointF(direction.toFloat(), 0f)
} else {
PointF(0f, direction.toFloat())
}
}
}
}
else -> throw IllegalStateException("Can't do smoothScrollToPosition for " + layoutManager.javaClass.name)
}
smoothScroller.targetPosition = position
layoutManager.startSmoothScroll(smoothScroller)
}
fun scrollToPositionProperly(
layoutManager: LayoutManager,
context: Context,
position: Int,
listener: OnScrollToPositionListener?,
) {
SimpleHandler.postDelayed({
val first = layoutManager.firstVisibleItemPosition
val last = getLastVisibleItemPosition(layoutManager)
val offset = abs(position - first)
val max = last - first
if (offset < max && max > 0) {
smoothScrollToPosition(
layoutManager,
context,
position,
MathUtils.lerp(100F, 25F, (offset / max).toFloat()).toInt(),
)
} else {
scrollToPositionWithOffset(layoutManager, position, 0)
listener?.onScrollToPosition(position)
}
}, 200)
}
val LayoutManager.firstVisibleItemPosition: Int
get() = when (this) {
is LinearLayoutManager -> findFirstVisibleItemPosition()
is StaggeredGridLayoutManager -> findFirstVisibleItemPositions(null).min()
else -> error("Can't do getFirstVisibleItemPosition for " + javaClass.name)
}
fun getLastVisibleItemPosition(layoutManager: LayoutManager): Int {
return when (layoutManager) {
is LinearLayoutManager -> layoutManager.findLastVisibleItemPosition()
is StaggeredGridLayoutManager -> {
val positions = layoutManager.findLastVisibleItemPositions(null)
positions.max()
}
else -> throw IllegalStateException("Can't do getLastVisibleItemPosition for " + layoutManager.javaClass.name)
}
}
fun interface OnScrollToPositionListener {
fun onScrollToPosition(position: Int)
}
}
@@ -15,7 +15,6 @@
*/
package com.hippo.ehviewer.ui.legacy
import android.annotation.SuppressLint
import android.content.Context
import android.text.Spanned
import android.text.style.ClickableSpan
@@ -26,15 +25,8 @@ import androidx.appcompat.widget.AppCompatTextView
class LinkifyTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
private val onCurrentSpanUpdate: (ClickableSpan?) -> Unit,
) : AppCompatTextView(context, attrs) {
var currentSpan: ClickableSpan? = null
private set
fun clearCurrentSpan() {
currentSpan = null
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
// Let the parent or grandparent of TextView to handles click aciton.
// Otherwise click effect like ripple will not work, and if touch area
@@ -42,10 +34,8 @@ class LinkifyTextView @JvmOverloads constructor(
// onTouchEven must be called with MotionEvent.ACTION_DOWN for each touch
// action on it, so we analyze touched url here.
if (event.action == MotionEvent.ACTION_DOWN) {
currentSpan = null
onCurrentSpanUpdate(null)
if (text is Spanned) {
// Get this code from android.text.method.LinkMovementMethod.
// Work fine !
var x = event.x.toInt()
var y = event.y.toInt()
x -= totalPaddingLeft
@@ -58,7 +48,7 @@ class LinkifyTextView @JvmOverloads constructor(
val off = layout.getOffsetForHorizontal(line, x.toFloat())
val spans = (text as Spanned).getSpans(off, off, ClickableSpan::class.java)
if (spans.isNotEmpty()) {
currentSpan = spans[0]
onCurrentSpanUpdate(spans[0])
}
}
}
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff. Show more