Compare commits

..
465 Commits
Author SHA1 Message Date
FooIbar 6cd7b85b94 Prepare for release 1.11.0
Releases / Build (push) Has been cancelled
2024-03-09 19:16:25 +08:00
FooIbar df97a9c4b9 Prepare changelog for 1.11.0 (#881)
no ci
2024-03-09 19:10:37 +08:00
FooIbar 5cb91e9b45 Update liblzma to v5.6.1 2024-03-09 19:03:37 +08:00
FooIbar 94794cb0db Address code review comments 2024-03-09 17:16:42 +08:00
FooIbar a16c021000 Fix temporary download directory cleanup 2024-03-09 16:22:57 +08:00
FooIbar 49d4ae078f Clarify message for no current torrents 2024-03-09 14:52:12 +08:00
FooIbar eb45068df3 Increase bringCursorIntoView workaround delay 2024-03-09 14:19:00 +08:00
renovate[bot] 1aa750d676 Update softprops/action-gh-release action to v2 2024-03-08 21:35:28 +00:00
FooIbar af85a68e3d Fix duplicate subscription on page state 2024-03-08 15:45:54 +08:00
FooIbar a8fc4003a3 Workaround for crash on delete text button click 2024-03-08 09:24:10 +08:00
renovate[bot] 675df44745 Update dependency com.google.devtools.ksp to v2.0.0-Beta4-1.0.19 2024-03-08 00:33:53 +00:00
renovate[bot] 7e271d7e8e Update dependency com.android.application to v8.4.0-alpha13 2024-03-07 22:07:29 +00:00
FooIbar acb2c720af Fix torrent parsing 2024-03-07 23:06:22 +08:00
GG Bond 5d0c2e3f05 Animated dim with SettingsPager 2024-03-07 22:48:33 +08:00
GG Bond 3d5e3620dd Restore dep behaviour in ReaderModeSetting 2024-03-07 22:48:33 +08:00
GG Bond 43aefeedae Restore dep behaviour of fullscreen & cutoutShort 2024-03-07 22:48:33 +08:00
GG Bond c2cbcc009d No more credits to MDC-Android 2024-03-07 21:08:55 +08:00
GG Bond 2aa494d9b7 Get rid of MDC-Android 2024-03-07 21:08:55 +08:00
GG Bond 8f3a11f374 Calculate Reader statusBarColor with Compose 2024-03-07 21:08:55 +08:00
GG Bond 76298f15e4 Rewrite harmonizeWithRole with ColorUtils 2024-03-07 21:08:55 +08:00
GG Bond 58839e0def Fix Error Text Color 2024-03-07 21:08:55 +08:00
GG Bond 6fe62db5e4 Remove ReaderButton, prepare to drop MDC 2024-03-07 21:08:55 +08:00
renovate[bot]andGG Bond 9b9f25d405 Move to Compose Reader Settings Now (#871)
* Move to Compose Reader Settings Now

* Cleanup res

* Cleanup

* Fix trans

* Fix

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-03-07 17:43:41 +08:00
renovate[bot]andGG Bond e47d7378ec Add remaining settings layout (#870)
* Implement ReadModeSetting

* Fix textstyle

* Fix pref color

* Fix

* Refine

* Refine style

* Remaining Pref

* Inline

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-03-07 08:49:13 +00:00
renovate[bot]andGG Bond 51b385408d Implement remaining of ColorFilterSetting (#867)
Co-authored-by: GG Bond <whatever@tagkong.com>
2024-03-07 13:14:58 +08:00
renovate[bot] 8e7cced8d2 Update dependencies 2024-03-07 13:12:18 +08:00
renovate[bot] affeb3e106 Update dependency androidx.browser:browser to v1.8.0 2024-03-06 21:05:15 +00:00
FooIbar a2377857e9 Fix page state update on cache entry removed 2024-03-06 22:53:41 +08:00
FooIbar 7714ff8938 Make sure slider value has single source of truth 2024-03-06 21:55:43 +08:00
FooIbar 352853b88c Implement VolumeKeysHandler 2024-03-06 20:44:49 +08:00
5idereal 23d282e354 Update strings.xml 2024-03-06 19:46:00 +08:00
FooIbar b93c12de89 Fix navigating on non-main thread 2024-03-06 16:30:37 +08:00
FooIbar e0e5a155aa Initial support for local tag search
BREAKING CHANGE: Upgrade database
2024-03-06 16:29:37 +08:00
renovate[bot]andGG Bond e1f34a7b1b Implement ColorFilterSetting (#859)
* Implement ColorFilterSetting

* Initial ColorFilterSetting

* Cleanup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-03-05 17:20:09 +00:00
renovate[bot] 298e80eb9d Update compose.destinations to v1.10.2 2024-03-05 16:28:14 +00:00
renovate[bot]andGG Bond 3ecfd83f1e Remaining GeneralSettings Layout (#857)
* PlaceHolder

* Implement

* ConfigureKeepScreenOn

* Implement

* Remaining general layout

* Remove zIndex

* Add ReaderContentOverlay

* Stub for ColorFilterSetting

* Implement ColorFilterSetting

* Introduce SliderChoice

* Refine naming

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-03-05 16:19:21 +00:00
renovate[bot]andGG Bond 1dcf4e7f46 Implement some reader settings (#856)
* Initial SpinnerChoice

* Apply bgColor

* Introduce SwitchChoice

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-03-05 18:32:36 +08:00
FooIbar f1a856cb45 Don't fetch metadata if source is GalleryDetail 2024-03-05 18:32:25 +08:00
renovate[bot]andGG Bond af32d7eea7 Initial Setting Pager (#854)
* Update Compose Snapshot

* Initial SettingsPager

* Cleanup

* Fix inactive color

* Sync container color with BSheet

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-03-05 13:32:48 +08:00
FooIbar 47be2754e5 Refactor tag namespace to use value class 2024-03-05 10:45:51 +08:00
FooIbar 39b16b6650 Fix webtoon last visible item position calculation 2024-03-04 22:13:07 +08:00
FooIbar 0a809a564c Fix general setting update on reading mode changes
Another bug introduced in DataStore migration.
2024-03-04 19:49:32 +08:00
FooIbar 07e24013c8 Workaround for prepending before the first item 2024-03-04 19:48:55 +08:00
FooIbar 8e8394985d Don't crop borders for webtoons
Webtoons are typically split across multiple pages, they require
consistent width to maintain alignment.
2024-03-04 16:56:23 +08:00
FooIbar e54b33aaaa Fix simple language generation for ComicInfo
Regression introduced in commit 4142f288
2024-03-04 15:01:16 +08:00
GG Bond e6a22d8ef4 Reader Setting PlaceHolder 2024-03-04 12:21:02 +08:00
GG Bond ae25018574 ReaderScreen saveToImage 2024-03-04 12:21:02 +08:00
GG Bond 28160608ec ReaderScreen saveImage 2024-03-04 12:21:02 +08:00
GG Bond 38651eba1d Optimize GalleryListScreen 2024-03-04 00:42:36 +08:00
GG Bond cc53fa5f1c Optimize 2024-03-04 00:42:36 +08:00
FooIbar 70f8181752 Support FTS for history and local favorites search
BREAKING CHANGE: Upgrade database
2024-03-03 23:24:08 +08:00
GG Bond 9e506f6258 Update empty hint in HistoryScreen 2024-03-03 23:23:08 +08:00
GG Bond 1304c728cf Cleanup GalleryCommentsScreen 2024-03-03 23:09:00 +08:00
GG Bond 80e11c54e7 Composing FavouritesScreen 2024-03-03 23:09:00 +08:00
GG Bond ff5d58b101 Update CopyLeft years 2024-03-03 23:09:00 +08:00
GG Bond bf550d99bc Remove credits to OkHttp as we are not directly built on it 2024-03-03 23:09:00 +08:00
FooIbar 7738b7bc29 Implement title keyword search in history (#840)
* Implement title keyword search in history

* Don't use Dispatchers.IO
2024-03-03 14:51:30 +00:00
FooIbar 01f51b1000 Decouple search FAB from SearchBarScreen 2024-03-03 18:44:40 +08:00
FooIbar 16dfacb285 Workaround incorrect fling velocity on zoom end 2024-03-03 00:42:46 +08:00
GG Bond a253ddf7e4 composing with MainActivity 2024-03-02 21:57:55 +08:00
GG Bond ffb488482c composing GalleryDetailScreen 2024-03-02 21:57:55 +08:00
GG Bond 0e29754ce9 composing GalleryCommentsScreen 2024-03-02 21:57:55 +08:00
GG Bond ee97720e1f composing GalleryPreviewScreen 2024-03-02 21:22:57 +08:00
GG Bond 29d4e6370c composing GalleryPreviewScreen 2024-03-02 21:22:57 +08:00
GG Bond 9040cb54d4 composing with CoroutineScope 2024-03-02 21:22:57 +08:00
GG Bond f082766f06 composing HistoryScreen 2024-03-02 21:22:57 +08:00
GG Bond e2372891cb composing ReaderScreen 2024-03-02 21:22:57 +08:00
GG Bond 9dc5b5c539 Introducing composing magic, pack necessary environment CompositionLocal 2024-03-02 21:22:57 +08:00
GG Bond 886e025ac8 Implement copy 2024-03-02 21:22:57 +08:00
GG Bond feb13cbd79 Fix sheet anim 2024-03-02 21:22:57 +08:00
GG Bond 66baaad86f Implement share image 2024-03-02 21:22:57 +08:00
FooIbar 4142f28822 Fix simple language generation for favorites 2024-03-02 19:40:30 +08:00
renovate[bot] c578b16e27 Update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.6.0-RC.2 2024-03-02 10:06:04 +00:00
FooIbar 32887acecd Migrate to kotlinx datetime [2/2] 2024-03-02 17:58:37 +08:00
FooIbar 5cf5cc5e34 Update page state on image extraction failure 2024-03-02 16:11:42 +08:00
GG Bond 4dc0f7e02e CropBorder use Compose IntRect 2024-03-02 16:01:08 +08:00
renovate[bot]andGG Bond 6e9c6e6269 Updates for ReaderScreen (#827)
* Retry pages

* Restore loading behaviour

* Refactor GalleryPager

* GalleryPager accepts onSelectPage

* Slot to do page select actions

* Move zoomableState in

* Refactor using EdgeApplier

* Cleanup unneeded

* Workaround Crash

* Don't scale non-image

* Reset page state when recycled

* Have pageloader restart when cropBorder changes

* Make PagerItem top-level

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-03-02 15:43:53 +08:00
renovate[bot]andGG Bond a22880a9b1 Fix archive memleak (#828)
* Don't lock anon pages when decode

* Log madvise fault

* Simplify

* Don't readahead unconditionally

* Refine

* Fixes

* Cleanup

* Fix sparse archive case

* Fixes

* Prefault anon pages for new kernel

* Fixes

* Fixes

* Fixes

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-03-02 14:48:35 +08:00
FooIbar 7357724392 Address GlobalScope usage and minor cleanup 2024-03-01 16:05:17 +08:00
renovate[bot] fb1b08e7e8 Update dependency com.android.application to v8.4.0-alpha12 2024-02-29 23:43:32 +00:00
renovate[bot] 1405b99ab8 Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha06 2024-02-29 23:41:13 +00:00
FooIbar f7aec69dae Avoid SAF overhead when save-as-cbz is enabled 2024-02-29 22:15:43 +08:00
renovate[bot] f15a0fc4bb Update dependency io.arrow-kt:arrow-stack to v1.2.3 2024-02-29 10:21:56 +00:00
FooIbar 6330fc3bdc Revert "Build with Android 15 preview SDK"
This reverts commit 7d2e0f7f

Reason for revert: Android SDK sources for VanillaIceCream are not
available, making it hard to debug.
2024-02-29 16:10:00 +08:00
FooIbar 01e3f3e9b7 Sort only entries in external archive files 2024-02-29 16:07:34 +08:00
FooIbar e0c614cf71 Cache display name and mime type for document file 2024-02-29 16:06:42 +08:00
renovate[bot] b34764d4c2 Update dependency com.google.devtools.ksp to v2.0.0-Beta4-1.0.18 2024-02-29 04:44:46 +00:00
FooIbar f46f84e533 Fix forbidden filename characters in gallery title 2024-02-29 12:36:40 +08:00
renovate[bot] ca0eaf2053 Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha05 2024-02-29 03:05:54 +00:00
renovate[bot] 25f283ee9c Update snapshot 2024-02-29 03:45:13 +08:00
FooIbar 8ee23ab520 Fix zero-copy branch condition for an edge case 2024-02-29 01:45:10 +08:00
renovate[bot]andrenovate[bot] 6964baf3f6 Update aboutlibraries to v11 (major) (#810)
* Update aboutlibraries to v11

* Update LicenseScreen.kt

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <99822064+revonateB0T@users.noreply.github.com>
2024-02-28 12:52:07 +00:00
FooIbar 01aa7af175 Minor fixes for new reader 2024-02-28 18:28:19 +08:00
renovate[bot]andGG Bond 5982bd2d4b Zero-copy read for 0-compression archives (#809)
* Zero-copy read for 0-compression archives

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-02-28 09:04:17 +00:00
FooIbar d0fbb3539f Initial save downloaded gallery as cbz archive (#808)
* Initial save downloaded gallery as cbz archive

* Make save as cbz archive opt-in
2024-02-28 08:06:08 +00:00
GG Bond 284fb4242b Compose Reader Start Page 2024-02-28 15:52:53 +08:00
GG Bond beeb4bdbd4 Crop border for Compose ReaderScreen 2024-02-28 15:52:53 +08:00
renovate[bot]andGG Bond 76a52903e5 Polymorphic pager (#804)
* Ctrl AppbarVisible

* PageIndicatorText

* Polymorphic pager

* Fixes

* Sync state for pager as well

* Refine

* Navbar Padding

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-02-28 04:19:29 +00:00
Vladimir Dostoyevskiy a19b8782a8 Update compose snapshot 2024-02-28 08:58:51 +08:00
Vladimir DostoyevskiyandGG Bond 31f66fd05b Reader updates (#800)
* Refine

* Bars and sliders

* Zoomable

* Refine

* Refine Slider & LazyList state sync

* Refine

* Cleanup

* Fixes

* Fix initial state

* Fixes

* Fixes

* Fixup initial state

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-02-27 23:14:36 +08:00
renovate[bot] bc3c4e969c Update dependency io.arrow-kt:arrow-stack to v1.2.2 2024-02-27 14:52:00 +00:00
FooIbar 4231402e54 Coroutinize DownloadManager 2024-02-27 22:44:12 +08:00
Vladimir DostoyevskiyandGG Bond ff4f76dfa5 Initial Compose ReaderScreen (#799)
* Initial Compose ReaderScreen

* Refine

* Naming

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-02-27 09:44:03 +00:00
FooIbar 2ff9df4c07 Show gallery pages in history 2024-02-27 17:29:35 +08:00
GG Bond 7cbc641178 Drop outdated comment 2024-02-27 13:58:33 +08:00
GG Bond 08d5621b59 Update snapshot for non-gesture systemback transition 2024-02-27 13:58:33 +08:00
GG Bond d39c2fdfa2 Update workaround 2024-02-27 13:58:33 +08:00
GG Bond 27e02ad642 Don't dup fd when calculate sha1
Signed-off-by: GG Bond <whatever@tagkong.com>
2024-02-27 12:50:32 +08:00
FooIbar 11e9faef7c Fix skip H@H key parsing
It may contain alphabets.
2024-02-26 20:55:48 +08:00
FooIbar cbb8971dd1 Implement sort downloads by page count 2024-02-26 18:04:45 +08:00
FooIbar 1077f4d8cf Show pages instead of language in downloads' badge (#793)
It is expected that most downloads will be in the same language.

BREAKING CHANGE: Upgrade db, add `PAGES` column to `GALLERIES` table
2024-02-26 17:54:21 +08:00
FooIbar 594f9412e3 Ignore leading zeros when sorting archive entries 2024-02-26 12:44:48 +08:00
Vladimir DostoyevskiyandGG Bond 7674f6a0f7 Refactor Image with coil Interceptor (#791)
* CropBorderInterceptor

* TooLargeBitmapInterceptor

* Refine

* Take Foolbar's code

* May fail if the image is too large

* Fixup

* Fixup

* Refine

* Refine

* Swap CropBorderInterceptor & HardwareBitmapInterceptor

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-02-26 03:49:40 +00:00
Vladimir DostoyevskiyandGG Bond aea6fbf4ab Anyhow for parser (#788)
* Use anyhow for parser

Signed-off-by: GG Bond <whatever@tagkong.com>

* Refine

Signed-off-by: GG Bond <whatever@tagkong.com>

---------

Signed-off-by: GG Bond <whatever@tagkong.com>
Co-authored-by: GG Bond <whatever@tagkong.com>
2024-02-25 23:58:33 +08:00
GG Bond 962f9bfff5 Fuck off silly error handling
Signed-off-by: GG Bond <whatever@tagkong.com>
2024-02-25 23:04:35 +08:00
GG Bond 60532e2622 Move sha1 to mod hash, use anyhow
Signed-off-by: GG Bond <whatever@tagkong.com>
2024-02-25 23:04:35 +08:00
FooIbar 5c7a834342 Treat download as finished if archive file exists 2024-02-25 21:24:08 +08:00
FooIbar ca940080a9 Move checked category filter cards to first 2024-02-25 19:39:16 +08:00
Vladimir Dostoyevskiy 2273dd541a Enable simd (#783)
* Enable simd

* Update CMakeLists.txt

* Update CMakeLists.txt

* Update CMakeLists.txt

* Update CMakeLists.txt
2024-02-25 07:34:17 +00:00
Vladimir DostoyevskiyandGG Bond 422062226d Move to rust sha1 impl (#782)
* Move to rust sha1 impl

* Use onstack buffer

Signed-off-by: GG Bond <whatever@tagkong.com>

---------

Signed-off-by: GG Bond <whatever@tagkong.com>
Co-authored-by: GG Bond <whatever@tagkong.com>
2024-02-25 14:07:32 +08:00
FooIbar 6a6da6f059 Move sha1 digest calculation to native function (#781)
`kotlinx-io` has removed hash functions API.
2024-02-25 00:15:34 +08:00
GG Bond 4cbb0eca4f launchInVM 2024-02-24 22:29:41 +08:00
GG Bond ebb3eb1ab3 Simplify EhEngine.getPreviewList 2024-02-24 22:29:41 +08:00
GG Bond ebf645acaf Refine naming 2024-02-24 22:29:41 +08:00
GG Bond ec787ebde2 Aggressively fetch in viewModelScope 2024-02-24 22:29:41 +08:00
GG Bond 4f190cb0ef Cleanup GalleryPreviewScreen 2024-02-24 22:29:41 +08:00
GG Bond 2a36868d11 CR for GalleryInfo 2024-02-24 22:29:41 +08:00
GG Bond 176d8be565 Throwable.displayString() 2024-02-24 22:29:41 +08:00
GG Bond 7b581e45e1 CodeReview for LoadStateIndicator 2024-02-24 22:29:41 +08:00
GG Bond 4888e29f79 CodeReview for EhAsyncThumb 2024-02-24 22:29:41 +08:00
GG Bond e5f6d1b1aa CodeReview for EhAsyncThumb 2024-02-24 22:29:41 +08:00
GG Bond d544bbfbeb CodeReview for GalleryPreview 2024-02-24 22:29:41 +08:00
FooIbar ed20d6022b Update liblzma to v5.6.0 2024-02-24 19:26:32 +08:00
FooIbar 4191e2091c Handle invalid UTF-8 characters in response body (#778) 2024-02-24 18:30:40 +08:00
FooIbar 1eeb5a51f6 Introduce DownloadsFilterState (#769)
* Fix default download list not updating on label delete

* Fix unexpected behavior after renaming label
2024-02-24 12:51:11 +08:00
FooIbar dd0776758f Refactor confirm remove download dialog 2024-02-24 01:42:01 +08:00
FooIbar cf3ccf6155 Update snapshot
* Remove obsolete workaround
2024-02-23 22:40:19 +08:00
GG Bond fa6e4acad6 Flatten closure
Signed-off-by: GG Bond <whatever@tagkong.com>
2024-02-23 11:13:58 +08:00
GG Bond db33a72a8e Update snapshot 2024-02-23 11:13:58 +08:00
renovate[bot] 0f8a0549b1 Update dependency com.android.application to v8.4.0-alpha11 2024-02-23 02:02:43 +00:00
renovate[bot] 26ff30af6c Update dependency sh.calvin.reorderable:reorderable to v1.3.2 2024-02-22 01:20:38 +00:00
GG Bond c2fa32f430 Workaround SlotTable modification race 2024-02-21 20:09:36 +08:00
GG Bond 2734de0588 Fix upstream RememberObserver forget order changes 2024-02-21 20:09:36 +08:00
GG Bond aa219e4b71 Fix for compose snapshot 2024-02-21 20:09:36 +08:00
GG Bond 645656b4cd Update compose snapshot 2024-02-21 20:09:36 +08:00
GG Bond ef16bdeb75 Relax GalleryList recomposition 2024-02-21 14:17:37 +08:00
FooIbar 84299b4db7 Workaround for image search (#768)
* Workaround for image search

* Cleanup
2024-02-20 15:22:51 +00:00
FooIbar 953e216dde Implement sort modes and random walk for downloads (#767)
* Implement sort modes and random walk for downloads

* Use `PrefDelegate<T>.asMutableState` instead

BREAKING CHANGE: Upgrade database.
2024-02-20 11:50:37 +00:00
FooIbar c57fd0a923 Reset progress indicator on retry 2024-02-19 17:31:20 +08:00
FooIbar f9cb3d769f Skip H@H on force retry (#762)
* Skip H@H on force retry

* Refine
2024-02-19 08:11:57 +00:00
GG Bond dd62571943 Apply padding for SearchBarScreen Fab 2024-02-19 15:55:41 +08:00
GG Bond 9edd33d88d Apply padding for GalleryCommentsScreen 2024-02-19 15:55:41 +08:00
GG Bond b2fded76d5 Extract a snackBarPadding Modifier 2024-02-19 15:55:41 +08:00
GG Bond 1761493c8e Apply padding to FabLayout 2024-02-19 15:29:33 +08:00
GG Bond 6aa6ee2810 LocalSnackbarFabPadding 2024-02-19 15:29:33 +08:00
GG Bond 84728a1490 Enable context receivers 2024-02-19 15:29:33 +08:00
GG Bond a1568467f0 Cleanup showTip usage 2024-02-19 15:29:33 +08:00
FooIbar 32e2a03c31 Make GalleryInfo.token non-null, upgrade db 2024-02-19 15:24:08 +08:00
GG Bond d4609593e2 Remove showTip usage in GalleryDetailScreen 2024-02-19 12:57:10 +08:00
GG Bond a2af7437c0 Remove showTip usage in GalleryCommentsScreen 2024-02-19 12:57:10 +08:00
FooIbar ab5af22fc3 Re-enable gradle configuration cache 2024-02-18 15:14:35 +08:00
FooIbar 39a159e5ca Optimize CI caching policy 2024-02-18 15:14:35 +08:00
FooIbar ec8a54906c Optimize CI caching policy 2024-02-18 11:59:27 +08:00
FooIbar 73c0ce02ea Separate build tasks to accelerate CI 2024-02-18 08:27:03 +08:00
FooIbar 7d2e0f7f7e Build with Android 15 preview SDK (#749)
* Build with Android 15 preview SDK

* Fix CI

* Don't set application as test-only
2024-02-17 19:05:12 +08:00
FooIbar 7515a049c2 Address cmake warning: "Policy CMP0077 is not set" 2024-02-17 15:03:28 +08:00
GG Bond a317d5303e Enable compiler nonSkippingGroupOptimization 2024-02-17 13:36:07 +08:00
Vladimir DostoyevskiyandGG Bond c4045ded70 Upstream Navigation (#744)
* Official Navigation

* Update snapshot

* Move navigation to snapshot

* Drop dead switch

* Bisect

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-02-17 12:16:05 +08:00
FooIbar 3cb1b1eee4 Migrate to Corrosion, rust-android-gradle is dead (#742)
* Migrate to Corrosion, rust-android-gradle is dead

This also allows us to enable gradle configuration cache.

* Disable gradle configuration cache
2024-02-17 08:04:28 +08:00
renovate[bot] 5423af46c5 Update dependency org.jetbrains.kotlinx:kotlinx-serialization-json-okio to v1.6.3 2024-02-16 19:03:24 +00:00
FooIbar a2883840e0 Fix thumbnails not updating in grid view (#741)
* Fix 7735237 ("Remember ImageRequest")

* Refine
2024-02-16 11:21:11 +00:00
GG Bond 14810b4c2c Grab some RestrictedApi to make lint happy 2024-02-16 12:25:42 +08:00
GG Bond f5a9a41790 Introduce Result<T>.foldToLoadResult 2024-02-16 12:25:42 +08:00
Vladimir Dostoyevskiy 500d00d214 Update kotlin & compose compiler 2024-02-16 10:00:17 +08:00
Vladimir Dostoyevskiy 49810f8fd1 Update compose snapshot 2024-02-16 09:47:45 +08:00
renovate[bot] 6ad3ab228f Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.8.0 2024-02-15 16:36:08 +00:00
renovate[bot] 3a29ce291b Update compose.destinations to v1.10.1 2024-02-15 16:35:33 +00:00
Vladimir DostoyevskiyandGG Bond 1e28a4c7ab Introduce delegateSnapshotUpdate dsl (#735)
* Introduce rememberSnapshotFlowAsState dsl

* Refine naming

* Cleanup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-02-16 00:11:35 +08:00
FooIbar 645467d20f Implement random walk in search results 2024-02-15 18:20:42 +08:00
Vladimir Dostoyevskiy b2a5fe9f52 Update NDK 2024-02-15 09:23:38 +08:00
Vladimir Dostoyevskiy 2ea3c03360 Update compose snapshot (#731)
* Update compose snapshot

* Update
2024-02-14 04:15:24 +00:00
FooIbar 0c147967d2 Attempt to parse received keyword as URL first 2024-02-13 23:45:30 +08:00
FooIbar de1fab2595 Fix current page in goto dialog on ToplistScreen 2024-02-13 23:11:26 +08:00
FooIbar b86eebf0a7 Refactor Context.decodeBitmap to non-blocking 2024-02-13 22:07:36 +08:00
FooIbar 07345f90e6 Fix exported archive extension on older platform 2024-02-13 02:09:54 +08:00
FooIbar 5fe520e575 Cleanup Log usage and make logging configurable 2024-02-13 00:23:10 +08:00
FooIbar a9dd705ec5 Don't trim gallery detail cache, Resolve #710
It will only free about 1 MB memory
2024-02-12 23:42:01 +08:00
FooIbar e36520faad Replace printStackTrace with proper priority log (#722)
* Replace `printStackTrace` with proper priority log

* Fix
2024-02-12 14:40:45 +00:00
Vladimir DostoyevskiyandGG Bond edaf0a4275 Implement check ripple (#721)
* Implement check ripple

* Fix

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-02-12 14:21:18 +00:00
GG Bond 7735237ea7 Remember ImageRequest
ImageRequest is not Stable, each recompose will allocate an ImageRequest
2024-02-12 21:21:06 +08:00
FooIbar 7bbc4ea618 Fix failed to generate ComicInfo.xml on API < 33 2024-02-12 21:10:46 +08:00
GG Bond db99392082 Simplify GalleryList state a bit 2024-02-12 19:32:54 +08:00
FooIbar 7bf6a9f545 Add animation for DownloadsScreen grid view switch 2024-02-12 18:54:01 +08:00
FooIbar 51c0efebc4 Support grid view in DownloadsScreen (#714)
* Support grid view in DownloadsScreen

* Fix aspect

* Refine
2024-02-12 09:37:45 +00:00
FooIbar 7ef70aa16e Generate ComicInfo.xml on archive download 2024-02-11 21:01:16 +08:00
FooIbar c5c0c6c4bd Initial support for third-party download manager 2024-02-11 18:43:59 +08:00
GG Bond b26cba8ea9 Update compose snapshot 2024-02-10 22:46:08 +08:00
GG Bond 90b4c16166 Lock drawer on SignInScreen 2024-02-10 15:01:53 +08:00
GG Bond 9b0f6fe433 Lock drawer on all Webview Screen 2024-02-10 15:01:53 +08:00
GG Bond e24735c341 Relax GalleryCommentsScreen recompose 2024-02-10 15:01:53 +08:00
GG Bond 9680ada9ee Lift a Modifier.layout usage 2024-02-10 15:01:53 +08:00
GG Bond 028a7c2352 ListUrlBuilder.asDst 2024-02-10 15:01:53 +08:00
GG Bond 80e5f36c4e Long.asDstWith -> GalleryDetailScreenDestination 2024-02-10 15:01:53 +08:00
GG Bond e0b65184e2 BaseGalleryInfo.asDestination() 2024-02-10 15:01:53 +08:00
GG Bond 395a48ddeb Implement random walk in localFav 2024-02-10 15:01:53 +08:00
GG Bond e22a39f549 Refactor FavouritesScreen state management 2024-02-10 15:01:53 +08:00
renovate[bot] e17d934ebf Update dependency com.squareup.okio:okio-jvm to v3.8.0 2024-02-10 00:28:46 +00:00
FooIbar 10746417b6 Fix crash when visiting newer version galleries
(cherry picked from commit 9be19489a4)
2024-02-09 23:27:27 +08:00
FooIbar 3ef45ba0cc Refactor remaining dialogs to Compose 2024-02-09 14:45:26 +08:00
FooIbar a3e7b6ef43 Refactor select item dialog API to take List<T> 2024-02-08 12:39:04 +08:00
renovate[bot] c444a3b435 Update dependency androidx.compose.material3:material3 to v1.2.0 2024-02-08 00:26:23 +00:00
renovate[bot] 5161802465 Update androidx.navigation to v2.7.7 2024-02-07 21:41:33 +00:00
FooIbar 8c27feec7a Refine exporting downloaded gallery as cbz archive (#698) 2024-02-07 07:17:55 +00:00
FooIbar 37e895cdca Decouple hideHvEvents and requestNews settings (#697)
Visiting gallery page on e-hentai will show HV events as well.
2024-02-06 16:23:25 +00:00
FooIbar e1ad4e3b90 Rename .thumb to thumb.jpg so it can be previewed 2024-02-06 23:18:41 +08:00
FooIbar 5f0be5ae43 Move reading progress to database
In this way cbz archives can be read without SpiderInfo.
2024-02-06 21:33:24 +08:00
Vladimir Dostoyevskiy 9a7cabb221 Update snapshot 2024-02-06 20:33:20 +08:00
renovate[bot] 6acc552058 Update dependency sh.calvin.reorderable:reorderable to v1.3.1 2024-02-06 17:44:30 +08:00
FooIbar b4f4d442eb Ensure images use .jpg extension instead of .jpeg 2024-02-06 16:24:55 +08:00
Vladimir DostoyevskiyandGG Bond 21016dda53 Initial draft support export as cbz (#685)
* Initial draft support export as cbz

* Hint user progress

* Hint user result

* Emit comicInfo as well

* Write ComicInfo if not exist

* Use perFilename

* Print stacktrace

* Fix

* Refine

* Revert "Refine"

This reverts commit 2b368180fde00c7a8cfd4e895f1f2d5c29c3cf9f.

* Refine

* Refine

* Fix

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-02-06 06:44:11 +00:00
Vladimir Dostoyevskiy 576e5b1b58 Revert "Update dependency sh.calvin.reorderable:reorderable to v1.3.0"
This reverts commit 17437a6882.
2024-02-06 14:21:33 +08:00
renovate[bot] 17437a6882 Update dependency sh.calvin.reorderable:reorderable to v1.3.0 2024-02-05 21:49:27 +00:00
FooIbar 04f9f087da Refactor image saving to run on background thread 2024-02-05 07:10:35 +08:00
FooIbar c508947728 Lazily initialize image cache of PageLoader
`OSUtils.totalMemory` needs to read from disk
2024-02-04 23:27:54 +08:00
FooIbar ce1dc55d3c Add support for non-UTF-8 encoded archives (#682) 2024-02-04 22:24:32 +08:00
renovate[bot]andFooIbar e9f615819f Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha04 (#677)
* Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha04

* Revert "Fix delayed application of HTTP engine changes"

This reverts commit c82de0b779.

* Revert "No longer need restart to switch HC"

This reverts commit 7e8b0a58

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-02-03 21:24:59 +08:00
FooIbar 8da3546558 Add back GifDecoder as service loader is disabled 2024-02-03 20:05:56 +08:00
FooIbar d8f8dcffbd Update compose snapshot and remove workaround 2024-02-03 18:14:03 +08:00
FooIbar e79113b19e Lazily initialize image cache on background thread 2024-02-03 18:04:42 +08:00
renovate[bot] 2a7f65a1bd Update dependency gradle to v8.6 2024-02-02 20:27:41 +00:00
FooIbar e322878b95 Don't use app-specific dir as default download dir 2024-02-02 11:01:06 +08:00
FooIbar 11b09f187c Refactor some dialogs to Compose (#675)
* Refactor some dialogs to Compose

* Implement rating bar

* Cleanup
2024-02-01 06:42:39 +00:00
renovate[bot] 926309733c Update dependency io.github.fornewid:material-motion-compose-core to v1.2.0 2024-01-31 05:24:14 +00:00
FooIbar c4b2bcab66 Allow clearing igneous cookie without signing out 2024-01-30 20:16:39 +08:00
FooIbar b88c96894d Prepare to save downloaded gallery as cbz archive 2024-01-30 19:15:40 +08:00
FooIbar 8502671203 Drop CookieSignInScreen and SelectSiteScreen
Cookie-based login is useless due to new e-hentai cookie policy.

Simplify login process a bit since exhentai is preferred.
2024-01-30 10:20:22 +08:00
renovate[bot] 4a7d6f7489 Update dependency io.github.fornewid:material-motion-compose-core to v1.1.2 2024-01-29 20:42:45 +00:00
FooIbar aec34c81aa Make PageCount field optional 2024-01-27 21:22:31 +08:00
FooIbar 1d4fe30e04 Restore download from ComicInfo.xml if available 2024-01-27 20:21:11 +08:00
FooIbar a2cfecd922 Upstream SearchBar Predictive Back support 2024-01-27 20:19:59 +08:00
renovate[bot] d36aef3214 Update actions/upload-artifact action to v4 2024-01-27 09:23:06 +00:00
FooIbar c632231813 Update xz to v5.4.6 2024-01-27 17:00:01 +08:00
FooIbar d8dd68c9b6 Fix sad panda check false positive 2024-01-27 14:19:26 +08:00
FooIbar d6a58d5d34 Generate ComicInfo.xml on download 2024-01-27 13:43:08 +08:00
renovate[bot] 41f8168c28 Update gradle/gradle-build-action action to v3 2024-01-26 21:54:50 +00:00
renovate[bot] 4513ead30a Update compose.destinations to v1.10.0 2024-01-26 05:21:27 +00:00
renovate[bot] f4e23a5738 Update dependency dev.chrisbanes.compose:compose-bom to v2024.01.00-alpha03 2024-01-24 22:00:36 +00:00
FooIbar 79b167002c Use spotless's default ktlint version (#657)
* Use spotless's default ktlint version

* Suppress property naming rule for backing property
2024-01-24 08:32:01 +00:00
FooIbar a9a763ae5f Update Kotlin, KSP and Compose Compiler 2024-01-24 15:43:36 +08:00
FooIbar a5fc29bc66 Fix uploader chip not clickable in thumbnail mode 2024-01-24 14:52:51 +08:00
FooIbar 02c568fd7a Update issue template
[no ci]
2024-01-24 13:33:08 +08:00
renovate[bot] ce55891bc2 Update dependency com.android.application to v8.2.2 2024-01-23 18:58:25 +00:00
renovate[bot] 87b1160c72 Update plugin spotless to v6.25.0 2024-01-23 11:18:25 +00:00
GG Bond ccc6d004de Refactor FilterScreen dialog to Compose, animateItemPlacement 2024-01-23 12:49:12 +08:00
renovate[bot]andFooIbar 20b145bee5 Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha03 (#648)
* Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha03

* Adapt coil3 changes

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-01-21 06:11:13 +00:00
renovate[bot] a37cc59a56 Update dependency org.chromium.net:cronet-api to v119 2024-01-17 18:54:41 +00:00
renovate[bot] 7cbffff12c Update compose.destinations to v1.9.62 2024-01-17 17:47:56 +00:00
renovate[bot] 4e5ae53775 Update actions/cache action to v4 2024-01-17 17:43:46 +00:00
renovate[bot] a53de63695 Update compose.destinations to v1.9.61 2024-01-17 14:54:53 +00:00
renovate[bot] 8020c139ec Update dependency io.github.fornewid:material-motion-compose-core to v1.1.1 2024-01-16 20:29:51 +00:00
FooIbar 3c5375a9a6 Prepare for next iteration 2024-01-15 23:41:08 +08:00
FooIbar 1d9008a1a6 Prepare for release 1.10.3
Releases / Build (push) Has been cancelled
2024-01-15 23:39:56 +08:00
FooIbar fb577dc026 Update compose snapshot and r8 2024-01-15 20:01:11 +08:00
FooIbar 675eb1b7e2 Update compose-bom to v2024.01.00-alpha01 2024-01-15 17:41:46 +08:00
renovate[bot] 6a3d522dfa Update plugin spotless to v6.24.0 2024-01-15 02:54:24 +00:00
renovate[bot] 47aa5c64e7 Update compose.destinations to v1.9.60 2024-01-14 19:25:34 +00:00
FooIbar 5ecf5c41ca Don't use password type keyboard for number input
Some devices will use secure keyboard for password input.
2024-01-14 16:40:50 +08:00
FooIbar bc6d12a873 Fix typo 2024-01-13 18:26:42 +08:00
FooIbar 4d983830df Refine exception translation 2024-01-13 12:27:52 +08:00
FooIbar 2f1e0d030e Revert "Reduce image target size since we are using fill"
This reverts commit c07d9da7

Reason for revert: Coil provides more precise target size control
compared to `ImageDecoder.setTargetSampleSize`.
2024-01-12 15:03:38 +08:00
FooIbar b7c873a172 Minor cleanup 2024-01-12 14:16:38 +08:00
renovate[bot] 9d5eb55290 Update compose.destinations to v1.9.59 2024-01-11 12:08:16 +00:00
renovate[bot] 78ffb1c4fe Update dependency com.google.android.material:material to v1.12.0-alpha03 2024-01-11 00:37:42 +00:00
renovate[bot] 031d622d5d Update androidx.lifecycle to v2.7.0 2024-01-10 19:49:20 +00:00
renovate[bot] 34da120a43 Update compose.destinations to v1.9.58 2024-01-10 19:46:27 +00:00
FooIbar 5285e1ccf2 Update coil to 3.0.0-alpha02 2024-01-10 21:10:40 +08:00
FooIbar e28efc1f50 Fix request news 2024-01-09 22:52:21 +08:00
FooIbar 1c043a81a6 Adapt coil3 changes 2024-01-09 22:09:39 +08:00
Vladimir DostoyevskiyandGG Bond 9a769cc7b6 Rust refactor (#617)
* Introduce deref_direct_bytebuffer

* Cleanup

* Cleanup

* Cleanup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-01-07 17:56:49 +00:00
FooIbar 045c6abe40 Revert "Always enable predictive nav" (#616)
* Revert "Always enable predictive nav"

This reverts commit 0333a8c679.

* Disable predictive nav animations by default
2024-01-07 13:45:28 +00:00
FooIbar 4ad5cef412 Fix unable to move to first page on first attempt 2024-01-07 14:24:31 +08:00
GG Bond c3ffab3c79 Move to official aboutlibraries m3 widget 2024-01-07 13:36:53 +08:00
FooIbar c82de0b779 Fix delayed application of HTTP engine changes 2024-01-07 13:36:41 +08:00
FooIbar 4e36cbabc0 Fix crash on get 509 and mDownloadReference > 1 2024-01-06 23:49:26 +08:00
FooIbar 94f87b14b7 Fix download state not updating 2024-01-06 23:49:26 +08:00
FooIbar 6bc80d1afa [Marshmallow] Archive support 2024-01-06 22:02:00 +08:00
FooIbar fe4d004083 Fix request news 2024-01-06 20:41:30 +08:00
FooIbar 28410befa3 Java 21 2024-01-05 17:36:03 +08:00
Vladimir Dostoyevskiy 515f7deb7e Update compose snapshot 2024-01-04 09:18:07 +08:00
renovate[bot] 800e66261e Update dependency com.android.application to v8.2.1 2024-01-03 19:15:18 +00:00
renovate[bot] 65658c4a02 Update dependency io.github.raamcosta.compose-destinations:core to v1.9.57 2024-01-03 17:09:11 +00:00
FooIbar 0488c7ebf4 Fix restartGalleryProvider being called on start 2024-01-03 22:09:53 +08:00
FooIbar c30c1b29ef Workaround loading images from file uri 2024-01-03 19:49:47 +08:00
GG Bond 876001ad99 Adapt coil3 changes 2024-01-03 19:13:55 +08:00
FooIbar 60dc59adbc Fix concurrent modification 2024-01-03 17:17:19 +08:00
FooIbar c994890f28 Don't retry too many times on the same request 2024-01-03 17:16:10 +08:00
FooIbar 76223a2cd2 Drop fixed workaround 2024-01-03 15:10:37 +08:00
FooIbar 6274b39b68 Kotlin 2.0.0-Beta2 2024-01-03 11:24:26 +08:00
FooIbar cca3a3e350 Lower crop border threshold
Some images have white edges on both left and right.
2024-01-02 21:39:59 +08:00
FooIbar 86ef8f2b48 Ensure cropped images meet minimum width/height 2024-01-02 18:41:53 +08:00
Vladimir DostoyevskiyandGG Bond 5d116d9477 Image wrap a CoilImage (#588)
* Image wrap a CoilImage

* Make ImageSource sealed

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-01-02 07:43:31 +00:00
Vladimir DostoyevskiyandGG Bond 6e73fcc8c9 Cleanup Image decode (#587)
* Move to coil snapshot

* Unify decode with coil

* Unify decode with coil

* Add AnimatedImageDecoderDecoder

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-01-02 06:48:15 +00:00
FooIbar 3bd12a3047 Fix typo 2024-01-01 22:40:27 +08:00
FooIbar c99d8e8275 [Marshmallow] Support crop image borders 2024-01-01 20:18:00 +08:00
FooIbar 4a12e71877 Fix crash in comment screen after leaving the app 2024-01-01 17:36:53 +08:00
GG Bond 3043901b5a Resolve #579, Resolve #581 2024-01-01 15:25:49 +08:00
renovate[bot] 7cc28955d6 Update dependency com.mikepenz.aboutlibraries.plugin to v10.10.0 2023-12-31 13:20:37 +00:00
FooIbar ef44bb4857 Prepare for next iteration 2023-12-31 19:50:55 +08:00
FooIbar 37660ef697 Prepare for release 1.10.2
Releases / Build (push) Has been cancelled
2023-12-31 19:50:11 +08:00
FooIbar 9821b3aaab Fix lint 2023-12-31 18:55:48 +08:00
FooIbar 1f5331af5a Update lint plugin and fix some issues 2023-12-31 18:55:48 +08:00
Vladimir DostoyevskiyandGG Bond 3ea6c82a6d Don't unwind (#574)
* Don't unwind

* Fixes

* Abort in panic

* Refine

---------

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

* Convert crop broder icons to ImageVector

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

* Use Cbor

* Refine

* Drop logging

---------

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

* Drop unused crate

* Fixup

---------

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

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

* Introduce unmarshalParsingAs

* Comments

* Introduce parse_marshal_inplace

* Convert parseLimit

* Aha, convert parseGalleryInfoList

* Drop cache_unwind

* Unwind manually

* Fixes

* Drop jnix, update jni

* Data class

* Cleanups

* Cleanups

* Optimize a copy

---------

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

* Adapt for RGBA8888

* Support crop with rect in SubsamplingScaleImageView

* Settings to control crop border

* BottomBar to control crop border

* Fix rgb detect

* Update border.c

* Update Image.kt

* Fix webtoon

* Don't copy if not isAtLeastQ

* Refine

* Avoid too large bitmap

* Fix grey calculation

* Revert "Avoid too large bitmap"

This reverts commit d58dbe12062586ffe4ce4b872aace48b23ad9fde.

* Use matrix to draw bitmap to bypass the size limit

---------

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

* Refine

---------

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

* Fixup

---------

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

* Make UniFile.imageSource a extension

* Make UniFile.openFileDescriptor a extension

* Use appCtx

* Use appCtx

* Use appCtx

* Use appCtx

* Drop saveImageToLauncher

* Cleanup

* Use operator resolve

* Simplify

* Fix Toast

* Fixup

---------

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

* Implement lookup cache for TreeDocumentFile

* Drop unused UriHandler

* UniFile.subFile never return null

* Nonnull UniFile.fromFile

* SingleDocumentFile use appCtx

* TreeDocumentFile use appCtx

* MediaFile use appCtx

* Refine

* Refine

* Refine

* optimize TreeDocumentFile cache

* optimize RawFile cache

* Take care of dir lookup cache cause memleak

* Fixup

* Fixup

---------

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

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

* Refine

---------

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

* Fixup

* Update DownloadInfoMagics.kt

---------

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

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

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

* Refine

---------

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

* Fix R8

---------

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

* Cleanup unused

---------

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

* Drop unnecessary fix

---------

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

* Use Compose to animate bottom reader menu bars

* Cleanup unused

* Refine

* Cleanup to use DropdownMenu

* Cleanup unused

* Refine BottomReaderBar layout

---------

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

* Delay for bulk favorite adding to prevent flooding

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

This reverts commit 0c175453101aab2d6d200a661259906291ceac36.

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

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

* Move to Apache5 HC instead

* Fix build

* Credit to Ktor instead

* Refactor Cronet Preference

---------

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

* Fix

---------

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

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

* Refine

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

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

* Update EhRequestBuilder.kt

* Update EhRequestBuilder.kt

---------

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

* Support ByteArrayContent

* Cleanup

---------

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

* Migration

* Migration

* Migration

* Make EhCookieStore a CookiesStorage

* Drop CookieDB migration

* Drop one Okhttp cookie usage

* Drop one Okhttp cookie usage

* Remove typealias

* Move EhTagDatabase to use ktor

* Make cronet just use Dispatchers.IO

* Make all okhttpClient internally used

* Cleanup

* Refine

---------

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

* Drop chunker since we no more se okhttp

* Move getGalleryDetail to ktor

* Migration

* Migration

* Migration

* Migration

* Migration

* Migration

* Migration

* Migration

---------

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

No files matched your search

-3
View File
@@ -141,9 +141,6 @@ body:
请确认您已经遵守所有必选项
Please ensure you have obtained all needed options
options:
- label: 如果您有足够的时间和能力,并愿意为此提交 PR ,请勾上此复选框 / Pull request is welcome. Check this if you want to start a pull request
required: false
- label: 您已搜索过 [Issue Tracker](https://github.com/FooIbar/EhViewer/issues),没有找到类似内容 / I have searched on [Issue Tracker](https://github.com/FooIbar/EhViewer/issues), No duplicate or related open issue has been found
required: true
+178 -41
View File
@@ -4,25 +4,21 @@ on:
push:
branches:
- '*'
- 'renovate/**'
pull_request:
workflow_dispatch:
jobs:
linux:
name: Build
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
submodules: 'recursive'
uses: actions/checkout@v4
- name: Setup Java 17
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- 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
@@ -31,6 +27,7 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
workspaces: "./app/src/main/rust"
shared-key: ${{ runner.os }}
- name: Rustfmt Check
working-directory: ./app/src/main/rust
@@ -41,174 +38,314 @@ jobs:
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
uses: gradle/actions/setup-gradle@v3
with:
gradle-home-cache-cleanup: true
cache-read-only: true
- name: Spotless Check
run: ./gradlew spotlessCheck
- name: CMake Cache
uses: actions/cache@v3
oss:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
path: |
app/.cxx
app/src/main/cpp/nettle/nettle
key: ${{ runner.os }}-cmake-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
restore-keys: ${{ runner.os }}-cmake-
distribution: temurin
java-version: 21
- 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
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: "./app/src/main/rust"
shared-key: ${{ runner.os }}
save-if: "false"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
gradle-home-cache-cleanup: true
- name: CMake Cache
uses: actions/cache@v4
with:
path: app/.cxx
key: cmake-default-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
restore-keys: cmake-default-
- name: Build OSS
run: ./gradlew assembleDefaultOssRelease
- name: Upload OSS ARM64
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: default-oss-arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/defaultOss/release/app-default-oss-arm64-v8a-release.apk
- name: Upload OSS x86_64
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: default-oss-x86_64-${{ github.sha }}
path: app/build/outputs/apk/defaultOss/release/app-default-oss-x86_64-release.apk
- name: Upload OSS ARM32
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: default-oss-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/defaultOss/release/app-default-oss-armeabi-v7a-release.apk
- name: Upload OSS x86
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: default-oss-x86-${{ github.sha }}
path: app/build/outputs/apk/defaultOss/release/app-default-oss-x86-release.apk
- name: Upload OSS mapping
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: default-oss-mapping-${{ github.sha }}
path: app/build/outputs/mapping/defaultOssRelease/mapping.txt
- name: Upload OSS native debug symbols
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: default-oss-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/defaultOssRelease/native-debug-symbols.zip
gms:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: 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
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: "./app/src/main/rust"
shared-key: ${{ runner.os }}
save-if: "false"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
gradle-home-cache-cleanup: true
- name: CMake Cache
uses: actions/cache/restore@v4
with:
path: app/.cxx
key: cmake-default-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
restore-keys: cmake-default-
- name: Install Build Tools
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "build-tools;35.0.0-rc1"
- name: Build GMS
run: ./gradlew assembleDefaultGmsRelease
- name: Upload GMS ARM64
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: default-gms-arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/defaultGms/release/app-default-gms-arm64-v8a-release.apk
- name: Upload GMS x86_64
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: default-gms-x86_64-${{ github.sha }}
path: app/build/outputs/apk/defaultGms/release/app-default-gms-x86_64-release.apk
- name: Upload GMS ARM32
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: default-gms-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/defaultGms/release/app-default-gms-armeabi-v7a-release.apk
- name: Upload GMS x86
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: default-gms-x86-${{ github.sha }}
path: app/build/outputs/apk/defaultGms/release/app-default-gms-x86-release.apk
- name: Upload GMS mapping
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: default-gms-mapping-${{ github.sha }}
path: app/build/outputs/mapping/defaultGmsRelease/mapping.txt
- name: Upload GMS native debug symbols
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: default-gms-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/defaultGmsRelease/native-debug-symbols.zip
marshmallow-oss:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: 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
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: "./app/src/main/rust"
shared-key: ${{ runner.os }}
save-if: "false"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
gradle-home-cache-cleanup: true
- name: CMake Cache
uses: actions/cache@v4
with:
path: app/.cxx
key: cmake-marshmallow-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
restore-keys: cmake-marshmallow-
- name: Install Build Tools
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "build-tools;35.0.0-rc1"
- name: Build Marshmallow OSS
run: ./gradlew assembleMarshmallowOssRelease
- name: Upload Marshmallow OSS ARM64
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: marshmallow-oss-arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-arm64-v8a-release.apk
- name: Upload Marshmallow OSS x86_64
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: marshmallow-oss-x86_64-${{ github.sha }}
path: app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-x86_64-release.apk
- name: Upload Marshmallow OSS ARM32
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: marshmallow-oss-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-armeabi-v7a-release.apk
- name: Upload Marshmallow OSS x86
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: marshmallow-oss-x86-${{ github.sha }}
path: app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-x86-release.apk
- name: Upload Marshmallow OSS mapping
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: marshmallow-oss-mapping-${{ github.sha }}
path: app/build/outputs/mapping/marshmallowOssRelease/mapping.txt
- name: Upload Marshmallow OSS native debug symbols
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: marshmallow-oss-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/marshmallowOssRelease/native-debug-symbols.zip
marshmallow-gms:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: 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
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: "./app/src/main/rust"
shared-key: ${{ runner.os }}
save-if: "false"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
gradle-home-cache-cleanup: true
- name: CMake Cache
uses: actions/cache/restore@v4
with:
path: app/.cxx
key: cmake-marshmallow-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
restore-keys: cmake-marshmallow-
- name: Install Build Tools
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "build-tools;35.0.0-rc1"
- name: Build Marshmallow GMS
run: ./gradlew assembleMarshmallowGmsRelease
- name: Upload Marshmallow GMS ARM64
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: marshmallow-gms-arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-arm64-v8a-release.apk
- name: Upload Marshmallow GMS x86_64
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: marshmallow-gms-x86_64-${{ github.sha }}
path: app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-x86_64-release.apk
- name: Upload Marshmallow GMS ARM32
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: marshmallow-gms-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-armeabi-v7a-release.apk
- name: Upload Marshmallow GMS x86
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: marshmallow-gms-x86-${{ github.sha }}
path: app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-x86-release.apk
- name: Upload Marshmallow GMS mapping
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: marshmallow-gms-mapping-${{ github.sha }}
path: app/build/outputs/mapping/marshmallowGmsRelease/mapping.txt
- name: Upload Marshmallow GMS native debug symbols
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: marshmallow-gms-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/marshmallowGmsRelease/native-debug-symbols.zip
-1
View File
@@ -18,5 +18,4 @@ jobs:
- uses: dessant/lock-threads@v5
with:
issue-inactive-days: '1'
include-any-issue-labels: 'duplicate, wontfix'
process-only: 'issues'
+6 -8
View File
@@ -11,15 +11,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
submodules: 'recursive'
uses: actions/checkout@v4
- name: Setup Java 17
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android
@@ -30,7 +28,7 @@ jobs:
workspaces: "./app/src/main/rust"
- name: CMake Cache
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
app/.cxx
@@ -39,7 +37,7 @@ jobs:
restore-keys: ${{ runner.os }}-cmake-
- name: Gradle Build
uses: gradle/gradle-build-action@v2
uses: gradle/actions/setup-gradle@v3
with:
arguments: assembleRelease
@@ -78,7 +76,7 @@ jobs:
mv app/build/outputs/native-debug-symbols/marshmallowGmsRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-gms-native-debug-symbols.zip
- name: Releases
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
body: Bump Version
files: |
+1 -2
View File
@@ -2,8 +2,7 @@
/local.properties
.DS_Store
/build
/src
/.kotlin
*.iml
.idea
/captures
google-services.json
+131 -3
View File
@@ -1,6 +1,134 @@
# 变更日志
## 1.9.0
## [1.11.0] - 2024-03-09
### 重大变更
* 由于 E 站 Cookie 策略变更,不再支持 Cookie 登录
* 登录时自动选择 exhentai (如果可用)
* 不再提供默认下载路径
* 由于数据库结构变更,此版本导出的数据将无法被旧版本导入
### 新功能
* 下载画廊时生成 ComicInfo.xml 格式元数据
* 归档下载时生成 ComicInfo.xml 格式元数据 [#711](https://github.com/FooIbar/EhViewer/issues/711)
* 归档下载支持第三方下载管理器 [#79](https://github.com/FooIbar/EhViewer/issues/79)
* 保存下载结果为 CBZ 压缩包 [#660](https://github.com/FooIbar/EhViewer/issues/660)
* 清除 `igneous` Cookie (将于下次搜索时重新获取)
* 随机打开本地收藏/下载中的画廊 [#695](https://github.com/FooIbar/EhViewer/issues/695)
* 随机在搜索结果中跳转
* 以网格模式查看下载列表
* 以添加时间/上传时间/标题/下载标签/页数对下载列表排序
* 本地收藏/历史/下载中的标签搜索 [#860](https://github.com/FooIbar/EhViewer/issues/860)
### 改进
* 搜索时优先显示已选择的类别卡片
* 多处性能优化
### Bug 修复
* 显示模式为 Thumbnail 时无法点击上传者卡片 [#653](https://github.com/FooIbar/EhViewer/issues/653)
* [Marshmallow] GIF 只显示第一帧
* 无法在 `定时请求新闻页面` 关闭时开启 `隐藏 HV 事件通知`
* 响应正文含有无效 UTF-8 字符导致解码失败 [#643](https://github.com/FooIbar/EhViewer/issues/643)
* 某些情况下应用崩溃 [#710](https://github.com/FooIbar/EhViewer/issues/710)
* 画廊名称包含非法路径字符时无法保存图片 [#815](https://github.com/FooIbar/EhViewer/issues/815)
* 无法解码超长图片 [#732](https://github.com/FooIbar/EhViewer/issues/732)
* E 站变更导致种子解析失败 [#880](https://github.com/FooIbar/EhViewer/issues/880)
* E 站不再支持相似扫描导致图片搜索无法使用
* 杂项修复
### 已知问题
* Android 14 上使用手势导航返回时过渡动画过早结束 [#775](https://github.com/FooIbar/EhViewer/issues/775)
## [1.10.3] - 2024-01-15
### 新功能
* [Marshmallow] 支持裁剪边缘、打开压缩包和 GIF
### 改进
* 由于部分设备上的非预期行为 [#578](https://github.com/FooIbar/EhViewer/issues/578) [#596](https://github.com/FooIbar/EhViewer/issues/596),默认禁用预见式返回导航动画,可于 设置-高级 中开启
* 杂项优化
### Bug 修复
* 刷新后页面无响应 [#579](https://github.com/FooIbar/EhViewer/issues/579)
* 浏览中保存图片后点击操作被阻挡 [#581](https://github.com/FooIbar/EhViewer/issues/581)
* 在画廊详情页面切换至后台后点击评论闪退 [#580](https://github.com/FooIbar/EhViewer/issues/580)
* 已下载的画廊中未阅读缓存过的图片解码失败 [#592](https://github.com/FooIbar/EhViewer/issues/592)
* 在收藏页多选并下载会使得下载状态出现问题 [#603](https://github.com/FooIbar/EhViewer/issues/603)
* 未能在不重启情况下切换 Http 引擎 [#610](https://github.com/FooIbar/EhViewer/issues/610)
* 在有进度的画廊阅读界面无法直接回到第一页 [#614](https://github.com/FooIbar/EhViewer/issues/614)
* 请求新闻界面失效 [#605](https://github.com/FooIbar/EhViewer/issues/605)
* 收藏夹为空解析失败 [#630](https://github.com/FooIbar/EhViewer/issues/630)
## [1.10.2] - 2023-12-31
### 新功能
* [阅读器] 支持裁剪图片黑色/白色边缘
### 改进
* 切换 HTTP 引擎无需重启
* 优化过渡动画
* 杂项性能优化
### Bug 修复
* 修复搜索建议中出现关键词相同的条目时崩溃 [#533](https://github.com/FooIbar/EhViewer/issues/533) [#555](https://github.com/FooIbar/EhViewer/issues/555)
* 修复重命名下载标签时不显示原标签 [#534](https://github.com/FooIbar/EhViewer/issues/534)
* 修复调整下载项目顺序时卡顿 [#552](https://github.com/FooIbar/EhViewer/issues/552)
* 修复新增或修改评论后黑屏 [#554](https://github.com/FooIbar/EhViewer/issues/554)
* 修复剪贴板读取bug [#566](https://github.com/FooIbar/EhViewer/issues/566)
* 修复画廊信息对话框无法通过返回按钮/手势关闭 [#413](https://github.com/FooIbar/EhViewer/issues/413)
## [1.10.1] - 2023-12-19
### 重大变更
* 由于 Apache HttpClient 5 在 Android 10 以下无法使用,后备 HTTP 引擎恢复为 OkHttp [#514](https://github.com/FooIbar/EhViewer/issues/514)
### Bug 修复
* 修复获取 pToken 失败导致无限加载 [#508](https://github.com/FooIbar/EhViewer/issues/508)
* 修复阅读页快速滑动滑块导致下载延时过多累计 [#512](https://github.com/FooIbar/EhViewer/issues/512)
* 修复 Android 9 以下的设备上无法退出搜索栏 [#513](https://github.com/FooIbar/EhViewer/issues/513)
## [1.10.0] - 2023-12-14 [YANKED]
### 重大变更
* 后备 HTTP 引擎变更为 Apache HttpClient 5
* 类别和高级搜索选项移至搜索栏下方
### 新功能
* 支持在搜索时指定画廊语言
### 改进
* 在下载中阅读画廊时会更新历史记录
* 多处性能改进
### Bug 修复
* 修复无法由外部应用进入画廊页面 [#436](https://github.com/FooIbar/EhViewer/issues/436)
* 修复点击精确到页的画廊链接闪退 [#430](https://github.com/FooIbar/EhViewer/issues/430)
* 修复无法搜索本地收藏 [#491](https://github.com/FooIbar/EhViewer/issues/491)
* 修复 CI 版本不显示变更日志
## [1.9.0] - 2023-12-01
### 重大变更
@@ -14,7 +142,7 @@
### 改进
* \[阅读器] 画廊页数过多时隐藏进度条刻度并禁用触感反馈
* [阅读器] 画廊页数过多时隐藏进度条刻度并禁用触感反馈
* 优化原图下载重试策略
* 优化小屏设备上的搜索类别布局
* 优化过渡动画
@@ -32,7 +160,7 @@
* 修复手速过快页面切换过快使加载圈圈不自动消失 [#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)
* [Marshmallow] 修复小内存设备上图片解码失败 [#229](https://github.com/FooIbar/EhViewer/issues/229) [#372](https://github.com/FooIbar/EhViewer/issues/372)
* 修复已舍弃画廊上传者解析
### 已知问题
+3 -4
View File
@@ -73,7 +73,7 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
| OSS | FOSS dependencies** |
| GMS | Proprietary GMS component |
\* Archives and GIFs are not supported
\* May not support some archives
** Fall back to OkHttp on devices that don't support [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine)
@@ -93,9 +93,8 @@ Here is the libraries
- [Arrow](https://arrow-kt.io/)
- [AOSP & AndroidX](http://source.android.com/)
- [Kotlin & KotlinX](https://kotlinlang.org/)
- [MDC-Android](https://github.com/material-components/material-components-android)
- [Material Icons](https://github.com/google/material-design-icons)
- [OkHttp](https://square.github.io/okhttp/)
- [Ktor](https://ktor.io/)
- [Coil](https://coil-kt.github.io/coil/)
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
- [libarchive](http://www.libarchive.org/)
@@ -108,7 +107,7 @@ Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementati
Copyright 2014-2019 Hippo Seven
Copyright 2020-2022 NekoInverter
Copyright 2022-2023 Tarsin Norbin
Copyright 2023 Foolbar
Copyright 2023-2024 Foolbar
EhViewer is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
-6
View File
@@ -1,8 +1,2 @@
/build
/src/main/java-gen
manifest-merger-release-report.txt
/src/main/libs
/src/main/obj
# Intellij
*.iml
/.cxx
+19 -51
View File
@@ -15,14 +15,12 @@ plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.spotless)
alias(libs.plugins.aboutlibrariesPlugin)
alias(libs.plugins.rustAndroidPlugin)
alias(libs.plugins.composeCompilerReportGenerator)
}
android {
compileSdk = 34
buildToolsVersion = "34.0.0"
ndkVersion = "26.1.10909125"
ndkVersion = "26.2.11394342"
splits {
abi {
@@ -64,8 +62,8 @@ android {
applicationId = "moe.tarsin.ehviewer"
minSdk = 28
targetSdk = 34
versionCode = 180046
versionName = "1.9.0"
versionCode = 180051
versionName = "1.11.0"
resourceConfigurations.addAll(
listOf(
"zh",
@@ -100,11 +98,6 @@ android {
minSdk = 23
applicationIdSuffix = ".m"
versionNameSuffix = "-M"
externalNativeBuild {
cmake {
targets += "noop"
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
@@ -130,8 +123,9 @@ android {
"-progressive",
"-Xjvm-default=all",
"-Xlambdas=indy",
"-Xcontext-receivers",
"-opt-in=coil.annotation.ExperimentalCoilApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
@@ -147,7 +141,7 @@ android {
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:intrinsicRemember=true",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:nonSkippingGroupOptimization=true",
)
}
@@ -159,6 +153,7 @@ android {
packaging {
resources {
excludes += "/META-INF/**"
excludes += "/okhttp3/**"
excludes += "/kotlin/**"
excludes += "**.txt"
excludes += "**.bin"
@@ -212,17 +207,15 @@ dependencies {
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.coordinatorlayout)
// https://developer.android.com/jetpack/androidx/releases/lifecycle
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// https://developer.android.com/jetpack/androidx/releases/navigation
implementation(libs.bundles.androidx.navigation)
implementation(libs.androidx.navigation.compose)
// https://developer.android.com/jetpack/androidx/releases/paging
implementation(libs.bundles.androidx.paging)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.recyclerview)
@@ -233,9 +226,7 @@ dependencies {
implementation(libs.androidx.work.runtime)
implementation(libs.photoview) // Dead Dependency
implementation(libs.directionalviewpager) // Dead Dependency
// https://github.com/google/accompanist/releases
implementation(libs.bundles.accompanist)
implementation(libs.material)
implementation(libs.material.motion.core)
implementation(libs.bundles.splitties)
@@ -245,9 +236,13 @@ dependencies {
implementation(libs.okio.jvm)
implementation(libs.logcat)
implementation(libs.diff)
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.accompanist.drawable.painter)
implementation(libs.insetter) // Dead Dependency
@@ -260,6 +255,8 @@ dependencies {
implementation(platform(libs.coil.bom))
implementation(libs.bundles.coil)
implementation(libs.telephoto.zoomable)
implementation(libs.bundles.ktor)
implementation(libs.bundles.kotlinx.serialization)
@@ -268,25 +265,13 @@ dependencies {
implementation(libs.kotlinx.datetime)
implementation(libs.jsoup)
debugImplementation(libs.chucker)
releaseImplementation(libs.chucker.nop)
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(17)
jvmToolchain(21)
}
ksp {
@@ -299,30 +284,13 @@ aboutLibraries {
duplicationRule = GROUP
}
cargo {
module = "src/main/rust"
libname = "ehviewer_rust"
targets = if (isRelease) listOf("arm", "x86", "arm64", "x86_64") else listOf("arm64", "x86_64")
if (isRelease) profile = "release"
}
val ktlintVersion = libs.versions.ktlint.get()
spotless {
kotlin {
// https://github.com/diffplug/spotless/issues/111
target("src/**/*.kt")
ktlint(ktlintVersion)
ktlint()
}
kotlinGradle {
ktlint(ktlintVersion)
}
}
tasks.configureEach {
if (name.startsWith("merge") && name.endsWith("JniLibFolders")) {
dependsOn("cargoBuild")
// fix mergeDebugJniLibFolders UP-TO-DATE
inputs.dir(layout.buildDirectory.dir("rustJniLibs/android"))
ktlint()
}
}
+4 -4
View File
@@ -11,10 +11,10 @@
public static final ** CREATOR;
}
# https://issuetracker.google.com/issues/307323842
-keepclassmembers class androidx.compose.ui.platform.WindowInfoImpl {
setWindowFocused(boolean);
}
# Ktor logger
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn org.conscrypt.Conscrypt
-allowaccessmodification
-repackageclasses
@@ -1,88 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "c801ee62729ba20a203b150211234d5c",
"entities": [
{
"tableName": "OK_HTTP_3_COOKIE",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`NAME` TEXT, `VALUE` TEXT, `EXPIRES_AT` INTEGER NOT NULL, `DOMAIN` TEXT, `PATH` TEXT, `SECURE` INTEGER NOT NULL, `HTTP_ONLY` INTEGER NOT NULL, `PERSISTENT` INTEGER NOT NULL, `HOST_ONLY` INTEGER NOT NULL, `_id` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "value",
"columnName": "VALUE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "expiresAt",
"columnName": "EXPIRES_AT",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "DOMAIN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "path",
"columnName": "PATH",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "secure",
"columnName": "SECURE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpOnly",
"columnName": "HTTP_ONLY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "persistent",
"columnName": "PERSISTENT",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hostOnly",
"columnName": "HOST_ONLY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c801ee62729ba20a203b150211234d5c')"
]
}
}
@@ -1,88 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "1b80cd29939b0f43934721f1289ba94d",
"entities": [
{
"tableName": "OK_HTTP_3_COOKIE",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`NAME` TEXT NOT NULL, `VALUE` TEXT NOT NULL, `EXPIRES_AT` INTEGER NOT NULL, `DOMAIN` TEXT NOT NULL, `PATH` TEXT NOT NULL, `SECURE` INTEGER NOT NULL, `HTTP_ONLY` INTEGER NOT NULL, `PERSISTENT` INTEGER NOT NULL, `HOST_ONLY` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "VALUE",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "expiresAt",
"columnName": "EXPIRES_AT",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "DOMAIN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "PATH",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "secure",
"columnName": "SECURE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpOnly",
"columnName": "HTTP_ONLY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "persistent",
"columnName": "PERSISTENT",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hostOnly",
"columnName": "HOST_ONLY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1b80cd29939b0f43934721f1289ba94d')"
]
}
}
@@ -0,0 +1,471 @@
{
"formatVersion": 1,
"database": {
"version": 17,
"identityHash": "d6375317a9298b7feae5925a23272bb9",
"entities": [
{
"tableName": "GALLERIES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TOKEN` TEXT, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_LANGUAGE` TEXT, `FAVORITE_SLOT` INTEGER NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "TOKEN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbKey",
"columnName": "THUMB",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "posted",
"columnName": "POSTED",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "UPLOADER",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rating",
"columnName": "RATING",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "simpleLanguage",
"columnName": "SIMPLE_LANGUAGE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favoriteSlot",
"columnName": "FAVORITE_SLOT",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "DOWNLOAD_LABELS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LABEL` TEXT NOT NULL, `POSITION` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [
{
"name": "index_DOWNLOAD_LABELS_LABEL",
"unique": true,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DOWNLOAD_LABELS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": []
},
{
"tableName": "DOWNLOADS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `STATE` INTEGER NOT NULL, `LEGACY` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `LABEL` TEXT, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`LABEL`) REFERENCES `DOWNLOAD_LABELS`(`LABEL`) ON UPDATE CASCADE ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "STATE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "legacy",
"columnName": "LEGACY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [
{
"name": "index_DOWNLOADS_LABEL",
"unique": false,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_DOWNLOADS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
},
{
"table": "DOWNLOAD_LABELS",
"onDelete": "SET NULL",
"onUpdate": "CASCADE",
"columns": [
"LABEL"
],
"referencedColumns": [
"LABEL"
]
}
]
},
{
"tableName": "DOWNLOAD_DIRNAME",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `DIRNAME` TEXT NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dirname",
"columnName": "DIRNAME",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "FILTER",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`MODE` INTEGER NOT NULL, `TEXT` TEXT NOT NULL, `ENABLE` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "TEXT",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "ENABLE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HISTORY",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "LOCAL_FAVORITES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "PROGRESS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `PAGE` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "PAGE",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "QUICK_SEARCH",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `NAME` TEXT NOT NULL, `MODE` INTEGER NOT NULL, `CATEGORY` INTEGER NOT NULL, `KEYWORD` TEXT, `ADVANCE_SEARCH` INTEGER NOT NULL, `MIN_RATING` INTEGER NOT NULL, `PAGE_FROM` INTEGER NOT NULL, `PAGE_TO` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyword",
"columnName": "KEYWORD",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "advanceSearch",
"columnName": "ADVANCE_SEARCH",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minRating",
"columnName": "MIN_RATING",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageFrom",
"columnName": "PAGE_FROM",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageTo",
"columnName": "PAGE_TO",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd6375317a9298b7feae5925a23272bb9')"
]
}
}
@@ -0,0 +1,471 @@
{
"formatVersion": 1,
"database": {
"version": 18,
"identityHash": "bc90acf4ffb4e7cce9a16cd5a0f662f3",
"entities": [
{
"tableName": "GALLERIES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TOKEN` TEXT NOT NULL, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_LANGUAGE` TEXT, `FAVORITE_SLOT` INTEGER NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "TOKEN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbKey",
"columnName": "THUMB",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "posted",
"columnName": "POSTED",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "UPLOADER",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rating",
"columnName": "RATING",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "simpleLanguage",
"columnName": "SIMPLE_LANGUAGE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favoriteSlot",
"columnName": "FAVORITE_SLOT",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "DOWNLOAD_LABELS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LABEL` TEXT NOT NULL, `POSITION` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [
{
"name": "index_DOWNLOAD_LABELS_LABEL",
"unique": true,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DOWNLOAD_LABELS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": []
},
{
"tableName": "DOWNLOADS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `STATE` INTEGER NOT NULL, `LEGACY` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `LABEL` TEXT, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`LABEL`) REFERENCES `DOWNLOAD_LABELS`(`LABEL`) ON UPDATE CASCADE ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "STATE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "legacy",
"columnName": "LEGACY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [
{
"name": "index_DOWNLOADS_LABEL",
"unique": false,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_DOWNLOADS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
},
{
"table": "DOWNLOAD_LABELS",
"onDelete": "SET NULL",
"onUpdate": "CASCADE",
"columns": [
"LABEL"
],
"referencedColumns": [
"LABEL"
]
}
]
},
{
"tableName": "DOWNLOAD_DIRNAME",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `DIRNAME` TEXT NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dirname",
"columnName": "DIRNAME",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "FILTER",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`MODE` INTEGER NOT NULL, `TEXT` TEXT NOT NULL, `ENABLE` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "TEXT",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "ENABLE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HISTORY",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "LOCAL_FAVORITES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "PROGRESS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `PAGE` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "PAGE",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "QUICK_SEARCH",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `NAME` TEXT NOT NULL, `MODE` INTEGER NOT NULL, `CATEGORY` INTEGER NOT NULL, `KEYWORD` TEXT, `ADVANCE_SEARCH` INTEGER NOT NULL, `MIN_RATING` INTEGER NOT NULL, `PAGE_FROM` INTEGER NOT NULL, `PAGE_TO` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyword",
"columnName": "KEYWORD",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "advanceSearch",
"columnName": "ADVANCE_SEARCH",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minRating",
"columnName": "MIN_RATING",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageFrom",
"columnName": "PAGE_FROM",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageTo",
"columnName": "PAGE_TO",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bc90acf4ffb4e7cce9a16cd5a0f662f3')"
]
}
}
@@ -0,0 +1,465 @@
{
"formatVersion": 1,
"database": {
"version": 19,
"identityHash": "edd2f8e628072889f7702b50eb2c44a1",
"entities": [
{
"tableName": "GALLERIES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TOKEN` TEXT NOT NULL, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_LANGUAGE` TEXT, `FAVORITE_SLOT` INTEGER NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "TOKEN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbKey",
"columnName": "THUMB",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "posted",
"columnName": "POSTED",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "UPLOADER",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rating",
"columnName": "RATING",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "simpleLanguage",
"columnName": "SIMPLE_LANGUAGE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favoriteSlot",
"columnName": "FAVORITE_SLOT",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "DOWNLOAD_LABELS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LABEL` TEXT NOT NULL, `POSITION` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [
{
"name": "index_DOWNLOAD_LABELS_LABEL",
"unique": true,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DOWNLOAD_LABELS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": []
},
{
"tableName": "DOWNLOADS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `STATE` INTEGER NOT NULL, `LEGACY` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `LABEL` TEXT, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`LABEL`) REFERENCES `DOWNLOAD_LABELS`(`LABEL`) ON UPDATE CASCADE ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "STATE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "legacy",
"columnName": "LEGACY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [
{
"name": "index_DOWNLOADS_LABEL",
"unique": false,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_DOWNLOADS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
},
{
"table": "DOWNLOAD_LABELS",
"onDelete": "SET NULL",
"onUpdate": "CASCADE",
"columns": [
"LABEL"
],
"referencedColumns": [
"LABEL"
]
}
]
},
{
"tableName": "DOWNLOAD_DIRNAME",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `DIRNAME` TEXT NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dirname",
"columnName": "DIRNAME",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "FILTER",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`MODE` INTEGER NOT NULL, `TEXT` TEXT NOT NULL, `ENABLE` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "TEXT",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "ENABLE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HISTORY",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "LOCAL_FAVORITES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "PROGRESS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `PAGE` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "PAGE",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "QUICK_SEARCH",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `NAME` TEXT NOT NULL, `MODE` INTEGER NOT NULL, `CATEGORY` INTEGER NOT NULL, `KEYWORD` TEXT, `ADVANCE_SEARCH` INTEGER NOT NULL, `MIN_RATING` INTEGER NOT NULL, `PAGE_FROM` INTEGER NOT NULL, `PAGE_TO` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyword",
"columnName": "KEYWORD",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "advanceSearch",
"columnName": "ADVANCE_SEARCH",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minRating",
"columnName": "MIN_RATING",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageFrom",
"columnName": "PAGE_FROM",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageTo",
"columnName": "PAGE_TO",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'edd2f8e628072889f7702b50eb2c44a1')"
]
}
}
@@ -0,0 +1,472 @@
{
"formatVersion": 1,
"database": {
"version": 20,
"identityHash": "a8dd0226b286cc57a8c99ba7ad416730",
"entities": [
{
"tableName": "GALLERIES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TOKEN` TEXT NOT NULL, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `PAGES` INTEGER NOT NULL DEFAULT 0, `SIMPLE_LANGUAGE` TEXT, `FAVORITE_SLOT` INTEGER NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "TOKEN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbKey",
"columnName": "THUMB",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "posted",
"columnName": "POSTED",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "UPLOADER",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rating",
"columnName": "RATING",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "pages",
"columnName": "PAGES",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "simpleLanguage",
"columnName": "SIMPLE_LANGUAGE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favoriteSlot",
"columnName": "FAVORITE_SLOT",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "DOWNLOAD_LABELS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LABEL` TEXT NOT NULL, `POSITION` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [
{
"name": "index_DOWNLOAD_LABELS_LABEL",
"unique": true,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DOWNLOAD_LABELS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": []
},
{
"tableName": "DOWNLOADS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `STATE` INTEGER NOT NULL, `LEGACY` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `LABEL` TEXT, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`LABEL`) REFERENCES `DOWNLOAD_LABELS`(`LABEL`) ON UPDATE CASCADE ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "STATE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "legacy",
"columnName": "LEGACY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [
{
"name": "index_DOWNLOADS_LABEL",
"unique": false,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_DOWNLOADS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
},
{
"table": "DOWNLOAD_LABELS",
"onDelete": "SET NULL",
"onUpdate": "CASCADE",
"columns": [
"LABEL"
],
"referencedColumns": [
"LABEL"
]
}
]
},
{
"tableName": "DOWNLOAD_DIRNAME",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `DIRNAME` TEXT NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dirname",
"columnName": "DIRNAME",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "FILTER",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`MODE` INTEGER NOT NULL, `TEXT` TEXT NOT NULL, `ENABLE` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "TEXT",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "ENABLE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HISTORY",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "LOCAL_FAVORITES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "PROGRESS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `PAGE` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "PAGE",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "QUICK_SEARCH",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `NAME` TEXT NOT NULL, `MODE` INTEGER NOT NULL, `CATEGORY` INTEGER NOT NULL, `KEYWORD` TEXT, `ADVANCE_SEARCH` INTEGER NOT NULL, `MIN_RATING` INTEGER NOT NULL, `PAGE_FROM` INTEGER NOT NULL, `PAGE_TO` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyword",
"columnName": "KEYWORD",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "advanceSearch",
"columnName": "ADVANCE_SEARCH",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minRating",
"columnName": "MIN_RATING",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageFrom",
"columnName": "PAGE_FROM",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageTo",
"columnName": "PAGE_TO",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a8dd0226b286cc57a8c99ba7ad416730')"
]
}
}
@@ -0,0 +1,513 @@
{
"formatVersion": 1,
"database": {
"version": 21,
"identityHash": "6a9926f6722f8fc6f9c4426b31260e33",
"entities": [
{
"tableName": "GALLERIES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TOKEN` TEXT NOT NULL, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `PAGES` INTEGER NOT NULL DEFAULT 0, `SIMPLE_LANGUAGE` TEXT, `FAVORITE_SLOT` INTEGER NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "TOKEN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbKey",
"columnName": "THUMB",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "posted",
"columnName": "POSTED",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "UPLOADER",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rating",
"columnName": "RATING",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "pages",
"columnName": "PAGES",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "simpleLanguage",
"columnName": "SIMPLE_LANGUAGE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favoriteSlot",
"columnName": "FAVORITE_SLOT",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "DOWNLOAD_LABELS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LABEL` TEXT NOT NULL, `POSITION` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [
{
"name": "index_DOWNLOAD_LABELS_LABEL",
"unique": true,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DOWNLOAD_LABELS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": []
},
{
"tableName": "DOWNLOADS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `STATE` INTEGER NOT NULL, `LEGACY` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `LABEL` TEXT, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`LABEL`) REFERENCES `DOWNLOAD_LABELS`(`LABEL`) ON UPDATE CASCADE ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "STATE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "legacy",
"columnName": "LEGACY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [
{
"name": "index_DOWNLOADS_LABEL",
"unique": false,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_DOWNLOADS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
},
{
"table": "DOWNLOAD_LABELS",
"onDelete": "SET NULL",
"onUpdate": "CASCADE",
"columns": [
"LABEL"
],
"referencedColumns": [
"LABEL"
]
}
]
},
{
"tableName": "DOWNLOAD_DIRNAME",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `DIRNAME` TEXT NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dirname",
"columnName": "DIRNAME",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "FILTER",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`MODE` INTEGER NOT NULL, `TEXT` TEXT NOT NULL, `ENABLE` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "TEXT",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "ENABLE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HISTORY",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "LOCAL_FAVORITES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "PROGRESS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `PAGE` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "PAGE",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "QUICK_SEARCH",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `NAME` TEXT NOT NULL, `MODE` INTEGER NOT NULL, `CATEGORY` INTEGER NOT NULL, `KEYWORD` TEXT, `ADVANCE_SEARCH` INTEGER NOT NULL, `MIN_RATING` INTEGER NOT NULL, `PAGE_FROM` INTEGER NOT NULL, `PAGE_TO` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyword",
"columnName": "KEYWORD",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "advanceSearch",
"columnName": "ADVANCE_SEARCH",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minRating",
"columnName": "MIN_RATING",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageFrom",
"columnName": "PAGE_FROM",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageTo",
"columnName": "PAGE_TO",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "GALLERIES",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GalleryFts_BEFORE_UPDATE BEFORE UPDATE ON `GALLERIES` BEGIN DELETE FROM `GalleryFts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GalleryFts_BEFORE_DELETE BEFORE DELETE ON `GALLERIES` BEGIN DELETE FROM `GalleryFts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GalleryFts_AFTER_UPDATE AFTER UPDATE ON `GALLERIES` BEGIN INSERT INTO `GalleryFts`(`docid`, `TITLE`, `TITLE_JPN`) VALUES (NEW.`rowid`, NEW.`TITLE`, NEW.`TITLE_JPN`); END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GalleryFts_AFTER_INSERT AFTER INSERT ON `GALLERIES` BEGIN INSERT INTO `GalleryFts`(`docid`, `TITLE`, `TITLE_JPN`) VALUES (NEW.`rowid`, NEW.`TITLE`, NEW.`TITLE_JPN`); END"
],
"tableName": "GalleryFts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`TITLE` TEXT NOT NULL, `TITLE_JPN` TEXT NOT NULL, content=`GALLERIES`)",
"fields": [
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6a9926f6722f8fc6f9c4426b31260e33')"
]
}
}
@@ -0,0 +1,525 @@
{
"formatVersion": 1,
"database": {
"version": 22,
"identityHash": "f61ae3beef3739c0de7db945d2c9a4a4",
"entities": [
{
"tableName": "GALLERIES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TOKEN` TEXT NOT NULL, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_TAGS` TEXT, `PAGES` INTEGER NOT NULL DEFAULT 0, `SIMPLE_LANGUAGE` TEXT, `FAVORITE_SLOT` INTEGER NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "TOKEN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbKey",
"columnName": "THUMB",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "posted",
"columnName": "POSTED",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "UPLOADER",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rating",
"columnName": "RATING",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "simpleTags",
"columnName": "SIMPLE_TAGS",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pages",
"columnName": "PAGES",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "simpleLanguage",
"columnName": "SIMPLE_LANGUAGE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favoriteSlot",
"columnName": "FAVORITE_SLOT",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "DOWNLOAD_LABELS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LABEL` TEXT NOT NULL, `POSITION` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [
{
"name": "index_DOWNLOAD_LABELS_LABEL",
"unique": true,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DOWNLOAD_LABELS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": []
},
{
"tableName": "DOWNLOADS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `STATE` INTEGER NOT NULL, `LEGACY` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `LABEL` TEXT, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`LABEL`) REFERENCES `DOWNLOAD_LABELS`(`LABEL`) ON UPDATE CASCADE ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "STATE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "legacy",
"columnName": "LEGACY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [
{
"name": "index_DOWNLOADS_LABEL",
"unique": false,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_DOWNLOADS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
},
{
"table": "DOWNLOAD_LABELS",
"onDelete": "SET NULL",
"onUpdate": "CASCADE",
"columns": [
"LABEL"
],
"referencedColumns": [
"LABEL"
]
}
]
},
{
"tableName": "DOWNLOAD_DIRNAME",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `DIRNAME` TEXT NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dirname",
"columnName": "DIRNAME",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "FILTER",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`MODE` INTEGER NOT NULL, `TEXT` TEXT NOT NULL, `ENABLE` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "TEXT",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "ENABLE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HISTORY",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "LOCAL_FAVORITES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "PROGRESS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `PAGE` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "PAGE",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "QUICK_SEARCH",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `NAME` TEXT NOT NULL, `MODE` INTEGER NOT NULL, `CATEGORY` INTEGER NOT NULL, `KEYWORD` TEXT, `ADVANCE_SEARCH` INTEGER NOT NULL, `MIN_RATING` INTEGER NOT NULL, `PAGE_FROM` INTEGER NOT NULL, `PAGE_TO` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyword",
"columnName": "KEYWORD",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "advanceSearch",
"columnName": "ADVANCE_SEARCH",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minRating",
"columnName": "MIN_RATING",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageFrom",
"columnName": "PAGE_FROM",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageTo",
"columnName": "PAGE_TO",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "GALLERIES",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_BEFORE_UPDATE BEFORE UPDATE ON `GALLERIES` BEGIN DELETE FROM `GALLERIES_FTS` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_BEFORE_DELETE BEFORE DELETE ON `GALLERIES` BEGIN DELETE FROM `GALLERIES_FTS` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_AFTER_UPDATE AFTER UPDATE ON `GALLERIES` BEGIN INSERT INTO `GALLERIES_FTS`(`docid`, `TITLE`, `TITLE_JPN`, `SIMPLE_TAGS`) VALUES (NEW.`rowid`, NEW.`TITLE`, NEW.`TITLE_JPN`, NEW.`SIMPLE_TAGS`); END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_AFTER_INSERT AFTER INSERT ON `GALLERIES` BEGIN INSERT INTO `GALLERIES_FTS`(`docid`, `TITLE`, `TITLE_JPN`, `SIMPLE_TAGS`) VALUES (NEW.`rowid`, NEW.`TITLE`, NEW.`TITLE_JPN`, NEW.`SIMPLE_TAGS`); END"
],
"tableName": "GALLERIES_FTS",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`TITLE` TEXT NOT NULL, `TITLE_JPN` TEXT NOT NULL, `SIMPLE_TAGS` TEXT, content=`GALLERIES`)",
"fields": [
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "simpleTags",
"columnName": "SIMPLE_TAGS",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f61ae3beef3739c0de7db945d2c9a4a4')"
]
}
}
@@ -1,106 +1,20 @@
package com.hippo.ehviewer.cronet
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.CHROME_ACCEPT
import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
import com.hippo.ehviewer.client.EhCookieStore
import java.nio.ByteBuffer
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.RequestBody
import okio.Buffer
import okio.Path.Companion.toOkioPath
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException
import org.chromium.net.UploadDataProvider
import org.chromium.net.UploadDataSink
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
import splitties.init.appCtx
val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
enableBrotli(true)
setUserAgent(Settings.userAgent)
// Cache Quic hint only since the real cache mechanism should on Ktor layer
val cache = (appCtx.cacheDir.toOkioPath() / "http_cache").toFile().apply { mkdirs() }
setStoragePath(cache.absolutePath)
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 100 * 1024)
setUserAgent(Settings.userAgent)
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096)
addQuicHint("e-hentai.org", 443, 443)
addQuicHint("forums.e-hentai.org", 443, 443)
addQuicHint("exhentai.org", 443, 443)
}.build()
class CronetRequest {
lateinit var consumer: (ByteBuffer) -> Unit
lateinit var onResponse: CronetRequest.(UrlResponseInfo) -> Unit
lateinit var request: UrlRequest
lateinit var onError: (Throwable) -> Unit
lateinit var readerCont: Continuation<Unit>
val callback = object : UrlRequest.Callback() {
override fun onRedirectReceived(req: UrlRequest, info: UrlResponseInfo, url: String) {
req.followRedirect()
}
override fun onResponseStarted(req: UrlRequest, info: UrlResponseInfo) {
onResponse(info)
}
override fun onReadCompleted(req: UrlRequest, info: UrlResponseInfo, data: ByteBuffer) {
consumer(data)
}
override fun onSucceeded(req: UrlRequest, info: UrlResponseInfo) {
readerCont.resume(Unit)
}
override fun onFailed(req: UrlRequest, info: UrlResponseInfo?, e: CronetException) {
onError(e)
}
}
}
inline fun cronetRequest(url: String, referer: String? = null, origin: String? = null, conf: UrlRequest.Builder.() -> Unit = {}) = CronetRequest().apply {
request = cronetHttpClient.newUrlRequestBuilder(url, callback, cronetHttpClientExecutor).apply {
addHeader("Cookie", EhCookieStore.getCookieHeader(url.toHttpUrl()))
addHeader("Accept", CHROME_ACCEPT)
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
referer?.let { addHeader("Referer", it) }
origin?.let { addHeader("Origin", it) }
}.apply(conf).build()
}
suspend inline fun <R> CronetRequest.execute(crossinline callback: suspend CronetRequest.(UrlResponseInfo) -> R): R {
contract {
callsInPlace(callback, InvocationKind.EXACTLY_ONCE)
}
return coroutineScope {
suspendCancellableCoroutine { cont ->
onResponse = { launch { cont.resume(callback(it)) } }
cont.invokeOnCancellation { request.cancel() }
onError = { cont.resumeWithException(it) }
request.start()
}
}
}
fun UrlRequest.Builder.withRequestBody(body: RequestBody) {
addHeader("Content-Type", body.contentType().toString())
val buffer = Buffer().apply { body.writeTo(this) }
val provider = object : UploadDataProvider() {
override fun getLength() = body.contentLength()
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
buffer.read(byteBuffer)
uploadDataSink.onReadSucceeded(false)
}
override fun rewind(uploadDataSink: UploadDataSink) {
error("OneShot!")
}
}
setUploadDataProvider(provider, cronetHttpClientExecutor)
}
fun UrlRequest.Builder.noCache(): UrlRequest.Builder = disableCache()
fun UrlResponseInfo.getHeadersMap(): Map<String, List<String>> = allHeaders
@@ -0,0 +1,176 @@
package com.hippo.ehviewer.ktor
import com.hippo.ehviewer.cronet.cronetDispatcher
import com.hippo.ehviewer.cronet.cronetHttpClient
import com.hippo.ehviewer.cronet.cronetHttpClientExecutor
import com.hippo.ehviewer.cronet.pool
import io.ktor.client.engine.HttpClientEngineBase
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.callContext
import io.ktor.client.request.HttpRequestData
import io.ktor.client.request.HttpResponseData
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpProtocolVersion
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.OutgoingContent
import io.ktor.http.headers
import io.ktor.util.date.GMTDate
import io.ktor.util.flattenForEach
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.InternalAPI
import io.ktor.utils.io.pool.useInstance
import io.ktor.utils.io.writer
import java.nio.ByteBuffer
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.chromium.net.CronetException
import org.chromium.net.UploadDataProvider
import org.chromium.net.UploadDataSink
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
object CronetEngine : HttpClientEngineBase("Cronet") {
override val config = HttpClientEngineConfig()
override val dispatcher = cronetDispatcher
@InternalAPI
override suspend fun execute(data: HttpRequestData): HttpResponseData {
val callContext = callContext()
return executeHttpRequest(callContext, data)
}
@OptIn(DelicateCoroutinesApi::class)
private suspend fun executeHttpRequest(
callContext: CoroutineContext,
data: HttpRequestData,
): HttpResponseData = suspendCancellableCoroutine { continuation ->
val requestTime = GMTDate()
val callback = object : UrlRequest.Callback() {
lateinit var chunkChan: Channel<ByteBuffer>
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) {
continuation.resume(
info.toHttpResponseData(
requestTime = requestTime,
callContext = callContext,
),
)
}
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
val channel = GlobalScope.writer(callContext) {
pool.useInstance {
chunkChan = Channel(1)
request.read(it)
chunkChan.consumeEach { buffer ->
buffer.flip()
channel.writeFully(buffer)
buffer.clear()
request.read(buffer)
}
}
}.channel
continuation.resume(
info.toHttpResponseData(
requestTime = requestTime,
callContext = callContext,
responseBody = channel,
),
)
}
override fun onReadCompleted(request: UrlRequest, info: UrlResponseInfo, byteBuffer: ByteBuffer) {
chunkChan.trySend(byteBuffer).getOrThrow()
}
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
chunkChan.close()
}
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
if (::chunkChan.isInitialized) {
chunkChan.close(error)
} else {
continuation.resumeWithException(error)
}
}
}
val request = cronetHttpClient.newUrlRequestBuilder(data.url.toString(), callback, cronetHttpClientExecutor).apply {
setHttpMethod(data.method.value)
data.headers.flattenForEach { key, value -> addHeader(key, value) }
data.body.contentType?.let { addHeader(HttpHeaders.ContentType, it.toString()) }
data.body.contentLength?.let { addHeader(HttpHeaders.ContentLength, it.toString()) }
data.body.toUploadDataProvider(callContext)?.let { setUploadDataProvider(it, cronetHttpClientExecutor) }
}.build()
request.start()
callContext[Job]!!.invokeOnCompletion { request.cancel() }
continuation.invokeOnCancellation { request.cancel() }
}
}
private fun UrlResponseInfo.toHttpResponseData(
requestTime: GMTDate,
callContext: CoroutineContext,
responseBody: ByteReadChannel = ByteReadChannel.Empty,
): HttpResponseData {
return HttpResponseData(
statusCode = HttpStatusCode.fromValue(httpStatusCode),
requestTime = requestTime,
headers = headers {
allHeaders.forEach { (key, value) ->
appendAll(key, value)
}
},
version = when (negotiatedProtocol) {
"h2" -> HttpProtocolVersion.HTTP_2_0
"h3" -> HttpProtocolVersion.QUIC
"quic/1+spdy/3" -> HttpProtocolVersion.SPDY_3
else -> HttpProtocolVersion.HTTP_1_1
},
body = responseBody,
callContext = callContext,
)
}
@OptIn(DelicateCoroutinesApi::class)
private fun OutgoingContent.toUploadDataProvider(callContext: CoroutineContext): UploadDataProvider? = when (this) {
is OutgoingContent.NoContent -> null
is OutgoingContent.ByteArrayContent -> object : UploadDataProvider() {
val buffer = ByteBuffer.wrap(bytes()).slice()
override fun getLength() = buffer.limit().toLong()
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
byteBuffer.put(buffer)
uploadDataSink.onReadSucceeded(false)
}
override fun rewind(uploadDataSink: UploadDataSink) {
buffer.position(0)
uploadDataSink.onRewindSucceeded()
}
}
is OutgoingContent.WriteChannelContent -> object : UploadDataProvider() {
fun launch() = GlobalScope.writer(callContext) { writeTo(channel) }.channel
var chan = launch()
override fun getLength() = contentLength!!
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
GlobalScope.launch(callContext) {
chan.readAvailable(byteBuffer)
uploadDataSink.onReadSucceeded(false)
}
}
override fun rewind(uploadDataSink: UploadDataSink) {
chan = launch()
uploadDataSink.onRewindSucceeded()
}
}
else -> error("UnsupportedContentType $this")
}
@@ -1,12 +1,11 @@
package com.hippo.ehviewer.util
import com.google.android.gms.net.CronetProviderInstaller
import com.hippo.ehviewer.Settings
import org.chromium.net.CronetProvider
import splitties.init.appCtx
val isCronetSupported: Boolean
get() = Settings.enableCronet && CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
val isCronetAvailable: Boolean
get() = CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
if (!it) {
CronetProviderInstaller.installProvider(appCtx)
}
-6
View File
@@ -37,11 +37,6 @@
tools:targetApi="tiramisu">
<profileable android:shell="true" />
<activity
android:name=".ui.ConfigureActivity"
android:exported="false"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.MainActivity"
android:banner="@mipmap/ic_leanback_banner"
@@ -139,7 +134,6 @@
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<activity android:name=".ui.WebViewActivity" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
+26 -19
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.14)
project(ehviewer C ASM)
project(ehviewer C)
include(FetchContent)
if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
@@ -16,32 +16,31 @@ if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
-mllvm -polly-invariant-load-hoisting")
endif (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
# Change the default path of fetch content to avoid downloading the same dependencies per architecture
get_filename_component(deps "../_deps" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}")
set(FETCHCONTENT_BASE_DIR ${deps})
option(BUILD_TESTING OFF)
set(BUILD_TESTING 0)
FetchContent_Declare(liblzma
FetchContent_Declare(
liblzma
GIT_REPOSITORY https://github.com/FooIbar/xz.git
GIT_TAG v5.4.5
)
GIT_TAG v5.6.1
)
FetchContent_MakeAvailable(liblzma)
include_directories(${liblzma_SOURCE_DIR}/src/liblzma/api)
# Build GNUTLS libnettle
FetchContent_Declare(nettle
FetchContent_Declare(
nettle
URL https://ftp.gnu.org/gnu/nettle/nettle-3.9.1.tar.gz
URL_MD5 29fcd2dec6bf5b48e5e3ffb3cbc4779e
SOURCE_DIR ${CMAKE_CURRENT_LIST_DIR}/nettle/nettle
)
SOURCE_DIR nettle-src/nettle
)
FetchContent_MakeAvailable(nettle)
set(NETTLE_DIR ${nettle_SOURCE_DIR})
add_subdirectory(nettle)
# Configure libnettle support for libarchive
include_directories(nettle)
include_directories(${CMAKE_CURRENT_BINARY_DIR}/nettle-src)
include_directories(.)
set(HAVE_LIBNETTLE 1)
set(HAVE_NETTLE_AES_H 1)
@@ -67,16 +66,24 @@ option(ENABLE_TEST OFF)
# Configure libarchive link's static lib
SET(LIBARCHIVE_CUSTOM_LIBS "nettle" "liblzma")
FetchContent_Declare(libarchive
FetchContent_Declare(
libarchive
GIT_REPOSITORY https://github.com/FooIbar/libarchive.git
GIT_TAG v3.7.2
)
)
FetchContent_MakeAvailable(libarchive)
include_directories(${libarchive_SOURCE_DIR}/libarchive)
# Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c gifutils.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} archive_static log)
FetchContent_Declare(
Corrosion
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
GIT_TAG 0a3bdf452995803c334d79dc9a2affbfad51b720
)
add_custom_target("noop")
FetchContent_MakeAvailable(Corrosion)
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml)
# Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics)
+120 -87
View File
@@ -34,7 +34,6 @@
#include "natsort/strnatcmp.h"
#include "ehviewer.h"
#include "spinlock.h"
typedef struct {
int using;
@@ -49,7 +48,7 @@ typedef struct {
size_t size;
} entry;
_Atomic mcs_lock ctx_lock;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static archive_ctx **ctx_pool;
#define CTX_POOL_SIZE 20
@@ -60,7 +59,6 @@ static size_t *mempoolofs = NULL;
#define MEMPOOL_ADDR_BY_SORTED_IDX(x) (mempool + (index ? mempoolofs[index - 1] : 0))
#define MEMPOOL_SIZE (mempoolofs[entryCount - 1])
#define MEMPOOL_ENTITY_SIZE(x) PAGE_ALIGN(entries[x].size)
#define PROT_RW (PROT_WRITE | PROT_READ)
#define MAP_ANON_POOL (MAP_ANONYMOUS | MAP_NORESERVE | MAP_PRIVATE | MAP_UNINITIALIZED)
@@ -86,6 +84,8 @@ const char supportExt[10][6] = {
};
static inline int filename_is_playable_file(const char *name) {
if (!name)
return false;
const char *dotptr = strrchr(name, '.');
if (!dotptr++)
return false;
@@ -96,17 +96,31 @@ static inline int filename_is_playable_file(const char *name) {
return false;
}
static inline const char *archive_entry_pathname2(struct archive_entry *entry) {
const char *name = archive_entry_pathname_utf8(entry);
return name ? name : archive_entry_pathname(entry);
}
static inline bool archive_entry_is_file(struct archive_entry *entry) {
return archive_entry_filetype(entry) == AE_IFREG;
}
static inline bool archive_entry_is_playable(struct archive_entry *entry) {
return archive_entry_is_file(entry) &&
filename_is_playable_file(archive_entry_pathname2(entry));
}
static inline int compare_entries(const void *a, const void *b) {
const char *fa = ((entry *) a)->filename;
const char *fb = ((entry *) b)->filename;
return strnatcmp(fa, fb);
}
static long archive_map_entries_index(archive_ctx *ctx) {
static long archive_map_entries_index(archive_ctx *ctx, bool sort) {
long count = 0;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
const char *name = archive_entry_pathname(ctx->entry);
if (filename_is_playable_file(name)) {
const char *name = archive_entry_pathname2(ctx->entry);
if (archive_entry_is_file(ctx->entry) && filename_is_playable_file(name)) {
entries[count].filename = strdup(name);
entries[count].index = count;
entries[count].size = archive_entry_size(ctx->entry);
@@ -115,7 +129,8 @@ static long archive_map_entries_index(archive_ctx *ctx) {
}
if (!count)
LOGE("%s", archive_error_string(ctx->arc));
qsort(entries, entryCount, sizeof(entry), compare_entries);
if (sort)
qsort(entries, entryCount, sizeof(entry), compare_entries);
return count;
}
@@ -135,36 +150,26 @@ static bool archive_prealloc_mempool() {
return true;
}
// Mincore does not support anon mapping yet
// Now that we use MADV_FREE, how can we detect whether pages is reclaimed?
static bool is_mempool_pages_present_and_lock(int index) {
void *addr = MEMPOOL_ADDR_BY_SORTED_IDX(index);
size_t size = MEMPOOL_ENTITY_SIZE(index);
mlock(addr, size);
#if 0
unsigned char vec[size / PAGE_SIZE];
unsigned char r = 1;
mincore(addr, size, vec);
for (int i = 0; i < size / PAGE_SIZE; ++i) r &= vec[i];
return r;
#endif
return false;
}
#define MEMPOOL_IN_CORE_AND_LOCK(x) is_mempool_pages_present_and_lock(x)
#define ADDR_IN_FILE_MAPPING(addr) (addr >= archiveAddr && addr < archiveAddr + archiveSize)
static void mempool_release_pages(void *addr, size_t size) {
size = PAGE_ALIGN(size);
munlock(addr, size);
int ret = madvise(addr, size, MADV_FREE);
if (ret == -EINVAL) madvise(addr, size, MADV_DONTNEED);
madvise_log_if_error(addr, size, MADV_DONTNEED);
}
static bool kernel_can_prefault = true;
static void mempool_prefault_pages(void *addr, size_t size) {
size = PAGE_ALIGN(size);
if (kernel_can_prefault) {
if (madvise(addr, size, MADV_POPULATE_WRITE)) kernel_can_prefault = false;
}
}
static long archive_list_all_entries(archive_ctx *ctx) {
long count = 0;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK)
if (filename_is_playable_file(archive_entry_pathname(ctx->entry)))
if (archive_entry_is_playable(ctx->entry))
count++;
if (!count)
LOGE("%s", archive_error_string(ctx->arc));
@@ -212,7 +217,7 @@ static int archive_alloc_ctx(archive_ctx **ctxptr) {
static int archive_skip_to_index(archive_ctx *ctx, int index) {
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
if (!filename_is_playable_file(archive_entry_pathname(ctx->entry)))
if (!archive_entry_is_playable(ctx->entry))
continue;
if (ctx->next_index++ == index) {
return ctx->next_index - 1;
@@ -224,8 +229,7 @@ static int archive_skip_to_index(archive_ctx *ctx, int index) {
static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
int ret;
archive_ctx *ctx = NULL;
mcs_lock_t local_lock;
lock_mcs(&ctx_lock, &local_lock);
pthread_mutex_lock(&mutex);
for (int i = 0; i < CTX_POOL_SIZE; i++) {
if (!ctx_pool[i])
continue;
@@ -240,7 +244,7 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
}
if (ctx)
ctx->using = 1;
unlock_mcs(&ctx_lock, &local_lock);
pthread_mutex_unlock(&mutex);
if (!ctx) {
archive_ctx *victimCtx = NULL;
@@ -249,7 +253,7 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
ret = archive_alloc_ctx(&ctx);
if (ret)
return ret;
lock_mcs(&ctx_lock, &local_lock);
pthread_mutex_lock(&mutex);
for (int i = 0; i < CTX_POOL_SIZE; i++) {
if (!ctx_pool[i]) {
ctx_pool[i] = ctx;
@@ -264,7 +268,7 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
}
}
if (replace) ctx_pool[victimIdx] = ctx;
unlock_mcs(&ctx_lock, &local_lock);
pthread_mutex_unlock(&mutex);
if (replace) archive_release_ctx(victimCtx);
}
ret = archive_skip_to_index(ctx, idx);
@@ -279,19 +283,16 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
}
JNIEXPORT jint JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd,
jlong size) {
Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd, jlong size, jboolean sort_entries) {
EH_UNUSED(env);
EH_UNUSED(thiz);
archive_ctx *ctx = NULL;
int mmap_flags = MAP_PRIVATE;
archiveAddr = mmap64(0, size, PROT_READ, mmap_flags, fd, 0);
archiveAddr = mmap(0, size, PROT_READ, MAP_PRIVATE, fd, 0);
if (archiveAddr == MAP_FAILED) {
LOGE("%s%s", "mmap64 failed with error ", strerror(errno));
LOGE("%s%s", "mmap failed with error ", strerror(errno));
return 0;
}
archiveSize = size;
madvise_log_if_error(archiveAddr, archiveSize, MADV_WILLNEED);
ctx_pool = calloc(CTX_POOL_SIZE, sizeof(archive_ctx **));
if (!ctx_pool) {
LOGE("Allocate archive ctx pool failed:ENOMEM");
@@ -340,7 +341,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
} else {
entries = calloc(entryCount, sizeof(entry));
r = archive_map_entries_index(ctx);
r = archive_map_entries_index(ctx, sort_entries);
if (!archive_prealloc_mempool()) {
r = 0;
for (int i = 0; i < entryCount; ++i) {
@@ -354,40 +355,47 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
return r;
}
#pragma clang diagnostic push
#pragma ide diagnostic ignored "ConstantConditionsOC"
#pragma ide diagnostic ignored "UnreachableCode"
JNIEXPORT jobject JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToByteBuffer(JNIEnv *env, jclass thiz,
jint index) {
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToByteBuffer(JNIEnv *env, jclass thiz, jint index) {
EH_UNUSED(env);
EH_UNUSED(thiz);
void *addr = MEMPOOL_ADDR_BY_SORTED_IDX(index);
size_t size = entries[index].size;
if (!MEMPOOL_IN_CORE_AND_LOCK(index)) {
index = entries[index].index;
archive_ctx *ctx = NULL;
int ret;
ret = archive_get_ctx(&ctx, index);
if (ret) return 0;
ret = archive_read_data(ctx->arc, addr, size);
index = entries[index].index;
archive_ctx *ctx = NULL;
int ret = archive_get_ctx(&ctx, index);
if (ret) return 0;
void *buffer = NULL;
size_t buffer_size = 0;
la_int64_t output_ofs = 0;
archive_read_data_block(ctx->arc, (const void **) &buffer, &buffer_size, &output_ofs);
bool zero_copy = ADDR_IN_FILE_MAPPING(buffer) && !output_ofs && buffer_size == size;
if (zero_copy) {
ctx->using = 0;
if (ret == size)
return (*env)->NewDirectByteBuffer(env, addr, size);
if (ret != size)
LOGE("%s", "No enough data read, WTF?");
if (ret < 0)
LOGE("%s%s", "Archive read failed:", archive_error_string(ctx->arc));
mempool_release_pages(addr, size);
return (*env)->NewDirectByteBuffer(env, buffer, buffer_size);
} else {
return (*env)->NewDirectByteBuffer(env, addr, size);
size_t bytes = 0;
mempool_prefault_pages(addr, size);
do {
memcpy(addr + output_ofs, buffer, buffer_size);
bytes += buffer_size;
ret = archive_read_data_block(ctx->arc, (const void **) &buffer, &buffer_size, &output_ofs);
} while (bytes != size && !ret);
ctx->using = 0;
if (bytes == size) {
return (*env)->NewDirectByteBuffer(env, addr, size);
} else {
if (ret < 0) {
LOGE("%s%s", "Archive read failed:", archive_error_string(ctx->arc));
} else {
LOGE("%s", "No enough data read, WTF?");
}
}
mempool_release_pages(addr, size);
}
return 0;
}
#pragma clang diagnostic pop
JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
EH_UNUSED(env);
@@ -415,8 +423,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_needPassword(JNIEnv *env, jclass thiz) {
}
JNIEXPORT jboolean JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
jstring str) {
Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz, jstring str) {
EH_UNUSED(thiz);
struct archive_entry *entry;
archive_ctx *ctx;
@@ -427,11 +434,11 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
LOGE("Allocate passwd buffer failed");
return false;
}
strcpy(passwd, (*env)->GetStringUTFChars(env, str, NULL));
(*env)->GetStringUTFRegion(env, str, 0, len, passwd);
archive_alloc_ctx(&ctx);
void *tmpBuf = alloca(4096);
while (archive_read_next_header(ctx->arc, &entry) == ARCHIVE_OK) {
if (!filename_is_playable_file(archive_entry_pathname(entry)))
if (!archive_entry_is_playable(entry))
continue;
if (!archive_entry_is_encrypted(entry))
continue;
@@ -446,24 +453,15 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
}
JNIEXPORT jstring JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_getFilename(JNIEnv *env, jclass thiz,
jint index) {
Java_com_hippo_ehviewer_jni_ArchiveKt_getExtension(JNIEnv *env, jclass thiz, jint index) {
EH_UNUSED(env);
EH_UNUSED(thiz);
index = entries[index].index;
archive_ctx *ctx = NULL;
int ret;
ret = archive_get_ctx(&ctx, index);
if (ret)
return NULL;
jstring str = (*env)->NewStringUTF(env, archive_entry_pathname(ctx->entry));
ctx->using = 0;
return str;
const char *ext = strrchr(entries[index].filename, '.') + 1;
return (*env)->NewStringUTF(env, ext);
}
JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToFd(JNIEnv *env, jclass thiz,
jint index, jint fd) {
JNIEXPORT jboolean JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToFd(JNIEnv *env, jclass thiz, jint index, jint fd) {
EH_UNUSED(env);
EH_UNUSED(thiz);
index = entries[index].index;
@@ -471,16 +469,51 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_extractToFd(JNIEnv *env, jclass thiz,
int ret;
ret = archive_get_ctx(&ctx, index);
if (!ret) {
archive_read_data_into_fd(ctx->arc, fd);
ret = archive_read_data_into_fd(ctx->arc, fd);
ctx->using = 0;
}
return ret == ARCHIVE_OK;
}
JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz,
jobject buffer) {
Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz, jobject buffer) {
EH_UNUSED(thiz);
void *addr = (*env)->GetDirectBufferAddress(env, buffer);
size_t size = (*env)->GetDirectBufferCapacity(env, buffer);
mempool_release_pages(addr, size);
if (!ADDR_IN_FILE_MAPPING(addr)) mempool_release_pages(addr, size);
}
JNIEXPORT jboolean JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz, jintArray fd_batch, jobjectArray names, jint arc_fd, jint size) {
EH_UNUSED(clazz);
struct archive *arc = archive_write_new();
struct stat st;
char buff[8192];
jint fdBatch[size];
(*env)->GetIntArrayRegion(env, fd_batch, 0, size, fdBatch);
archive_write_set_format_zip(arc);
archive_write_zip_set_compression_store(arc);
archive_write_open_fd(arc, arc_fd);
struct archive_entry *entry = archive_entry_new();
for (int i = 0; i < size; i++) {
int fd = fdBatch[i];
jobject name = (*env)->GetObjectArrayElement(env, names, i);
const char *cname = (*env)->GetStringUTFChars(env, name, false);
archive_entry_set_pathname(entry, cname);
(*env)->ReleaseStringUTFChars(env, name, cname);
fstat(fd, &st);
archive_entry_copy_stat(entry, &st);
archive_entry_set_perm(entry, 0644);
archive_write_header(arc, entry);
int len = read(fd, buff, sizeof(buff));
while (len > 0) {
archive_write_data(arc, buff, len);
len = read(fd, buff, sizeof(buff));
}
archive_write_finish_entry(arc);
archive_entry_clear(entry);
}
archive_write_close(arc);
archive_write_free(arc);
return true;
}
+241
View File
@@ -0,0 +1,241 @@
#include <jni.h>
#include <math.h>
#include <stdbool.h>
#include <android/bitmap.h>
#include <stdlib.h>
#include <string.h>
/** A line will be considered as having content if 0.25% of it is filled. */
const float filledRatioLimit = 0.0025;
/** When the threshold is closer to 1, less content will be cropped. **/
#define THRESHOLD 0.75
const uint8_t thresholdForBlack = (uint8_t) (255.0 * THRESHOLD);
const uint8_t thresholdForWhite = (uint8_t) (255.0 - 255.0 * THRESHOLD);
static uint8_t inline grey(uint32_t pixel) {
const uint8_t r = ((uint8_t *) &pixel)[0];
const uint8_t g = ((uint8_t *) &pixel)[1];
const uint8_t b = ((uint8_t *) &pixel)[2];
return (r * 77 + g * 151 + b * 28) >> 8;
}
static bool inline isBlackPixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
return grey(pixel) < thresholdForBlack;
}
static bool inline isWhitePixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
return grey(pixel) > thresholdForWhite;
}
/** Return the first x position where there is a substantial amount of fill,
* starting the search from the left. */
uint32_t findBorderLeft(uint32_t *pixels, uint32_t width, uint32_t height,
uint32_t top, uint32_t bottom) {
int x, y;
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
for (y = top; y < bottom; y += 2) {
if (isBlackPixel(pixels, width, 0, y)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, 0, y)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return 0;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan vertical lines in search of filled lines
for (x = 1; x < width; x++) {
uint32_t filledCount = 0;
for (y = top; y < bottom; y += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return x;
}
}
// No fill found... don't crop anything
return 0;
}
/** Return the first x position where there is a substantial amount of fill,
* starting the search from the right. */
uint32_t findBorderRight(uint32_t *pixels, uint32_t width, uint32_t height,
uint32_t top, uint32_t bottom) {
int x, y;
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
uint32_t lastX = width - 1;
for (y = top; y < bottom; y += 2) {
if (isBlackPixel(pixels, width, lastX, y)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, lastX, y)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return width;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan vertical lines in search of filled lines
for (x = width - 2; x > 0; x--) {
uint32_t filledCount = 0;
for (y = top; y < bottom; y += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return x + 1;
}
}
// No fill found... don't crop anything
return width;
}
/** Return the first y position where there is a substantial amount of fill,
* starting the search from the top. */
uint32_t findBorderTop(uint32_t *pixels, uint32_t width, uint32_t height) {
int x, y;
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
for (x = 0; x < width; x += 2) {
if (isBlackPixel(pixels, width, x, 0)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, x, 0)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return 0;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan horizontal lines in search of filled lines
for (y = 1; y < height; y++) {
uint32_t filledCount = 0;
for (x = 0; x < width; x += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return y;
}
}
// No fill found... don't crop anything
return 0;
}
/** Return the first y position where there is a substantial amount of fill,
* starting the search from the bottom. */
uint32_t findBorderBottom(uint32_t *pixels, uint32_t width, uint32_t height) {
int x, y;
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
// Scan first line to detect dominant color
uint32_t whitePixels = 0;
uint32_t blackPixels = 0;
uint32_t lastY = height - 1;
for (x = 0; x < width; x += 2) {
if (isBlackPixel(pixels, width, x, lastY)) {
blackPixels++;
} else if (isWhitePixel(pixels, width, x, lastY)) {
whitePixels++;
}
}
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
if (whitePixels > filledLimit && blackPixels > filledLimit) {
// Mixed fill found... don't crop anything
return height;
} else if (blackPixels > filledLimit) {
detectFunc = isWhitePixel;
}
// Scan horizontal lines in search of filled lines
for (y = height - 2; y > 0; y--) {
uint32_t filledCount = 0;
for (x = 0; x < width; x += 2) {
if (detectFunc(pixels, width, x, y)) {
filledCount++;
}
}
if (filledCount > filledLimit) {
// This line contains enough fill
return y + 1;
}
}
// No fill found... don't crop anything
return height;
}
JNIEXPORT jintArray JNICALL
Java_com_hippo_ehviewer_image_ImageKt_detectBorder(JNIEnv *env, jclass clazz, jobject bitmap) {
void *pixels = NULL;
AndroidBitmapInfo info;
memset(&info, 0, sizeof(info));
AndroidBitmap_getInfo(env, bitmap, &info);
AndroidBitmap_lockPixels(env, bitmap, &pixels);
uint32_t width = info.width;
uint32_t height = info.height;
uint32_t top = findBorderTop(pixels, width, height);
uint32_t bottom = findBorderBottom(pixels, width, height);
uint32_t left = findBorderLeft(pixels, width, height, top, bottom);
uint32_t right = findBorderRight(pixels, width, height, top, bottom);
int array[4] = {left, top, right, bottom};
AndroidBitmap_unlockPixels(env, bitmap);
jintArray ret = (*env)->NewIntArray(env, 4);
(*env)->SetIntArrayRegion(env, ret, 0, 4, array);
return ret;
}
+5 -36
View File
@@ -84,30 +84,6 @@ compare_right(nat_char const *a, nat_char const *b)
} else if (!*a && !*b)
return bias;
}
return 0;
}
static int
compare_left(nat_char const *a, nat_char const *b)
{
/* Compare two left-aligned numbers: the first to have a
different value wins. */
for (;; a++, b++) {
if (!nat_isdigit(*a) && !nat_isdigit(*b))
return 0;
if (!nat_isdigit(*a))
return -1;
if (!nat_isdigit(*b))
return +1;
if (*a < *b)
return -1;
if (*a > *b)
return +1;
}
return 0;
}
@@ -116,30 +92,23 @@ strnatcmp0(nat_char const *a, nat_char const *b, int fold_case)
{
int ai, bi;
nat_char ca, cb;
int fractional, result;
int result;
ai = bi = 0;
while (1) {
ca = a[ai]; cb = b[bi];
/* skip over leading spaces or zeros */
while (nat_isspace(ca))
while (nat_isspace(ca) || ca == '0')
ca = a[++ai];
while (nat_isspace(cb))
while (nat_isspace(cb) || cb == '0')
cb = b[++bi];
/* process run of digits */
if (nat_isdigit(ca) && nat_isdigit(cb)) {
fractional = (ca == '0' || cb == '0');
if (fractional) {
if ((result = compare_left(a+ai, b+bi)) != 0)
return result;
} else {
if ((result = compare_right(a+ai, b+bi)) != 0)
return result;
}
if ((result = compare_right(a+ai, b+bi)) != 0)
return result;
}
if (!ca && !cb) {
-1
View File
@@ -1 +0,0 @@
/nettle
+222 -222
View File
@@ -5,232 +5,232 @@ set(LIBNETTLE_DEFINITIONS
-DHAVE_CONFIG_H)
set(LIBNETTLE_SOURCES
nettle/aes-decrypt-internal.c
nettle/aes-decrypt.c
nettle/aes-decrypt-table.c
nettle/aes128-decrypt.c
nettle/aes192-decrypt.c
nettle/aes256-decrypt.c
nettle/aes-encrypt-internal.c
nettle/aes-encrypt.c
nettle/aes-encrypt-table.c
nettle/aes128-encrypt.c
nettle/aes192-encrypt.c
nettle/aes256-encrypt.c
nettle/aes-invert-internal.c
nettle/aes-set-key-internal.c
nettle/aes-set-encrypt-key.c
nettle/aes-set-decrypt-key.c
nettle/aes128-set-encrypt-key.c
nettle/aes128-set-decrypt-key.c
nettle/aes128-meta.c
nettle/aes192-set-encrypt-key.c
nettle/aes192-set-decrypt-key.c
nettle/aes192-meta.c
nettle/aes256-set-encrypt-key.c
nettle/aes256-set-decrypt-key.c
nettle/aes256-meta.c
nettle/nist-keywrap.c
nettle/arcfour.c
nettle/arctwo.c
nettle/arctwo-meta.c
nettle/blowfish.c
nettle/blowfish-bcrypt.c
nettle/base16-encode.c
nettle/base16-decode.c
nettle/base16-meta.c
nettle/base64-encode.c
nettle/base64-decode.c
nettle/base64-meta.c
nettle/base64url-encode.c
nettle/base64url-decode.c
nettle/base64url-meta.c
nettle/buffer.c
nettle/buffer-init.c
nettle/camellia-crypt-internal.c
nettle/camellia-table.c
nettle/camellia-absorb.c
nettle/camellia-invert-key.c
nettle/camellia128-set-encrypt-key.c
nettle/camellia128-crypt.c
nettle/camellia128-set-decrypt-key.c
nettle/camellia128-meta.c
nettle/camellia192-meta.c
nettle/camellia256-set-encrypt-key.c
nettle/camellia256-crypt.c
nettle/camellia256-set-decrypt-key.c
nettle/camellia256-meta.c
nettle/cast128.c
nettle/cast128-meta.c
nettle/cbc.c
nettle/cbc-aes128-encrypt.c
nettle/cbc-aes192-encrypt.c
nettle/cbc-aes256-encrypt.c
nettle/ccm.c
nettle/ccm-aes128.c
nettle/ccm-aes192.c
nettle/ccm-aes256.c
nettle/cfb.c
nettle/siv-cmac.c
nettle/siv-cmac-aes128.c
nettle/siv-cmac-aes256.c
nettle/cnd-memcpy.c
nettle/chacha-crypt.c
nettle/chacha-core-internal.c
nettle/chacha-poly1305.c
nettle/chacha-poly1305-meta.c
nettle/chacha-set-key.c
nettle/chacha-set-nonce.c
nettle/ctr.c
nettle/ctr16.c
nettle/des.c
nettle/des3.c
nettle/eax.c
nettle/eax-aes128.c
nettle/eax-aes128-meta.c
nettle/ghash-set-key.c
nettle/ghash-update.c
nettle/gcm.c
nettle/gcm-aes.c
nettle/gcm-aes128.c
nettle/gcm-aes128-meta.c
nettle/gcm-aes192.c
nettle/gcm-aes192-meta.c
nettle/gcm-aes256.c
nettle/gcm-aes256-meta.c
nettle/gcm-camellia128.c
nettle/gcm-camellia128-meta.c
nettle/gcm-camellia256.c
nettle/gcm-camellia256-meta.c
nettle/cmac.c
nettle/cmac64.c
nettle/cmac-aes128.c
nettle/cmac-aes256.c
nettle/cmac-des3.c
nettle/cmac-aes128-meta.c
nettle/cmac-aes256-meta.c
nettle/cmac-des3-meta.c
nettle/gost28147.c
nettle/gosthash94.c
nettle/gosthash94-meta.c
nettle/hmac.c
nettle/hmac-gosthash94.c
nettle/hmac-md5.c
nettle/hmac-ripemd160.c
nettle/hmac-sha1.c
nettle/hmac-sha224.c
nettle/hmac-sha256.c
nettle/hmac-sha384.c
nettle/hmac-sha512.c
nettle/hmac-streebog.c
nettle/hmac-sm3.c
nettle/hmac-md5-meta.c
nettle/hmac-ripemd160-meta.c
nettle/hmac-sha1-meta.c
nettle/hmac-sha224-meta.c
nettle/hmac-sha256-meta.c
nettle/hmac-sha384-meta.c
nettle/hmac-sha512-meta.c
nettle/hmac-streebog-meta.c
nettle/hmac-sm3-meta.c
nettle/knuth-lfib.c
nettle/hkdf.c
nettle/md2.c
nettle/md2-meta.c
nettle/md4.c
nettle/md4-meta.c
nettle/md5.c
nettle/md5-compress.c
nettle/md5-compat.c
nettle/md5-meta.c
nettle/memeql-sec.c
nettle/memxor.c
nettle/memxor3.c
nettle/nettle-lookup-hash.c
nettle/nettle-meta-aeads.c
nettle/nettle-meta-armors.c
nettle/nettle-meta-ciphers.c
nettle/nettle-meta-hashes.c
nettle/nettle-meta-macs.c
nettle/pbkdf2.c
nettle/pbkdf2-hmac-gosthash94.c
nettle/pbkdf2-hmac-sha1.c
nettle/pbkdf2-hmac-sha256.c
nettle/pbkdf2-hmac-sha384.c
nettle/pbkdf2-hmac-sha512.c
nettle/poly1305-aes.c
nettle/poly1305-internal.c
nettle/realloc.c
nettle/ripemd160.c
nettle/ripemd160-compress.c
nettle/ripemd160-meta.c
nettle/salsa20-core-internal.c
nettle/salsa20-crypt-internal.c
nettle/salsa20-crypt.c
nettle/salsa20r12-crypt.c
nettle/salsa20-set-key.c
nettle/salsa20-set-nonce.c
nettle/salsa20-128-set-key.c
nettle/salsa20-256-set-key.c
nettle/sha1.c
nettle/sha1-compress.c
nettle/sha1-meta.c
nettle/sha256.c
nettle/sha256-compress-n.c
nettle/sha224-meta.c
nettle/sha256-meta.c
nettle/sha512.c
nettle/sha512-compress.c
nettle/sha384-meta.c
nettle/sha512-meta.c
nettle/sha512-224-meta.c
nettle/sha512-256-meta.c
nettle/sha3.c
nettle/sha3-permute.c
nettle/sha3-224.c
nettle/sha3-224-meta.c
nettle/sha3-256.c
nettle/sha3-256-meta.c
nettle/sha3-384.c
nettle/sha3-384-meta.c
nettle/sha3-512.c
nettle/sha3-512-meta.c
nettle/shake256.c
nettle/sm3.c
nettle/sm3-meta.c
nettle/serpent-set-key.c
nettle/serpent-encrypt.c
nettle/serpent-decrypt.c
nettle/serpent-meta.c
nettle/streebog.c
nettle/streebog-meta.c
nettle/twofish.c
nettle/twofish-meta.c
nettle/umac-nh.c
nettle/umac-nh-n.c
nettle/umac-l2.c
nettle/umac-l3.c
nettle/umac-poly64.c
nettle/umac-poly128.c
nettle/umac-set-key.c
nettle/umac32.c
nettle/umac64.c
nettle/umac96.c
nettle/umac128.c
nettle/version.c
nettle/write-be32.c
nettle/write-le32.c
nettle/write-le64.c
nettle/yarrow256.c
nettle/yarrow_key_event.c
nettle/xts.c
nettle/xts-aes128.c
nettle/xts-aes256.c
${NETTLE_DIR}/aes-decrypt-internal.c
${NETTLE_DIR}/aes-decrypt.c
${NETTLE_DIR}/aes-decrypt-table.c
${NETTLE_DIR}/aes128-decrypt.c
${NETTLE_DIR}/aes192-decrypt.c
${NETTLE_DIR}/aes256-decrypt.c
${NETTLE_DIR}/aes-encrypt-internal.c
${NETTLE_DIR}/aes-encrypt.c
${NETTLE_DIR}/aes-encrypt-table.c
${NETTLE_DIR}/aes128-encrypt.c
${NETTLE_DIR}/aes192-encrypt.c
${NETTLE_DIR}/aes256-encrypt.c
${NETTLE_DIR}/aes-invert-internal.c
${NETTLE_DIR}/aes-set-key-internal.c
${NETTLE_DIR}/aes-set-encrypt-key.c
${NETTLE_DIR}/aes-set-decrypt-key.c
${NETTLE_DIR}/aes128-set-encrypt-key.c
${NETTLE_DIR}/aes128-set-decrypt-key.c
${NETTLE_DIR}/aes128-meta.c
${NETTLE_DIR}/aes192-set-encrypt-key.c
${NETTLE_DIR}/aes192-set-decrypt-key.c
${NETTLE_DIR}/aes192-meta.c
${NETTLE_DIR}/aes256-set-encrypt-key.c
${NETTLE_DIR}/aes256-set-decrypt-key.c
${NETTLE_DIR}/aes256-meta.c
${NETTLE_DIR}/nist-keywrap.c
${NETTLE_DIR}/arcfour.c
${NETTLE_DIR}/arctwo.c
${NETTLE_DIR}/arctwo-meta.c
${NETTLE_DIR}/blowfish.c
${NETTLE_DIR}/blowfish-bcrypt.c
${NETTLE_DIR}/base16-encode.c
${NETTLE_DIR}/base16-decode.c
${NETTLE_DIR}/base16-meta.c
${NETTLE_DIR}/base64-encode.c
${NETTLE_DIR}/base64-decode.c
${NETTLE_DIR}/base64-meta.c
${NETTLE_DIR}/base64url-encode.c
${NETTLE_DIR}/base64url-decode.c
${NETTLE_DIR}/base64url-meta.c
${NETTLE_DIR}/buffer.c
${NETTLE_DIR}/buffer-init.c
${NETTLE_DIR}/camellia-crypt-internal.c
${NETTLE_DIR}/camellia-table.c
${NETTLE_DIR}/camellia-absorb.c
${NETTLE_DIR}/camellia-invert-key.c
${NETTLE_DIR}/camellia128-set-encrypt-key.c
${NETTLE_DIR}/camellia128-crypt.c
${NETTLE_DIR}/camellia128-set-decrypt-key.c
${NETTLE_DIR}/camellia128-meta.c
${NETTLE_DIR}/camellia192-meta.c
${NETTLE_DIR}/camellia256-set-encrypt-key.c
${NETTLE_DIR}/camellia256-crypt.c
${NETTLE_DIR}/camellia256-set-decrypt-key.c
${NETTLE_DIR}/camellia256-meta.c
${NETTLE_DIR}/cast128.c
${NETTLE_DIR}/cast128-meta.c
${NETTLE_DIR}/cbc.c
${NETTLE_DIR}/cbc-aes128-encrypt.c
${NETTLE_DIR}/cbc-aes192-encrypt.c
${NETTLE_DIR}/cbc-aes256-encrypt.c
${NETTLE_DIR}/ccm.c
${NETTLE_DIR}/ccm-aes128.c
${NETTLE_DIR}/ccm-aes192.c
${NETTLE_DIR}/ccm-aes256.c
${NETTLE_DIR}/cfb.c
${NETTLE_DIR}/siv-cmac.c
${NETTLE_DIR}/siv-cmac-aes128.c
${NETTLE_DIR}/siv-cmac-aes256.c
${NETTLE_DIR}/cnd-memcpy.c
${NETTLE_DIR}/chacha-crypt.c
${NETTLE_DIR}/chacha-core-internal.c
${NETTLE_DIR}/chacha-poly1305.c
${NETTLE_DIR}/chacha-poly1305-meta.c
${NETTLE_DIR}/chacha-set-key.c
${NETTLE_DIR}/chacha-set-nonce.c
${NETTLE_DIR}/ctr.c
${NETTLE_DIR}/ctr16.c
${NETTLE_DIR}/des.c
${NETTLE_DIR}/des3.c
${NETTLE_DIR}/eax.c
${NETTLE_DIR}/eax-aes128.c
${NETTLE_DIR}/eax-aes128-meta.c
${NETTLE_DIR}/ghash-set-key.c
${NETTLE_DIR}/ghash-update.c
${NETTLE_DIR}/gcm.c
${NETTLE_DIR}/gcm-aes.c
${NETTLE_DIR}/gcm-aes128.c
${NETTLE_DIR}/gcm-aes128-meta.c
${NETTLE_DIR}/gcm-aes192.c
${NETTLE_DIR}/gcm-aes192-meta.c
${NETTLE_DIR}/gcm-aes256.c
${NETTLE_DIR}/gcm-aes256-meta.c
${NETTLE_DIR}/gcm-camellia128.c
${NETTLE_DIR}/gcm-camellia128-meta.c
${NETTLE_DIR}/gcm-camellia256.c
${NETTLE_DIR}/gcm-camellia256-meta.c
${NETTLE_DIR}/cmac.c
${NETTLE_DIR}/cmac64.c
${NETTLE_DIR}/cmac-aes128.c
${NETTLE_DIR}/cmac-aes256.c
${NETTLE_DIR}/cmac-des3.c
${NETTLE_DIR}/cmac-aes128-meta.c
${NETTLE_DIR}/cmac-aes256-meta.c
${NETTLE_DIR}/cmac-des3-meta.c
${NETTLE_DIR}/gost28147.c
${NETTLE_DIR}/gosthash94.c
${NETTLE_DIR}/gosthash94-meta.c
${NETTLE_DIR}/hmac.c
${NETTLE_DIR}/hmac-gosthash94.c
${NETTLE_DIR}/hmac-md5.c
${NETTLE_DIR}/hmac-ripemd160.c
${NETTLE_DIR}/hmac-sha1.c
${NETTLE_DIR}/hmac-sha224.c
${NETTLE_DIR}/hmac-sha256.c
${NETTLE_DIR}/hmac-sha384.c
${NETTLE_DIR}/hmac-sha512.c
${NETTLE_DIR}/hmac-streebog.c
${NETTLE_DIR}/hmac-sm3.c
${NETTLE_DIR}/hmac-md5-meta.c
${NETTLE_DIR}/hmac-ripemd160-meta.c
${NETTLE_DIR}/hmac-sha1-meta.c
${NETTLE_DIR}/hmac-sha224-meta.c
${NETTLE_DIR}/hmac-sha256-meta.c
${NETTLE_DIR}/hmac-sha384-meta.c
${NETTLE_DIR}/hmac-sha512-meta.c
${NETTLE_DIR}/hmac-streebog-meta.c
${NETTLE_DIR}/hmac-sm3-meta.c
${NETTLE_DIR}/knuth-lfib.c
${NETTLE_DIR}/hkdf.c
${NETTLE_DIR}/md2.c
${NETTLE_DIR}/md2-meta.c
${NETTLE_DIR}/md4.c
${NETTLE_DIR}/md4-meta.c
${NETTLE_DIR}/md5.c
${NETTLE_DIR}/md5-compress.c
${NETTLE_DIR}/md5-compat.c
${NETTLE_DIR}/md5-meta.c
${NETTLE_DIR}/memeql-sec.c
${NETTLE_DIR}/memxor.c
${NETTLE_DIR}/memxor3.c
${NETTLE_DIR}/nettle-lookup-hash.c
${NETTLE_DIR}/nettle-meta-aeads.c
${NETTLE_DIR}/nettle-meta-armors.c
${NETTLE_DIR}/nettle-meta-ciphers.c
${NETTLE_DIR}/nettle-meta-hashes.c
${NETTLE_DIR}/nettle-meta-macs.c
${NETTLE_DIR}/pbkdf2.c
${NETTLE_DIR}/pbkdf2-hmac-gosthash94.c
${NETTLE_DIR}/pbkdf2-hmac-sha1.c
${NETTLE_DIR}/pbkdf2-hmac-sha256.c
${NETTLE_DIR}/pbkdf2-hmac-sha384.c
${NETTLE_DIR}/pbkdf2-hmac-sha512.c
${NETTLE_DIR}/poly1305-aes.c
${NETTLE_DIR}/poly1305-internal.c
${NETTLE_DIR}/realloc.c
${NETTLE_DIR}/ripemd160.c
${NETTLE_DIR}/ripemd160-compress.c
${NETTLE_DIR}/ripemd160-meta.c
${NETTLE_DIR}/salsa20-core-internal.c
${NETTLE_DIR}/salsa20-crypt-internal.c
${NETTLE_DIR}/salsa20-crypt.c
${NETTLE_DIR}/salsa20r12-crypt.c
${NETTLE_DIR}/salsa20-set-key.c
${NETTLE_DIR}/salsa20-set-nonce.c
${NETTLE_DIR}/salsa20-128-set-key.c
${NETTLE_DIR}/salsa20-256-set-key.c
${NETTLE_DIR}/sha1.c
${NETTLE_DIR}/sha1-compress.c
${NETTLE_DIR}/sha1-meta.c
${NETTLE_DIR}/sha256.c
${NETTLE_DIR}/sha256-compress-n.c
${NETTLE_DIR}/sha224-meta.c
${NETTLE_DIR}/sha256-meta.c
${NETTLE_DIR}/sha512.c
${NETTLE_DIR}/sha512-compress.c
${NETTLE_DIR}/sha384-meta.c
${NETTLE_DIR}/sha512-meta.c
${NETTLE_DIR}/sha512-224-meta.c
${NETTLE_DIR}/sha512-256-meta.c
${NETTLE_DIR}/sha3.c
${NETTLE_DIR}/sha3-permute.c
${NETTLE_DIR}/sha3-224.c
${NETTLE_DIR}/sha3-224-meta.c
${NETTLE_DIR}/sha3-256.c
${NETTLE_DIR}/sha3-256-meta.c
${NETTLE_DIR}/sha3-384.c
${NETTLE_DIR}/sha3-384-meta.c
${NETTLE_DIR}/sha3-512.c
${NETTLE_DIR}/sha3-512-meta.c
${NETTLE_DIR}/shake256.c
${NETTLE_DIR}/sm3.c
${NETTLE_DIR}/sm3-meta.c
${NETTLE_DIR}/serpent-set-key.c
${NETTLE_DIR}/serpent-encrypt.c
${NETTLE_DIR}/serpent-decrypt.c
${NETTLE_DIR}/serpent-meta.c
${NETTLE_DIR}/streebog.c
${NETTLE_DIR}/streebog-meta.c
${NETTLE_DIR}/twofish.c
${NETTLE_DIR}/twofish-meta.c
${NETTLE_DIR}/umac-nh.c
${NETTLE_DIR}/umac-nh-n.c
${NETTLE_DIR}/umac-l2.c
${NETTLE_DIR}/umac-l3.c
${NETTLE_DIR}/umac-poly64.c
${NETTLE_DIR}/umac-poly128.c
${NETTLE_DIR}/umac-set-key.c
${NETTLE_DIR}/umac32.c
${NETTLE_DIR}/umac64.c
${NETTLE_DIR}/umac96.c
${NETTLE_DIR}/umac128.c
${NETTLE_DIR}/version.c
${NETTLE_DIR}/write-be32.c
${NETTLE_DIR}/write-le32.c
${NETTLE_DIR}/write-le64.c
${NETTLE_DIR}/yarrow256.c
${NETTLE_DIR}/yarrow_key_event.c
${NETTLE_DIR}/xts.c
${NETTLE_DIR}/xts-aes128.c
${NETTLE_DIR}/xts-aes256.c
)
set(LIBNETTLE_INCLUDES
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/nettle
${NETTLE_DIR}
)
add_library(nettle STATIC ${LIBNETTLE_SOURCES})
-44
View File
@@ -1,44 +0,0 @@
#ifndef EHVIEWER_SPINLOCK_H
#define EHVIEWER_SPINLOCK_H
#include <stddef.h>
#include <stdatomic.h>
#define cmpxchg(P, O, N) __sync_val_compare_and_swap((P), (O), (N))
#define barrier() __asm__ __volatile__("":::"memory")
#if defined(__aarch64__) || defined(__arm__)
#define cpu_relax() __asm__ volatile ("wfe");
#else
#define cpu_relax() __asm__ volatile ("pause" ::: "memory");
#endif
typedef struct mcs_lock_t mcs_lock_t;
struct mcs_lock_t {
mcs_lock_t *next;
int spin;
};
typedef struct mcs_lock_t *mcs_lock;
static inline void lock_mcs(_Atomic mcs_lock *m, mcs_lock_t *me) {
mcs_lock_t *tail;
me->next = NULL;
me->spin = 0;
tail = atomic_exchange(m, me);
if (!tail) return;
tail->next = me;
barrier();
while (!me->spin) cpu_relax();
return;
}
static inline void unlock_mcs(mcs_lock *m, mcs_lock_t *me) {
if (!me->next) {
if (cmpxchg(m, me, NULL) == me) return;
while (!me->next) cpu_relax();
}
me->next->spin = 1;
}
#endif //EHVIEWER_SPINLOCK_H
@@ -1,989 +0,0 @@
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
package androidx.compose.material3
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PixelFormat
import android.os.Build
import android.view.Gravity
import android.view.View
import android.view.ViewTreeObserver
import android.view.WindowManager
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.ViewRootForInspector
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.popup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
import com.hippo.ehviewer.util.isAtLeastT
import java.util.UUID
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
@Stable
@ExperimentalMaterial3Api
internal object AnchoredDraggableDefaults2 {
/**
* The default animation used by [AnchoredDraggableState].
*/
@get:ExperimentalMaterial3Api
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@ExperimentalMaterial3Api
val AnimationSpec = SpringSpec<Float>()
}
/**
* State of a sheet composable, such as [ModalBottomSheetFix]
*
* Contains states relating to its swipe position as well as animations between state values.
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
* interaction.
* @param initialValue The initial value of the state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
* programmatically or by user interaction.
*/
@Stable
@ExperimentalMaterial3Api
class SheetState2
@Deprecated(
message = "This constructor is deprecated. " +
"Please use the constructor that provides a [Density]",
replaceWith = ReplaceWith(
"SheetState(" +
"skipPartiallyExpanded, LocalDensity.current, initialValue, " +
"confirmValueChange, skipHiddenState)",
),
)
constructor(
internal val skipPartiallyExpanded: Boolean,
initialValue: SheetValue = SheetValue.Hidden,
val confirmValueChange: (SheetValue) -> Boolean = { true },
internal val skipHiddenState: Boolean = false,
) {
/**
* State of a sheet composable, such as [ModalBottomSheetFix]
*
* Contains states relating to its swipe position as well as animations between state values.
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
* interaction.
* @param initialValue The initial value of the state.
* @param density The density that this state can use to convert values to and from dp.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
* programmatically or by user interaction.
*/
@ExperimentalMaterial3Api
@Suppress("Deprecation")
constructor(
skipPartiallyExpanded: Boolean,
density: Density,
initialValue: SheetValue = SheetValue.Hidden,
confirmValueChange: (SheetValue) -> Boolean = { true },
skipHiddenState: Boolean = false,
) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) {
this.density = density
}
init {
if (skipPartiallyExpanded) {
require(initialValue != SheetValue.PartiallyExpanded) {
"The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
"is set to true."
}
}
if (skipHiddenState) {
require(initialValue != SheetValue.Hidden) {
"The initial value must not be set to Hidden if skipHiddenState is set to true."
}
}
}
/**
* The current value of the state.
*
* If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
* currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
* was in before the swipe or animation started.
*/
val currentValue: SheetValue get() = anchoredDraggableState.currentValue
/**
* The target value of the bottom sheet state.
*
* If a swipe is in progress, this is the value that the sheet would animate to if the
* swipe finishes. If an animation is running, this is the target value of that animation.
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
*/
val targetValue: SheetValue get() = anchoredDraggableState.targetValue
/**
* Whether the modal bottom sheet is visible.
*/
val isVisible: Boolean
get() = anchoredDraggableState.currentValue != SheetValue.Hidden
/**
* Require the current offset (in pixels) of the bottom sheet.
*
* The offset will be initialized during the first measurement phase of the provided sheet
* content.
*
* These are the phases:
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
*
* During the first composition, an [IllegalStateException] is thrown. In subsequent
* compositions, the offset will be derived from the anchors of the previous pass. Always prefer
* accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
* frame, after layout.
*
* @throws IllegalStateException If the offset has not been initialized yet
*/
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
/**
* Whether the sheet has an expanded state defined.
*/
val hasExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Expanded)
/**
* Whether the modal bottom sheet has a partially expanded state defined.
*/
val hasPartiallyExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.PartiallyExpanded)
/**
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
* animation has been cancelled.
* *
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun expand() {
anchoredDraggableState.animateTo(SheetValue.Expanded)
}
/**
* Animate the bottom sheet and suspend until it is partially expanded or animation has been
* cancelled.
* @throws [CancellationException] if the animation is interrupted
* @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
*/
suspend fun partialExpand() {
check(!skipPartiallyExpanded) {
"Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
" skipPartiallyExpanded to false to use this function."
}
animateTo(SheetValue.PartiallyExpanded)
}
/**
* Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
* else [Expanded].
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun show() {
val targetValue = when {
hasPartiallyExpandedState -> SheetValue.PartiallyExpanded
else -> SheetValue.Expanded
}
animateTo(targetValue)
}
/**
* Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
* been cancelled.
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun hide() {
check(!skipHiddenState) {
"Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
" to false to use this function."
}
animateTo(SheetValue.Hidden)
}
/**
* Animate to a [targetValue].
* If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
* [targetValue] without updating the offset.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
internal suspend fun animateTo(
targetValue: SheetValue,
velocity: Float = anchoredDraggableState.lastVelocity,
) {
anchoredDraggableState.animateTo(targetValue, velocity)
}
/**
* Snap to a [targetValue] without any animation.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
internal suspend fun snapTo(targetValue: SheetValue) {
anchoredDraggableState.snapTo(targetValue)
}
/**
* Find the closest anchor taking into account the velocity and settle at it with an animation.
*/
internal suspend fun settle(velocity: Float) {
anchoredDraggableState.settle(velocity)
}
internal var anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
animationSpec = AnchoredDraggableDefaults2.AnimationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } },
velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } },
)
internal val offset: Float get() = anchoredDraggableState.offset
internal var density: Density? = null
private fun requireDensity() = requireNotNull(density) {
"SheetState did not have a density attached. Are you using SheetState with " +
"BottomSheetScaffold or ModalBottomSheet component?"
}
companion object {
/**
* The default [Saver] implementation for [SheetState2].
*/
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean,
density: Density,
) = Saver<SheetState2, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
SheetState2(skipPartiallyExpanded, density, savedValue, confirmValueChange)
},
)
/**
* The default [Saver] implementation for [SheetState2].
*/
@Deprecated(
message = "This function is deprecated. Please use the overload where Density is" +
" provided.",
replaceWith = ReplaceWith(
"Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)",
),
)
@Suppress("Deprecation")
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean,
) = Saver<SheetState2, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
SheetState2(skipPartiallyExpanded, savedValue, confirmValueChange)
},
)
}
}
/**
* <a href="https://m3.material.io/components/bottom-sheets/overview" class="external" target="_blank">Material Design modal bottom sheet</a>.
*
* Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile,
* especially when offering a long list of action items, or when items require longer descriptions
* and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other
* app functionality when they appear, and remaining on screen until confirmed, dismissed, or a
* required action has been taken.
*
* ![Bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material3/bottom_sheet.png)
*
* A simple example of a modal bottom sheet looks like this:
*
* @sample androidx.compose.material3.samples.ModalBottomSheetSample
*
* @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet
* animates to [Hidden].
* @param modifier Optional [Modifier] for the bottom sheet.
* @param sheetState The state of the bottom sheet.
* @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take.
* Pass in [Dp.Unspecified] for a sheet that spans the entire screen width.
* @param shape The shape of the bottom sheet.
* @param containerColor The color used for the background of this bottom sheet
* @param contentColor The preferred color for content inside this bottom sheet. Defaults to either
* the matching content color for [containerColor], or to the current [LocalContentColor] if
* [containerColor] is not a color from the theme.
* @param tonalElevation The tonal elevation of this bottom sheet.
* @param scrimColor Color of the scrim that obscures content when the bottom sheet is open.
* @param dragHandle Optional visual marker to swipe the bottom sheet.
* @param windowInsets window insets to be passed to the bottom sheet window via [PaddingValues]
* params.
* @param properties [ModalBottomSheetProperties2] for further customization of this
* modal bottom sheet's behavior.
* @param content The content to be displayed inside the bottom sheet.
*/
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheetFix(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState2 = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
properties: ModalBottomSheetProperties2 = ModalBottomSheetDefaults2.properties(),
content: @Composable ColumnScope.() -> Unit,
) {
// b/291735717 Remove this once deprecated methods without density are removed
val density = LocalDensity.current
SideEffect {
sheetState.density = density
}
val scope = rememberCoroutineScope()
val animateToDismiss: () -> Unit = {
if (sheetState.confirmValueChange(SheetValue.Hidden)) {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) {
onDismissRequest()
}
}
}
}
LaunchedEffect(sheetState) {
snapshotFlow {
sheetState.targetValue == SheetValue.Hidden && !sheetState.isVisible
}.drop(1).collectLatest {
if (it) onDismissRequest()
}
}
val settleToDismiss: (velocity: Float) -> Unit = {
scope.launch { sheetState.settle(it) }.invokeOnCompletion {
if (!sheetState.isVisible) onDismissRequest()
}
}
val popupOnDismissRequest: () -> Unit = {
if (sheetState.currentValue == SheetValue.Expanded && sheetState.hasPartiallyExpandedState) {
scope.launch { sheetState.partialExpand() }
} else { // Is expanded without collapsed state or is collapsed.
scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() }
}
}
ModalBottomSheetPopup(
properties = properties,
onDismissRequest = popupOnDismissRequest,
windowInsets = windowInsets,
) {
val configuration = LocalConfiguration.current
val widthDp = configuration.screenWidthDp.dp
val height = configuration.screenHeightDp.dp
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(true) {
sheetState.hide()
onDismissRequest()
}
val predictiveModifier = Modifier.graphicsLayer {
val scale = lerp(1f, 0.90f, predictiveState.absoluteValue)
scaleX = scale
scaleY = scale
}.offset {
val ofsY = lerp(0f, height.toPx() * 0.0625f, predictiveState.absoluteValue).roundToInt()
val ofsX = lerp(0f, widthDp.toPx() * 0.025f, predictiveState).roundToInt()
IntOffset(ofsX, ofsY)
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val fullHeight = constraints.maxHeight
Scrim(
color = scrimColor,
onDismissRequest = animateToDismiss,
visible = sheetState.targetValue != SheetValue.Hidden,
)
Surface(
modifier = modifier
.widthIn(max = sheetMaxWidth)
.fillMaxWidth()
.align(Alignment.TopCenter)
.offset {
IntOffset(
0,
sheetState
.requireOffset()
.toInt(),
)
} then predictiveModifier
.nestedScroll(
remember(sheetState) {
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState = sheetState,
orientation = Orientation.Vertical,
onFling = settleToDismiss,
)
},
)
.anchoredDraggable(
state = sheetState.anchoredDraggableState,
orientation = Orientation.Vertical,
enabled = sheetState.isVisible,
startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
)
.modalBottomSheetAnchors(
sheetState = sheetState,
fullHeight = fullHeight.toFloat(),
),
shape = shape,
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
) {
Column(Modifier.fillMaxWidth()) {
if (dragHandle != null) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
dragHandle()
}
}
content()
}
}
}
}
if (sheetState.hasExpandedState) {
LaunchedEffect(sheetState) {
sheetState.show()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState: SheetState2,
orientation: Orientation,
onFling: (velocity: Float) -> Unit,
): NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
return if (source == NestedScrollSource.Drag) {
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat()
val currentOffset = sheetState.requireOffset()
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
return if (toFling < 0 && currentOffset > minAnchor) {
onFling(toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
onFling(available.toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(
x = if (orientation == Orientation.Horizontal) this else 0f,
y = if (orientation == Orientation.Vertical) this else 0f,
)
@JvmName("velocityToFloat")
private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
@JvmName("offsetToFloat")
private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
}
/**
* Properties used to customize the behavior of a [ModalBottomSheetFix].
*
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
* sheet's window.
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
* the modal bottom sheet will receive IME events and key presses, such as when
* the back button is pressed.
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
* the back button. If true, pressing the back button will call onDismissRequest.
* Note that [isFocusable] must be set to true in order to receive key events such as
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
*/
@ExperimentalMaterial3Api
class ModalBottomSheetProperties2(
val securePolicy: SecureFlagPolicy,
val isFocusable: Boolean,
val shouldDismissOnBackPress: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ModalBottomSheetProperties2) return false
if (securePolicy != other.securePolicy) return false
if (isFocusable != other.isFocusable) return false
if (shouldDismissOnBackPress != other.shouldDismissOnBackPress) return false
return true
}
override fun hashCode(): Int {
var result = securePolicy.hashCode()
result = 31 * result + isFocusable.hashCode()
result = 31 * result + shouldDismissOnBackPress.hashCode()
return result
}
}
/**
* Default values for [ModalBottomSheetFix]
*/
@Immutable
@ExperimentalMaterial3Api
object ModalBottomSheetDefaults2 {
/**
* Properties used to customize the behavior of a [ModalBottomSheetFix].
*
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
* sheet's window.
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
* the modal bottom sheet will receive IME events and key presses, such as when
* the back button is pressed.
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
* the back button. If true, pressing the back button will call onDismissRequest.
* Note that [isFocusable] must be set to true in order to receive key events such as
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
*/
fun properties(
securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
isFocusable: Boolean = true,
shouldDismissOnBackPress: Boolean = true,
) = ModalBottomSheetProperties2(securePolicy, isFocusable, shouldDismissOnBackPress)
}
/**
* Create and [remember] a [SheetState2] for [ModalBottomSheetFix].
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is tall enough,
* should be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
* [Hidden] state when hiding the sheet, either programmatically or by user interaction.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
@ExperimentalMaterial3Api
fun rememberModalBottomSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
) = rememberSheetState(skipPartiallyExpanded, confirmValueChange, SheetValue.Hidden)
@Composable
@ExperimentalMaterial3Api
internal fun rememberSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
initialValue: SheetValue = SheetValue.Hidden,
skipHiddenState: Boolean = false,
): SheetState2 {
val density = LocalDensity.current
return rememberSaveable(
skipPartiallyExpanded,
confirmValueChange,
saver = SheetState2.Saver(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmValueChange = confirmValueChange,
density = density,
),
) {
SheetState2(
skipPartiallyExpanded,
density,
initialValue,
confirmValueChange,
skipHiddenState,
)
}
}
@Composable
private fun Scrim(
color: Color,
onDismissRequest: () -> Unit,
visible: Boolean,
) {
if (color.isSpecified) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec(),
label = "scrim",
)
val dismissSheet = if (visible) {
Modifier
.pointerInput(onDismissRequest) {
detectTapGestures {
onDismissRequest()
}
}
.clearAndSetSemantics {}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissSheet),
) {
drawRect(color = color, alpha = alpha)
}
}
}
@ExperimentalMaterial3Api
private fun Modifier.modalBottomSheetAnchors(
sheetState: SheetState2,
fullHeight: Float,
) = onSizeChanged { sheetSize ->
val newAnchors = DraggableAnchors {
SheetValue.Hidden at fullHeight
if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) {
SheetValue.PartiallyExpanded at fullHeight / 2f
}
if (sheetSize.height != 0) {
SheetValue.Expanded at max(0f, fullHeight - sheetSize.height)
}
}
val newTarget = when (sheetState.anchoredDraggableState.targetValue) {
SheetValue.Hidden -> SheetValue.Hidden
SheetValue.PartiallyExpanded, SheetValue.Expanded -> {
val hasPartiallyExpandedState = newAnchors.hasAnchorFor(SheetValue.PartiallyExpanded)
val newTarget = if (hasPartiallyExpandedState) {
SheetValue.PartiallyExpanded
} else if (newAnchors.hasAnchorFor(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.Hidden
newTarget
}
}
sheetState.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
}
/**
* Popup specific for modal bottom sheet.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ModalBottomSheetPopup(
properties: ModalBottomSheetProperties2,
onDismissRequest: () -> Unit,
windowInsets: WindowInsets,
content: @Composable () -> Unit,
) {
val view = LocalView.current
val id = rememberSaveable { UUID.randomUUID() }
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
val layoutDirection = LocalLayoutDirection.current
val configuration = LocalConfiguration.current
val modalBottomSheetWindow = remember(configuration) {
ModalBottomSheetWindow2(
properties = properties,
onDismissRequest = onDismissRequest,
composeView = view,
saveId = id,
).apply {
setCustomContent(
parent = parentComposition,
content = {
Box(
Modifier
.semantics { this.popup() }
.windowInsetsPadding(windowInsets)
.then(
// TODO(b/290893168): Figure out a solution for APIs < 30.
if (Build.VERSION.SDK_INT >= 33) {
Modifier.imePadding()
} else {
Modifier
},
),
) {
currentContent()
}
},
)
}
}
DisposableEffect(modalBottomSheetWindow) {
modalBottomSheetWindow.show()
modalBottomSheetWindow.superSetLayoutDirection(layoutDirection)
onDispose {
modalBottomSheetWindow.disposeComposition()
modalBottomSheetWindow.dismiss()
}
}
}
/** Custom compose view for [ModalBottomSheetFix] */
@SuppressLint("ViewConstructor")
@OptIn(ExperimentalMaterial3Api::class)
private class ModalBottomSheetWindow2(
private val properties: ModalBottomSheetProperties2,
private var onDismissRequest: () -> Unit,
private val composeView: View,
saveId: UUID,
) : AbstractComposeView(composeView.context),
ViewTreeObserver.OnGlobalLayoutListener,
ViewRootForInspector,
OnBackPressedDispatcherOwner {
init {
id = android.R.id.content
// Set up view owners
setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId")
// Enable children to draw their shadow by not clipping them
clipChildren = false
}
override val lifecycle = composeView.findViewTreeLifecycleOwner()!!.lifecycle
override val onBackPressedDispatcher = OnBackPressedDispatcher {
onDismissRequest()
}
private val windowManager =
composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val displayWidth: Int
get() = context.resources.displayMetrics.widthPixels
private val params: WindowManager.LayoutParams =
WindowManager.LayoutParams().apply {
// Position bottom sheet from the bottom of the screen
gravity = Gravity.BOTTOM or Gravity.START
// Application panel window
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
// Fill up the entire app view
width = displayWidth
height = WindowManager.LayoutParams.MATCH_PARENT
// Format of screen pixels
format = PixelFormat.TRANSLUCENT
// Title used as fallback for a11y services
// TODO: Provide bottom sheet window resource
title = composeView.context.resources.getString(
androidx.compose.ui.R.string.default_popup_window_title,
)
// Get the Window token from the parent view
token = composeView.applicationWindowToken
// Flags specific to modal bottom sheet.
flags = flags and (
WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
).inv()
flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
// Security flag
val secureFlagEnabled =
properties.securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled())
if (secureFlagEnabled) {
flags = flags or WindowManager.LayoutParams.FLAG_SECURE
} else {
flags = flags and (WindowManager.LayoutParams.FLAG_SECURE.inv())
}
// Focusable
if (!properties.isFocusable) {
flags = flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
} else {
flags = flags and (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv())
}
}
private var content: @Composable () -> Unit by mutableStateOf({})
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
private set
@Composable
override fun Content() {
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides this) {
content()
}
}
fun setCustomContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit,
) {
parent?.let { setParentCompositionContext(it) }
this.content = content
shouldCreateCompositionOnAttachedToWindow = true
}
fun show() {
windowManager.addView(this, params)
if (isAtLeastT) {
onBackPressedDispatcher.setOnBackInvokedDispatcher(findOnBackInvokedDispatcher()!!)
}
}
fun dismiss() {
setViewTreeLifecycleOwner(null)
setViewTreeSavedStateRegistryOwner(null)
composeView.viewTreeObserver.removeOnGlobalLayoutListener(this)
windowManager.removeViewImmediate(this)
}
override fun onGlobalLayout() {
// No-op
}
override fun setLayoutDirection(layoutDirection: Int) {
// Do nothing. ViewRootImpl will call this method attempting to set the layout direction
// from the context's locale, but we have one already from the parent composition.
}
// Sets the "real" layout direction for our content that we obtain from the parent composition.
fun superSetLayoutDirection(layoutDirection: LayoutDirection) {
val direction = when (layoutDirection) {
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
}
super.setLayoutDirection(direction)
}
}
// Taken from AndroidPopup.android.kt
private fun View.isFlagSecureEnabled(): Boolean {
val windowParams = rootView.layoutParams as? WindowManager.LayoutParams
if (windowParams != null) {
return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
}
return false
}
// Taken from AndroidPopup.android.kt
private fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean {
return when (this) {
SecureFlagPolicy.SecureOff -> false
SecureFlagPolicy.SecureOn -> true
SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent
}
}
@@ -5,6 +5,7 @@ package androidx.compose.material3
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableState
@@ -23,13 +24,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlurEffect
@@ -50,7 +54,6 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.util.lerp
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
import kotlin.math.roundToInt
@@ -71,7 +74,8 @@ class DrawerState2(
val anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
animationSpec = AnimationSpec,
snapAnimationSpec = AnimationSpec,
decayAnimationSpec = exponentialDecay(7.5f),
confirmValueChange = confirmStateChange,
positionalThreshold = { distance -> distance * DrawerPositionalThreshold },
velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } },
@@ -252,7 +256,7 @@ fun ModalNavigationDrawer(
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var minValue by remember { mutableStateOf(-with(density) { ContainerWidth.toPx() }) }
var minValue by remember { mutableFloatStateOf(-with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
SideEffect {
@@ -302,9 +306,13 @@ fun ModalNavigationDrawer(
Modifier
}
Box(modifier.fillMaxSize().then(dragModifier)) {
val radius by remember {
snapshotFlow {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
lerp(0, 10, step).dp
}
}.collectAsState(0.dp)
val blurModifier = Modifier.graphicsLayer {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
val radius = lerp(0.dp, 10.dp, step)
if (radius != 0.dp) {
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
shape = RectangleShape
@@ -385,7 +393,7 @@ fun SideDrawer(
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var minValue by remember { mutableStateOf(with(density) { ContainerWidth.toPx() }) }
var minValue by remember { mutableFloatStateOf(with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
SideEffect {
@@ -436,9 +444,13 @@ fun SideDrawer(
Modifier
}
Box(modifier.fillMaxSize().then(dragModifier)) {
val radius by remember {
snapshotFlow {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
lerp(0, 10, step).dp
}
}.collectAsState(0.dp)
val blurModifier = Modifier.graphicsLayer {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
val radius = lerp(0.dp, 10.dp, step)
if (radius != 0.dp) {
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
shape = RectangleShape
@@ -20,6 +20,7 @@ package androidx.compose.material3
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.FiniteAnimationSpec
@@ -43,21 +44,23 @@ 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.BasicTextField
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.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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
@@ -86,12 +89,12 @@ 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.coroutines.cancellation.CancellationException
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
/**
@@ -135,9 +138,10 @@ import kotlinx.coroutines.delay
* [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 interactionSource an optional hoisted [MutableInteractionSource] for observing and
* emitting [Interaction]s for this search bar. You can use this to change the search bar's
* appearance or preview the search bar in different states. Note that if `null` is provided,
* interactions will still happen internally.
* @param content the content of this search bar that will be displayed below the input field
*/
@ExperimentalMaterial3Api
@@ -158,13 +162,114 @@ fun SearchBar(
tonalElevation: Dp = SearchBarDefaults.TonalElevation,
shadowElevation: Dp = SearchBarDefaults.ShadowElevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
interactionSource: MutableInteractionSource? = null,
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 finalBackProgress = remember { mutableFloatStateOf(Float.NaN) }
val firstBackEvent = remember { mutableStateOf<BackEventCompat?>(null) }
val currentBackEvent = remember { mutableStateOf<BackEventCompat?>(null) }
LaunchedEffect(active) {
val animationInProgress = animationProgress.value > 0 && animationProgress.value < 1
val animationSpec =
if (animationInProgress) {
AnimationPredictiveBackExitFloatSpec
} else if (active) {
AnimationEnterFloatSpec
} else {
AnimationExitFloatSpec
}
val targetValue = if (active) 1f else 0f
if (animationProgress.value != targetValue) {
animationProgress.animateTo(targetValue, animationSpec)
}
if (!active) {
finalBackProgress.floatValue = Float.NaN
firstBackEvent.value = null
currentBackEvent.value = null
}
}
val mutatorMutex = remember { MutatorMutex() }
PredictiveBackHandler(enabled = active) { progress ->
mutatorMutex.mutate {
try {
finalBackProgress.floatValue = Float.NaN
progress.collect { backEvent ->
if (firstBackEvent.value == null) {
firstBackEvent.value = backEvent
}
currentBackEvent.value = backEvent
val interpolatedProgress = EaseOut.transform(backEvent.progress)
animationProgress.snapTo(targetValue = 1 - interpolatedProgress)
}
finalBackProgress.floatValue = animationProgress.value
onActiveChange(false)
} catch (e: CancellationException) {
animationProgress.animateTo(
targetValue = 1f,
animationSpec = AnimationPredictiveBackExitFloatSpec,
)
finalBackProgress.floatValue = Float.NaN
firstBackEvent.value = null
currentBackEvent.value = null
}
}
}
SearchBarInternal(
animationProgress = animationProgress,
finalBackProgress = finalBackProgress,
firstBackEvent = firstBackEvent,
currentBackEvent = currentBackEvent,
state = state,
onSearch = onSearch,
active = active,
onActiveChange = onActiveChange,
modifier = modifier,
enabled = enabled,
title = title,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
shape = shape,
colors = colors,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
windowInsets = windowInsets,
interactionSource = interactionSource,
content = content,
)
}
@ExperimentalMaterial3Api
@Composable
internal fun SearchBarInternal(
animationProgress: Animatable<Float, AnimationVector1D>,
finalBackProgress: MutableFloatState,
firstBackEvent: MutableState<BackEventCompat?>,
currentBackEvent: MutableState<BackEventCompat?>,
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? = null,
content: @Composable ColumnScope.() -> Unit,
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
val focusManager = LocalFocusManager.current
val density = LocalDensity.current
@@ -218,19 +323,24 @@ fun SearchBar(
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 defaultStartWidth = max(constraints.minWidth, SearchBarMinWidth.roundToPx())
.coerceAtMost(min(constraints.maxWidth, SearchBarMaxWidth.roundToPx()))
val defaultStartHeight = max(constraints.minHeight, InputFieldHeight.roundToPx())
.coerceAtMost(constraints.maxHeight)
val predictiveBackStartWidth =
(constraints.maxWidth * SearchBarPredictiveBackMinScale).roundToInt()
val predictiveBackStartHeight =
(constraints.maxHeight * SearchBarPredictiveBackMinScale).roundToInt()
val predictiveBackMultiplier = calculatePredictiveBackMultiplier(
currentBackEvent.value,
animationProgress.value,
finalBackProgress.floatValue,
)
val startWidth =
lerp(defaultStartWidth, predictiveBackStartWidth, predictiveBackMultiplier)
val startHeight =
lerp(defaultStartHeight, predictiveBackStartHeight, predictiveBackMultiplier)
val endWidth = constraints.maxWidth
val endHeight = constraints.maxHeight
@@ -242,17 +352,20 @@ fun SearchBar(
val predictiveBackOffsetX = calculatePredictiveBackOffsetX(
constraints,
minOffsetMargin,
currentBackEvent,
currentBackEvent.value,
animationProgress.value,
predictiveBackMultiplier,
)
val predictiveBackOffsetY = calculatePredictiveBackOffsetY(
constraints,
minOffsetMargin,
currentBackEvent,
firstBackEvent,
currentBackEvent.value,
firstBackEvent.value,
height,
SearchBarPredictiveBackMaxOffsetY.roundToPx(),
predictiveBackMultiplier,
)
val placeable = measurable.measure(
Constraints
.fixed(width, height)
@@ -300,54 +413,25 @@ fun SearchBar(
val isFocused = interactionSource.collectIsFocusedAsState().value
val shouldClearFocus = !active && isFocused
if (shouldClearFocus) {
LaunchedEffect(true) {
LaunchedEffect(active) {
if (shouldClearFocus) {
// Not strictly needed according to the motion spec, but since the animation already has
// a delay, this works around b/261632544.
delay(AnimationDelayMillis.toLong())
focusManager.clearFocus()
}
}
}
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 calculatePredictiveBackMultiplier(
currentBackEvent: BackEventCompat?,
progress: Float,
finalBackProgress: Float,
) = when {
currentBackEvent == null -> 0f // Not in predictive back at all.
finalBackProgress.isNaN() -> 1f // User is currently swiping predictive back.
finalBackProgress <= 0 -> 0f // Safety check for divide by zero.
else -> progress / finalBackProgress // User has released predictive back swipe.
}
private fun calculatePredictiveBackOffsetX(
@@ -355,14 +439,16 @@ private fun calculatePredictiveBackOffsetX(
minMargin: Int,
currentBackEvent: BackEventCompat?,
progress: Float,
predictiveBackMultiplier: Float,
): Int {
if (currentBackEvent == null) {
if (currentBackEvent == null || predictiveBackMultiplier == 0f) {
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
val interpolatedOffsetX = maxOffsetX * (1 - progress)
return (interpolatedOffsetX * predictiveBackMultiplier * directionMultiplier).roundToInt()
}
private fun calculatePredictiveBackOffsetY(
@@ -372,8 +458,9 @@ private fun calculatePredictiveBackOffsetY(
firstBackEvent: BackEventCompat?,
height: Int,
maxOffsetY: Int,
predictiveBackMultiplier: Float,
): Int {
if (firstBackEvent == null || currentBackEvent == null) {
if (firstBackEvent == null || currentBackEvent == null || predictiveBackMultiplier == 0f) {
return 0
}
val availableVerticalSpace = max(0, (constraints.maxHeight - height) / 2 - minMargin)
@@ -381,7 +468,8 @@ private fun calculatePredictiveBackOffsetY(
val yDelta = currentBackEvent.touchY - firstBackEvent.touchY
val yProgress = abs(yDelta) / constraints.maxHeight
val directionMultiplier = sign(yDelta)
return (lerp(0, adjustedMaxOffsetY, yProgress) * directionMultiplier).roundToInt()
val interpolatedOffsetY = lerp(0, adjustedMaxOffsetY, yProgress)
return (interpolatedOffsetY * predictiveBackMultiplier * directionMultiplier).roundToInt()
}
@Composable
@@ -396,15 +484,17 @@ private fun SearchBarInputField(
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
colors: TextFieldColors = SearchBarDefaults.inputFieldColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
interactionSource: MutableInteractionSource? = null,
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: 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(
BasicTextField(
state = state,
modifier = modifier
.height(InputFieldHeight)
@@ -474,7 +564,6 @@ 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
@@ -1,63 +0,0 @@
/*
* 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
}
}
@@ -1,128 +0,0 @@
/*
* 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"
}
}
@@ -1,137 +0,0 @@
/*
* 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)
}
},
)
}
}
}
@@ -1,96 +0,0 @@
/*
* 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"
}
}
@@ -1,88 +0,0 @@
/*
* 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()
}
}
@@ -1,243 +0,0 @@
/*
* 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)
}
},
)
}
@@ -1,424 +0,0 @@
/*
* 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
}
@@ -1,86 +0,0 @@
/*
* 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) } },
)
@@ -145,7 +145,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
// Min scale allowed (prevent infinite zoom)
private var minScale = minScale()
private val sRegion = Rect(0, 0, 0, 0)
private var srcRect = Rect(0, 0, 0, 0)
// Is two-finger zooming in progress
private var isZooming = false
@@ -230,8 +230,9 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
*
* @param bitmap Image source.
*/
fun setImage(bitmap: Bitmap) {
fun setImage(bitmap: Bitmap, rect: Rect) {
reset(true)
srcRect = Rect(rect)
onImageLoaded(bitmap)
}
@@ -267,7 +268,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
}
sWidth = 0
sHeight = 0
sRegion.setEmpty()
srcRect.setEmpty()
isReady = false
isImageLoaded = false
bitmap = null
@@ -760,6 +761,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
val xScale = scale
val yScale = scale
mtrx.reset()
mtrx.postTranslate((-srcRect.left).toFloat(), (-srcRect.top).toFloat())
mtrx.postScale(xScale, yScale)
mtrx.postTranslate(vTranslate!!.x, vTranslate!!.y)
canvas.drawBitmap(bitmap!!, mtrx, bitmapPaint)
@@ -908,7 +910,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
@Synchronized
private fun onImageLoaded(bitmap: Bitmap) {
// If actual dimensions don't match the declared size, reset everything.
if (sWidth > 0 && sHeight > 0 && (sWidth != bitmap.width || sHeight != bitmap.height)) {
if (sWidth > 0 && sHeight > 0 && (sWidth != srcRect.width() || sHeight != srcRect.height())) {
reset(false)
}
if (!this.bitmapIsCached) {
@@ -916,8 +918,8 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
}
this.bitmapIsCached = true
this.bitmap = bitmap
sWidth = bitmap.width
sHeight = bitmap.height
sWidth = srcRect.width()
sHeight = srcRect.height()
val ready = checkReady()
val imageLoaded = checkImageLoaded()
if (ready || imageLoaded) {
@@ -0,0 +1,653 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.accompanist.web
import android.content.Context
import android.graphics.Bitmap
import android.os.Bundle
import android.view.ViewGroup.LayoutParams
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.google.accompanist.web.LoadingState.Finished
import com.google.accompanist.web.LoadingState.Loading
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* A wrapper around the Android View WebView to provide a basic WebView composable.
*
* If you require more customisation you are most likely better rolling your own and using this
* wrapper as an example.
*
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it
* is incorrectly sizing, use the layoutParams composable function instead.
*
* @param state The webview state holder where the Uri to load is defined.
* @param modifier A compose modifier
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
* the WebView back.
* @param navigator An optional navigator object that can be used to control the WebView's
* navigation from outside the composable.
* @param onCreated Called when the WebView is first created, this can be used to set additional
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be
* subsequently overwritten after this lambda is called.
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved
* if you need to save and restore state in this WebView.
* @param client Provides access to WebViewClient via subclassing
* @param chromeClient Provides access to WebChromeClient via subclassing
* @param factory An optional WebView factory for using a custom subclass of WebView
*/
@Composable
fun WebView(
state: WebViewState,
modifier: Modifier = Modifier,
captureBackPresses: Boolean = true,
navigator: WebViewNavigator = rememberWebViewNavigator(),
onCreated: (WebView) -> Unit = {},
onDispose: (WebView) -> Unit = {},
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
factory: ((Context) -> WebView)? = null,
) {
BoxWithConstraints(modifier) {
// WebView changes it's layout strategy based on
// it's layoutParams. We convert from Compose Modifier to
// layout params here.
val width =
if (constraints.hasFixedWidth) {
LayoutParams.MATCH_PARENT
} else {
LayoutParams.WRAP_CONTENT
}
val height =
if (constraints.hasFixedHeight) {
LayoutParams.MATCH_PARENT
} else {
LayoutParams.WRAP_CONTENT
}
val layoutParams = FrameLayout.LayoutParams(
width,
height,
)
WebView(
state,
layoutParams,
Modifier,
captureBackPresses,
navigator,
onCreated,
onDispose,
client,
chromeClient,
factory,
)
}
}
/**
* A wrapper around the Android View WebView to provide a basic WebView composable.
*
* If you require more customisation you are most likely better rolling your own and using this
* wrapper as an example.
*
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it
* is incorrectly sizing, use the layoutParams composable function instead.
*
* @param state The webview state holder where the Uri to load is defined.
* @param layoutParams A FrameLayout.LayoutParams object to custom size the underlying WebView.
* @param modifier A compose modifier
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
* the WebView back.
* @param navigator An optional navigator object that can be used to control the WebView's
* navigation from outside the composable.
* @param onCreated Called when the WebView is first created, this can be used to set additional
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be
* subsequently overwritten after this lambda is called.
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved
* if you need to save and restore state in this WebView.
* @param client Provides access to WebViewClient via subclassing
* @param chromeClient Provides access to WebChromeClient via subclassing
* @param factory An optional WebView factory for using a custom subclass of WebView
*/
@Composable
fun WebView(
state: WebViewState,
layoutParams: FrameLayout.LayoutParams,
modifier: Modifier = Modifier,
captureBackPresses: Boolean = true,
navigator: WebViewNavigator = rememberWebViewNavigator(),
onCreated: (WebView) -> Unit = {},
onDispose: (WebView) -> Unit = {},
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
factory: ((Context) -> WebView)? = null,
) {
val webView = state.webView
BackHandler(captureBackPresses && navigator.canGoBack) {
webView?.goBack()
}
webView?.let { wv ->
LaunchedEffect(wv, navigator) {
with(navigator) {
wv.handleNavigationEvents()
}
}
LaunchedEffect(wv, state) {
snapshotFlow { state.content }.collect { content ->
when (content) {
is WebContent.Url -> {
wv.loadUrl(content.url, content.additionalHttpHeaders)
}
is WebContent.Data -> {
wv.loadDataWithBaseURL(
content.baseUrl,
content.data,
content.mimeType,
content.encoding,
content.historyUrl,
)
}
is WebContent.Post -> {
wv.postUrl(
content.url,
content.postData,
)
}
is WebContent.NavigatorOnly -> {
// NO-OP
}
}
}
}
}
// Set the state of the client and chrome client
// This is done internally to ensure they always are the same instance as the
// parent Web composable
client.state = state
client.navigator = navigator
chromeClient.state = state
AndroidView(
factory = { context ->
(factory?.invoke(context) ?: WebView(context)).apply {
onCreated(this)
this.layoutParams = layoutParams
state.viewState?.let {
this.restoreState(it)
}
webChromeClient = chromeClient
webViewClient = client
}.also { state.webView = it }
},
modifier = modifier,
onRelease = {
onDispose(it)
},
)
}
/**
* AccompanistWebViewClient
*
* A parent class implementation of WebViewClient that can be subclassed to add custom behaviour.
*
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
* class that can be overriden if further custom behaviour is required.
*/
open class AccompanistWebViewClient : WebViewClient() {
open lateinit var state: WebViewState
internal set
open lateinit var navigator: WebViewNavigator
internal set
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
state.loadingState = Loading(0.0f)
state.errorsForCurrentRequest.clear()
state.pageTitle = null
state.pageIcon = null
state.lastLoadedUrl = url
}
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
state.loadingState = Finished
}
override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) {
super.doUpdateVisitedHistory(view, url, isReload)
navigator.canGoBack = view.canGoBack()
navigator.canGoForward = view.canGoForward()
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest?,
error: WebResourceError?,
) {
super.onReceivedError(view, request, error)
if (error != null) {
state.errorsForCurrentRequest.add(WebViewError(request, error))
}
}
}
/**
* AccompanistWebChromeClient
*
* A parent class implementation of WebChromeClient that can be subclassed to add custom behaviour.
*
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
* class that can be overriden if further custom behaviour is required.
*/
open class AccompanistWebChromeClient : WebChromeClient() {
open lateinit var state: WebViewState
internal set
override fun onReceivedTitle(view: WebView, title: String?) {
super.onReceivedTitle(view, title)
state.pageTitle = title
}
override fun onReceivedIcon(view: WebView, icon: Bitmap?) {
super.onReceivedIcon(view, icon)
state.pageIcon = icon
}
override fun onProgressChanged(view: WebView, newProgress: Int) {
super.onProgressChanged(view, newProgress)
if (state.loadingState is Finished) return
state.loadingState = Loading(newProgress / 100.0f)
}
}
sealed class WebContent {
data class Url(
val url: String,
val additionalHttpHeaders: Map<String, String> = emptyMap(),
) : WebContent()
data class Data(
val data: String,
val baseUrl: String? = null,
val encoding: String = "utf-8",
val mimeType: String? = null,
val historyUrl: String? = null,
) : WebContent()
data class Post(
val url: String,
val postData: ByteArray,
) : WebContent() {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Post
if (url != other.url) return false
if (!postData.contentEquals(other.postData)) return false
return true
}
override fun hashCode(): Int {
var result = url.hashCode()
result = 31 * result + postData.contentHashCode()
return result
}
}
data object NavigatorOnly : WebContent()
}
/**
* Sealed class for constraining possible loading states.
* See [Loading] and [Finished].
*/
sealed class LoadingState {
/**
* Describes a WebView that has not yet loaded for the first time.
*/
data object Initializing : LoadingState()
/**
* Describes a webview between `onPageStarted` and `onPageFinished` events, contains a
* [progress] property which is updated by the webview.
*/
data class Loading(val progress: Float) : LoadingState()
/**
* Describes a webview that has finished loading content.
*/
data object Finished : LoadingState()
}
/**
* A state holder to hold the state for the WebView. In most cases this will be remembered
* using the rememberWebViewState(uri) function.
*/
@Stable
class WebViewState(webContent: WebContent) {
var lastLoadedUrl: String? by mutableStateOf(null)
internal set
/**
* The content being loaded by the WebView
*/
var content: WebContent by mutableStateOf(webContent)
/**
* Whether the WebView is currently [LoadingState.Loading] data in its main frame (along with
* progress) or the data loading has [LoadingState.Finished]. See [LoadingState]
*/
var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing)
internal set
/**
* Whether the webview is currently loading data in its main frame
*/
val isLoading: Boolean
get() = loadingState !is Finished
/**
* The title received from the loaded content of the current page
*/
var pageTitle: String? by mutableStateOf(null)
internal set
/**
* the favicon received from the loaded content of the current page
*/
var pageIcon: Bitmap? by mutableStateOf(null)
internal set
/**
* A list for errors captured in the last load. Reset when a new page is loaded.
* Errors could be from any resource (iframe, image, etc.), not just for the main page.
* For more fine grained control use the OnError callback of the WebView.
*/
val errorsForCurrentRequest: SnapshotStateList<WebViewError> = mutableStateListOf()
/**
* The saved view state from when the view was destroyed last. To restore state,
* use the navigator and only call loadUrl if the bundle is null.
* See WebViewSaveStateSample.
*/
var viewState: Bundle? = null
internal set
// We need access to this in the state saver. An internal DisposableEffect or AndroidView
// onDestroy is called after the state saver and so can't be used.
internal var webView by mutableStateOf<WebView?>(null)
}
/**
* Allows control over the navigation of a WebView from outside the composable. E.g. for performing
* a back navigation in response to the user clicking the "up" button in a TopAppBar.
*
* @see [rememberWebViewNavigator]
*/
@Stable
class WebViewNavigator(private val coroutineScope: CoroutineScope) {
private sealed interface NavigationEvent {
data object Back : NavigationEvent
data object Forward : NavigationEvent
data object Reload : NavigationEvent
data object StopLoading : NavigationEvent
data class LoadUrl(
val url: String,
val additionalHttpHeaders: Map<String, String> = emptyMap(),
) : NavigationEvent
data class LoadHtml(
val html: String,
val baseUrl: String? = null,
val mimeType: String? = null,
val encoding: String? = "utf-8",
val historyUrl: String? = null,
) : NavigationEvent
data class PostUrl(
val url: String,
val postData: ByteArray,
) : NavigationEvent {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PostUrl
if (url != other.url) return false
if (!postData.contentEquals(other.postData)) return false
return true
}
override fun hashCode(): Int {
var result = url.hashCode()
result = 31 * result + postData.contentHashCode()
return result
}
}
}
private val navigationEvents: MutableSharedFlow<NavigationEvent> = MutableSharedFlow(replay = 1)
// Use Dispatchers.Main to ensure that the webview methods are called on UI thread
internal suspend fun WebView.handleNavigationEvents(): Nothing = withContext(Dispatchers.Main) {
navigationEvents.collect { event ->
when (event) {
is NavigationEvent.Back -> goBack()
is NavigationEvent.Forward -> goForward()
is NavigationEvent.Reload -> reload()
is NavigationEvent.StopLoading -> stopLoading()
is NavigationEvent.LoadHtml -> loadDataWithBaseURL(
event.baseUrl,
event.html,
event.mimeType,
event.encoding,
event.historyUrl,
)
is NavigationEvent.LoadUrl -> {
loadUrl(event.url, event.additionalHttpHeaders)
}
is NavigationEvent.PostUrl -> {
postUrl(event.url, event.postData)
}
}
}
}
/**
* True when the web view is able to navigate backwards, false otherwise.
*/
var canGoBack: Boolean by mutableStateOf(false)
internal set
/**
* True when the web view is able to navigate forwards, false otherwise.
*/
var canGoForward: Boolean by mutableStateOf(false)
internal set
fun loadUrl(url: String, additionalHttpHeaders: Map<String, String> = emptyMap()) {
coroutineScope.launch {
navigationEvents.emit(
NavigationEvent.LoadUrl(
url,
additionalHttpHeaders,
),
)
}
}
fun loadHtml(
html: String,
baseUrl: String? = null,
mimeType: String? = null,
encoding: String? = "utf-8",
historyUrl: String? = null,
) {
coroutineScope.launch {
navigationEvents.emit(
NavigationEvent.LoadHtml(
html,
baseUrl,
mimeType,
encoding,
historyUrl,
),
)
}
}
fun postUrl(
url: String,
postData: ByteArray,
) {
coroutineScope.launch {
navigationEvents.emit(
NavigationEvent.PostUrl(
url,
postData,
),
)
}
}
/**
* Navigates the webview back to the previous page.
*/
fun navigateBack() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) }
}
/**
* Navigates the webview forward after going back from a page.
*/
fun navigateForward() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) }
}
/**
* Reloads the current page in the webview.
*/
fun reload() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) }
}
/**
* Stops the current page load (if one is loading).
*/
fun stopLoading() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.StopLoading) }
}
}
/**
* Creates and remembers a [WebViewNavigator] using the default [CoroutineScope] or a provided
* override.
*/
@Composable
fun rememberWebViewNavigator(
coroutineScope: CoroutineScope = rememberCoroutineScope(),
): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope) }
/**
* A wrapper class to hold errors from the WebView.
*/
@Immutable
data class WebViewError(
/**
* The request the error came from.
*/
val request: WebResourceRequest?,
/**
* The error that was reported.
*/
val error: WebResourceError,
)
/**
* Creates a WebView state that is remembered across Compositions.
*
* @param url The url to load in the WebView
* @param additionalHttpHeaders Optional, additional HTTP headers that are passed to [WebView.loadUrl].
* Note that these headers are used for all subsequent requests of the WebView.
*/
@Composable
fun rememberWebViewState(
url: String,
additionalHttpHeaders: Map<String, String> = emptyMap(),
): WebViewState =
// Rather than using .apply {} here we will recreate the state, this prevents
// a recomposition loop when the webview updates the url itself.
remember {
WebViewState(
WebContent.Url(
url = url,
additionalHttpHeaders = additionalHttpHeaders,
),
)
}.apply {
this.content = WebContent.Url(
url = url,
additionalHttpHeaders = additionalHttpHeaders,
)
}
@@ -17,48 +17,57 @@
package com.hippo.ehviewer
import android.app.Application
import android.content.ComponentCallbacks2
import android.content.Context
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.content.res.AppCompatResources
import androidx.collection.LruCache
import androidx.compose.runtime.snapshots.Snapshot
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil.ImageLoaderFactory
import coil.decode.ImageDecoderDecoder
import coil.util.DebugLogger
import coil3.SingletonImageLoader
import coil3.asCoilImage
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.ktor.KtorNetworkFetcherFactory
import coil3.request.crossfade
import coil3.serviceLoaderEnabled
import coil3.util.DebugLogger
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.coil.CropBorderInterceptor
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
import com.hippo.ehviewer.coil.HardwareBitmapInterceptor
import com.hippo.ehviewer.coil.MergeInterceptor
import com.hippo.ehviewer.coil.installCronetHttpUriFetcher
import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.ktbuilder.cache
import com.hippo.ehviewer.ktbuilder.chunker
import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.ktbuilder.httpClient
import com.hippo.ehviewer.ktbuilder.imageLoader
import com.hippo.ehviewer.ktor.CronetEngine
import com.hippo.ehviewer.legacy.cleanObsoleteCache
import com.hippo.ehviewer.legacy.migrateCookies
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.lockObserver
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.Crash
import com.hippo.ehviewer.util.FavouriteStatusRouter
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 com.hippo.ehviewer.util.isCronetAvailable
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.cookies.HttpCookies
import kotlinx.coroutines.launch
import moe.tarsin.kt.unreachable
import logcat.AndroidLogcatLogger
import logcat.LogPriority
import logcat.LogcatLogger
import okhttp3.AsyncDns
import okhttp3.android.AndroidAsyncDns
import okio.Path.Companion.toOkioPath
@@ -68,7 +77,7 @@ import splitties.init.appCtx
private val lifecycle = ProcessLifecycleOwner.get().lifecycle
private val lifecycleScope = lifecycle.coroutineScope
class EhApplication : Application(), ImageLoaderFactory {
class EhApplication : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
// Initialize Settings on first access
lifecycleScope.launchIO {
@@ -78,6 +87,9 @@ class EhApplication : Application(), ImageLoaderFactory {
AppCompatDelegate.setDefaultNightMode(mode)
}
}
if (!LogcatLogger.isInstalled && Settings.saveCrashLog) {
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
}
}
lifecycle.addObserver(lockObserver)
val handler = Thread.getDefaultUncaughtExceptionHandler()
@@ -91,37 +103,37 @@ class EhApplication : Application(), ImageLoaderFactory {
handler?.uncaughtException(t, e)
}
super.onCreate()
if (isAtLeastP) {
System.loadLibrary("ehviewer")
}
System.loadLibrary("ehviewer_rust")
ReadableTime.initialize(this)
System.loadLibrary("ehviewer")
lifecycleScope.launchIO {
launchIO {
migrateCookies()
}
launchIO {
launch {
EhTagDatabase
}
launchIO {
launch {
EhDB
}
launchIO {
DownloadManager.isIdle
launch {
DownloadManager.readMetadataFromLocal()
}
launchIO {
launch {
FileUtils.cleanupDirectory(AppConfig.externalCrashDir)
FileUtils.cleanupDirectory(AppConfig.externalParseErrorDir)
}
launch {
cleanupDownload()
}
if (Settings.requestNews) {
launchIO {
launch {
checkDawn()
}
}
launch {
cleanObsoleteCache()
}
}
cleanObsoleteCache(this)
if (BuildConfig.DEBUG) {
StrictMode.enableDefaults()
Snapshot.registerApplyObserver { anies, _ ->
logcat { anies.toString() }
logcat(LogPriority.VERBOSE) { anies.toString() }
}
}
}
@@ -130,12 +142,12 @@ class EhApplication : Application(), ImageLoaderFactory {
runCatching {
keepNoMediaFileStatus()
}.onFailure {
it.printStackTrace()
logcat(it)
}
runCatching {
clearTempDir()
}.onFailure {
it.printStackTrace()
logcat(it)
}
}
@@ -150,59 +162,58 @@ class EhApplication : Application(), ImageLoaderFactory {
}
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
galleryDetailCache.evictAll()
}
}
override fun newImageLoader() = imageLoader {
override fun newImageLoader(context: Context) = context.imageLoader {
components {
if (isCronetSupported) {
callFactory { unreachable() }
installCronetHttpUriFetcher()
} else {
callFactory(baseOkHttpClient)
}
if (isAtLeastP) {
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
}
serviceLoaderEnabled(false)
add(KtorNetworkFetcherFactory { ktorClient })
add(MergeInterceptor)
add(DownloadThumbInterceptor)
add(CropBorderInterceptor)
add(HardwareBitmapInterceptor)
if (isAtLeastP) {
add(AnimatedImageDecoder.Factory(false))
} else {
add(GifDecoder.Factory())
}
}
diskCache(imageCache)
diskCache { imageCache }
crossfade(300)
error(R.drawable.image_failed)
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
if (drawable != null) error(drawable.asCoilImage(true))
if (BuildConfig.DEBUG) logger(DebugLogger())
respectCacheHeaders(false)
}
companion object {
val baseOkHttpClient by lazy {
httpClient {
cookieJar(EhCookieStore)
if (isAtLeastQ) {
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
val ktorClient by lazy {
if (Settings.enableCronet && isCronetAvailable) {
HttpClient(CronetEngine) {
install(HttpCookies) {
storage = EhCookieStore
}
}
} else {
HttpClient(OkHttp) {
engine {
config {
if (isAtLeastQ) {
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
}
addInterceptor(UncaughtExceptionInterceptor())
}
}
install(HttpCookies) {
storage = EhCookieStore
}
}
chunker { alwaysReadResponseBody(false) }
addInterceptor(UncaughtExceptionInterceptor())
addInterceptor(CloudflareInterceptor(appCtx))
}
}
val noRedirectOkHttpClient by lazy {
httpClient(baseOkHttpClient) {
followRedirects(false)
}
}
// Never use this okhttp client to download large blobs!!!
val okHttpClient by lazy {
httpClient(baseOkHttpClient) {
cache(
appCtx.cacheDir.toOkioPath() / "http_cache",
20L * 1024L * 1024L,
)
val noRedirectKtorClient by lazy {
HttpClient(ktorClient.engine) {
followRedirects = false
install(HttpCookies) {
storage = EhCookieStore
}
}
}
+25 -11
View File
@@ -17,8 +17,6 @@ package com.hippo.ehviewer
import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.os.ParcelFileDescriptor.MODE_READ_ONLY
import arrow.fx.coroutines.release
import arrow.fx.coroutines.resource
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@@ -29,9 +27,11 @@ import com.hippo.ehviewer.dao.EhDatabase
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.HistoryInfo
import com.hippo.ehviewer.dao.LocalFavoriteInfo
import com.hippo.ehviewer.dao.ProgressInfo
import com.hippo.ehviewer.dao.QuickSearch
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.asUniFile
import kotlinx.coroutines.flow.Flow
import splitties.arch.room.roomDb
@@ -46,13 +46,22 @@ object EhDB {
runCatching { db.galleryDao().delete(galleryInfo) }
}
suspend fun updateGalleryInfo(galleryInfoList: List<BaseGalleryInfo>) {
db.galleryDao().update(galleryInfoList)
}
fun getReadProgressFlow(gid: Long) = db.progressDao().getPageFlow(gid)
suspend fun getReadProgress(gid: Long) = db.progressDao().getPage(gid)
suspend fun putReadProgress(gid: Long, page: Int) = db.progressDao().upsert(ProgressInfo(gid, page))
suspend fun clearProgressInfo() = db.progressDao().deleteAll()
suspend fun getAllDownloadInfo() = db.downloadsDao().joinList().onEach {
if (it.state == DownloadInfo.STATE_WAIT || it.state == DownloadInfo.STATE_DOWNLOAD) {
it.state = DownloadInfo.STATE_NONE
}
}
suspend fun updateDownloadInfo(downloadInfo: List<DownloadInfo>) {
suspend fun updateDownloadInfo(downloadInfo: Collection<DownloadInfo>) {
val dao = db.downloadsDao()
dao.update(downloadInfo.map(DownloadInfo::downloadInfo))
}
@@ -65,17 +74,17 @@ object EhDB {
suspend fun removeDownloadInfo(downloadInfo: DownloadInfo) {
val dao = db.downloadsDao()
dao.delete(downloadInfo.downloadInfo)
dao.fill(downloadInfo.position)
deleteGalleryInfo(downloadInfo.galleryInfo)
}
suspend fun randomLocalFav() = db.localFavoritesDao().random()
suspend fun removeDownloadInfo(downloadInfo: List<DownloadInfo>) {
val dao = db.downloadsDao()
downloadInfo.forEach {
dao.delete(it.downloadInfo)
deleteGalleryInfo(it.galleryInfo)
}
dao.fill(downloadInfo.first().position, downloadInfo.size)
}
suspend fun getDownloadDirname(gid: Long): String? {
@@ -99,6 +108,9 @@ object EhDB {
dao.insertOrIgnore(downloadDirnameList)
}
val downloadsCount
get() = db.downloadsDao().count()
suspend fun getAllDownloadLabelList() = db.downloadLabelDao().list()
suspend fun addDownloadLabel(raw: DownloadLabel): DownloadLabel {
@@ -182,13 +194,15 @@ object EhDB {
val historyLazyList
get() = db.historyDao().joinListLazy()
fun searchHistory(keyword: String) = db.historyDao().joinListLazy("*$keyword*")
val localFavLazyList
get() = db.localFavoritesDao().joinListLazy()
val localFavCount: Flow<Int>
get() = db.localFavoritesDao().count()
fun searchLocalFav(keyword: String) = db.localFavoritesDao().joinListLazy("%$keyword%")
fun searchLocalFav(keyword: String) = db.localFavoritesDao().joinListLazy("*$keyword*")
suspend fun putHistoryInfo(galleryInfo: BaseGalleryInfo) {
putGalleryInfo(galleryInfo)
@@ -252,6 +266,7 @@ object EhDB {
context.deleteDatabase(ehExportName)
} use { newDb ->
db.galleryDao().list().let { newDb.galleryDao().insertOrIgnore(it) }
db.progressDao().list().let { newDb.progressDao().insertOrIgnore(it) }
db.downloadLabelDao().list().let { newDb.downloadLabelDao().insert(it) }
db.downloadDirnameDao().list().let { newDb.downloadDirnameDao().insertOrIgnore(it) }
db.downloadsDao().list().let { newDb.downloadsDao().insert(it) }
@@ -261,11 +276,7 @@ object EhDB {
db.filterDao().list().let { newDb.filterDao().insert(it) }
newDb.close()
val dbFile = context.getDatabasePath(ehExportName)
context.contentResolver.openFileDescriptor(uri, "rw")!!.use { toFd ->
ParcelFileDescriptor.open(dbFile, MODE_READ_ONLY).use { fromFd ->
fromFd sendTo toFd
}
}
dbFile.asUniFile() sendTo uri.asUniFile()
}
}
@@ -281,6 +292,9 @@ object EhDB {
runCatching {
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
}
runCatching {
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
}
runCatching {
val downloadLabelList = oldDB.downloadLabelDao().list()
DownloadManager.addDownloadLabel(downloadLabelList)
@@ -12,6 +12,9 @@ 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 com.hippo.ehviewer.download.SortMode
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import java.util.Locale
import kotlin.reflect.KProperty
import kotlinx.coroutines.flow.Flow
@@ -105,10 +108,15 @@ object Settings : DataStorePreferences(null) {
val detailSize = intPref("detail_size_2", 0)
val thumbColumns = intPref("thumb_columns", 3)
val listThumbSize = intPref("list_tile_size", 40)
val languageFilter = intPref("language_filter", -1)
val downloadSortMode = intPref("download_sort_mode", SortMode.Default.flag)
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
val predictiveNavAnim = boolPref("predictive_nav_anim", true)
val blackDarkTheme = boolPref("black_dark_theme", false)
val gridView = boolPref("grid_view", false)
val showGalleryPages = boolPref("show_gallery_pages", true)
val qSSaveProgress = boolPref("qs_save_progress", true)
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
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)
@@ -130,46 +138,77 @@ object Settings : DataStorePreferences(null) {
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
var hideHvEvents by boolPref("hide_hv_events", false)
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 appLinkVerifyTip by boolPref("app_link_verify_tip", false)
var enabledSecurity by boolPref("enable_secure", false)
var useCIUpdateChannel by boolPref("ci_update_channel", false)
var mediaScan by boolPref("media_scan", false).observed { updateWhenKeepMediaStatusChanges() }
var hasDefaultDownloadLabel by boolPref("has_default_download_label", false)
var qSSaveProgress by boolPref("qs_save_progress", true)
var saveParseErrorBody by boolPref("save_parse_error_body", true)
var saveCrashLog by boolPref("save_crash_log", true)
var security by boolPref("require_unlock", false)
var removeImageFiles by boolPref("include_pic", true)
var needSignIn by boolPref("need_sign_in", true).also { needSignInFlow = it.valueFlow() }
var blackDarkTheme by boolPref("black_dark_theme", false).observed { updateWhenAmoledModeChanges() }
var needSignIn by boolPref("need_sign_in", true)
var harmonizeCategoryColor by boolPref("harmonize_category_color", true)
var preloadThumbAggressively by boolPref("preload_thumb_aggressively", false)
var downloadOriginImage by boolPref("download_origin_image", false)
var saveAsCbz by boolPref("save_as_cbz", false)
var archiveMetadata by boolPref("archive_metadata", true)
var enableCronet by boolPref("enable_cronet", true)
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
var defaultFavSlot by intPref("default_favorite_slot", -2)
var securityDelay by intPref("require_unlock_delay", 0)
var clipboardTextHashCode by intPref("clipboard_text_hash_code", 0)
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 requestNewsTime by intPref("request_news_time", 0).observed { updateWhenRequestNewsChanges() }
var updateIntervalDays by intPref("update_interval_days", 7)
var lastDawnDays by intPref("last_dawn_days", 0)
var recentToplist by stringPref("recent_toplist", "11")
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
var recentDownloadLabel by stringOrNullPref("recent_download_label", null)
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
var displayName by stringOrNullPref("display_name", null)
var avatar by stringOrNullPref("avatar", null)
var language by stringPref("app_language", "system").observed { updateWhenLocaleChanges() }
var lastDawnDay by longPref("last_dawn_day", 0)
var lastUpdateDay by longPref("last_update_day", 0)
var lastUpdateTime by longPref("last_update_time", 0)
var favDialogTheta by floatPref("fav_select_dialog_delta", 0F)
// TODO: Remove this after swipe gestures are correctly handled in compose
var touchSlopFactor by intPref("touch_slop", 3)
// Tachiyomi Reader
var newReader by boolPref("new_compose_reader", false)
val cropBorder = boolPref("crop_borders", false)
val colorFilter = boolPref("pref_color_filter_key", false)
val colorFilterValue = intPref("color_filter_value", 0)
val colorFilterMode = intPref("color_filter_mode", 0)
val customBrightness = boolPref("pref_custom_brightness_key", false)
val customBrightnessValue = intPref("custom_brightness_value", 0)
val readingMode = intPref("pref_default_reading_mode_key", ReadingModeType.LEFT_TO_RIGHT.flagValue)
val orientationMode = intPref("pref_default_orientation_type_key", OrientationType.FREE.flagValue)
val showReaderSeekbar = boolPref("pref_show_reader_seekbar", true)
val showPageNumber = boolPref("pref_show_page_number_key", true)
val readerTheme = intPref("pref_reader_theme_key", 1)
val doubleTapToZoom = boolPref("pref_double_tap_to_zoom", true)
val fullscreen = boolPref("fullscreen", true)
val cutoutShort = boolPref("cutout_short", true)
val keepScreenOn = boolPref("pref_keep_screen_on_key", true)
val readerLongTapAction = boolPref("reader_long_tap", true)
val pageTransitions = boolPref("pref_enable_transitions_key", true)
val readWithVolumeKeys = boolPref("reader_volume_keys", false)
val readWithVolumeKeysInterval = intPref("reader_volume_keys_interval", 0)
val readWithVolumeKeysInverted = boolPref("reader_volume_keys_inverted", false)
val grayScale = boolPref("pref_grayscale", false)
val invertedColors = boolPref("pref_inverted_colors", false)
val readerWebtoonNav = intPref("reader_navigation_mode_webtoon", 0)
val readerPagerNav = intPref("reader_navigation_mode_pager", 0)
val readerWebtoonNavInverted = intPref("reader_tapping_inverted_webtoon", 0)
val webtoonSidePadding = intPref("webtoon_side_padding", 0)
val navigateToPan = boolPref("navigate_pan", true)
val imageScaleType = intPref("pref_image_scale_type_key", 1)
val landscapeZoom = boolPref("landscape_zoom", false)
val zoomStart = intPref("pref_zoom_start_key", 1)
init {
if ("CN" == Locale.getDefault().country) {
edit {
@@ -2,7 +2,6 @@ package com.hippo.ehviewer
import android.app.UiModeManager
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
import androidx.core.os.LocaleListCompat
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
@@ -11,6 +10,7 @@ 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 eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -59,14 +59,6 @@ fun updateWhenThemeChanges() {
}
}
fun updateWhenAmoledModeChanges() {
collectScope.launch {
delay(100) // Avoid recompose being cancelled
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_AUTO_BATTERY) // Trigger onNightModeChanged
AppCompatDelegate.setDefaultNightMode(Settings.theme)
}
}
fun updateWhenRequestNewsChanges() {
updateDailyCheckWork(appCtx)
}
@@ -77,7 +69,7 @@ fun updateWhenGallerySiteChanges() {
runCatching {
EhEngine.getUConfig()
}.onFailure {
it.printStackTrace()
logcat(it)
}
}
}
@@ -88,7 +80,7 @@ fun updateWhenTagTranslationChanges() {
runCatching {
EhTagDatabase.update()
}.onFailure {
it.printStackTrace()
logcat(it)
}
}
}
@@ -1,108 +1,85 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.client
import android.webkit.CookieManager
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastFilter
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastJoinToString
import androidx.compose.ui.util.fastMapNotNull
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import io.ktor.client.plugins.cookies.CookiesStorage
import io.ktor.http.Cookie
import io.ktor.http.Url
import io.ktor.http.parseClientCookiesHeader
import io.ktor.http.renderCookieHeader
import io.ktor.http.renderSetCookieHeader
object EhCookieStore : CookieJar {
object EhCookieStore : CookiesStorage {
private val manager = CookieManager.getInstance()
fun signOut() = manager.removeAllCookies(null)
fun contains(url: HttpUrl, name: String) = get(url).fastAny { it.name == name }
fun get(url: HttpUrl): List<Cookie> {
val cookies = manager.getCookie(url.toString())
return if (!cookies.isNullOrEmpty()) {
cookies.split(";").fastMapNotNull { setCookie ->
Cookie.parse(url, setCookie)?.takeUnless { it.name == KEY_UTMP_NAME }
}
} else {
emptyList()
}
}
fun contains(url: String, name: String) = load(Url(url)).fastAny { it.name == name }
fun hasSignedIn(): Boolean {
val url = EhUrl.HOST_E.toHttpUrl()
val url = EhUrl.HOST_E
return contains(url, KEY_IPB_MEMBER_ID) && contains(url, KEY_IPB_PASS_HASH)
}
const val KEY_IPB_MEMBER_ID = "ipb_member_id"
const val KEY_IPB_PASS_HASH = "ipb_pass_hash"
const val KEY_IGNEOUS = "igneous"
private const val KEY_STAR = "star"
private const val KEY_CONTENT_WARNING = "nw"
private const val CONTENT_WARNING_NOT_SHOW = "1"
private const val KEY_UTMP_NAME = "__utmp"
private val sTipsCookie = Cookie.Builder().apply {
name(KEY_CONTENT_WARNING)
value(CONTENT_WARNING_NOT_SHOW)
domain(EhUrl.DOMAIN_E)
path("/")
expiresAt(Long.MAX_VALUE)
}.build()
private val sTipsCookie = Cookie(
name = KEY_CONTENT_WARNING,
value = CONTENT_WARNING_NOT_SHOW,
)
fun copyNecessaryCookies() {
val cookies = get(EhUrl.HOST_E.toHttpUrl())
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 clearIgneous() {
manager.setCookie(
EhUrl.HOST_EX,
renderSetCookieHeader(KEY_IGNEOUS, "", maxAge = 0, domain = EhUrl.DOMAIN_EX, path = "/"),
)
}
fun deleteCookie(url: HttpUrl, name: String) {
manager.setCookie(url.toString(), "$name=;Max-Age=0")
}
fun addCookie(cookie: Cookie) {
manager.setCookie(if (EhUrl.DOMAIN_E == cookie.domain) EhUrl.HOST_E else EhUrl.HOST_EX, cookie.toString())
fun getIdentityCookies(): List<Pair<String, String?>> {
val eCookies = getCookies(EhUrl.HOST_E)
val exCookies = getCookies(EhUrl.HOST_EX)
val ipbMemberId = eCookies?.get(KEY_IPB_MEMBER_ID)
val ipbPassHash = eCookies?.get(KEY_IPB_PASS_HASH)
val igneous = exCookies?.get(KEY_IGNEOUS)
return listOf(
KEY_IPB_MEMBER_ID to ipbMemberId,
KEY_IPB_PASS_HASH to ipbPassHash,
KEY_IGNEOUS to igneous,
)
}
fun flush() = manager.flush()
fun getCookieHeader(url: HttpUrl): String {
return loadForRequest(url).fastJoinToString("; ") {
"${it.name}=${it.value}"
fun getCookieHeader(url: String): String {
return load(Url(url)).fastJoinToString("; ") {
renderCookieHeader(it)
}
}
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = cookies.fastForEach {
if (it.name != KEY_UTMP_NAME) {
manager.setCookie(url.toString(), it.toString())
override suspend fun addCookie(requestUrl: Url, cookie: Cookie) {
if (cookie.name != KEY_UTMP_NAME) {
manager.setCookie(requestUrl.toString(), renderSetCookieHeader(cookie))
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
override fun close() = Unit
private fun getCookies(url: String) = manager.getCookie(url)?.let { parseClientCookiesHeader(it) }
fun load(url: Url): List<Cookie> {
val checkTips = EhUrl.DOMAIN_E in url.host
return get(url).run {
return getCookies(url.toString())?.mapTo(mutableListOf()) {
Cookie(it.key, it.value)
}?.apply {
if (checkTips) {
fastFilter { it.name != KEY_CONTENT_WARNING }.toMutableList().apply { add(sTipsCookie) }
} else {
this
add(sTipsCookie)
}
}
} ?: if (checkTips) listOf(sTipsCookie) else emptyList()
}
override suspend fun get(requestUrl: Url) = load(requestUrl)
}
@@ -15,9 +15,12 @@
*/
package com.hippo.ehviewer.client
import android.util.Log
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import arrow.fx.coroutines.parMap
import arrow.fx.coroutines.parZip
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@@ -29,6 +32,7 @@ import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.parser.ArchiveParser
import com.hippo.ehviewer.client.parser.EventPaneParser
import com.hippo.ehviewer.client.parser.FavParserResult
import com.hippo.ehviewer.client.parser.FavoritesParser
import com.hippo.ehviewer.client.parser.ForumsParser
import com.hippo.ehviewer.client.parser.GalleryApiParser
@@ -45,18 +49,19 @@ import com.hippo.ehviewer.client.parser.TorrentParser
import com.hippo.ehviewer.client.parser.TorrentResult
import com.hippo.ehviewer.client.parser.VoteCommentResult
import com.hippo.ehviewer.client.parser.VoteTagParser
import com.hippo.ehviewer.cronet.awaitBodyFully
import com.hippo.ehviewer.cronet.cronetRequest
import com.hippo.ehviewer.cronet.execute
import com.hippo.ehviewer.cronet.withRequestBody
import com.hippo.ehviewer.dailycheck.showEventNotification
import com.hippo.ehviewer.dailycheck.today
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.isCronetSupported
import com.hippo.ehviewer.util.bodyAsUtf8Text
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.statement.HttpStatement
import io.ktor.client.statement.bodyAsChannel
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import kotlin.math.ceil
import kotlinx.coroutines.CancellationException
@@ -67,26 +72,38 @@ import kotlinx.serialization.json.addJsonArray
import kotlinx.serialization.json.put
import moe.tarsin.coroutines.removeAllSuspend
import moe.tarsin.coroutines.runSuspendCatching
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import org.jsoup.Jsoup
import splitties.init.appCtx
private const val TAG = "EhEngine"
// https://ehwiki.org/wiki/API#Basics
private const val MAX_REQUEST_SIZE = 25
private const val U_CONFIG_TEXT = "Selected Profile"
private val MEDIA_TYPE_JPEG: MediaType = "image/jpeg".toMediaType()
private const val MAX_SEQUENTIAL_REQUESTS = 5
private const val REQUEST_INTERVAL = 5000L
private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
private const val U_CONFIG_TEXT = "Selected Profile"
fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
val dir = AppConfig.externalParseErrorDir ?: return
val file = File(dir, ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".txt")
RandomAccessFile(file, "rw").use { randomAccessFile ->
with(randomAccessFile) {
writeUTF(e.message + "\n")
onLeft { writeUTF(it) }
onRight { channel.write(it) }
}
}
}
fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
// Don't translate coroutine cancellation
if (e is CancellationException) throw e
// Check sad panda(without panda)
if (body.isEmpty()) {
// Check sad panda (without panda)
val empty = body.fold(
{ it.isEmpty() },
{ it.limit() == 0 },
)
if (empty) {
if (EhUtils.isExHentai) {
throw EhException("Sad Panda\n(without panda)")
} else {
@@ -95,10 +112,12 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
}
// Check Gallery Not Available
if (body.contains("Gallery Not Available - ")) {
val error = GalleryNotAvailableParser.parse(body)
if (!error.isNullOrBlank()) {
throw EhException(error)
body.onLeft {
if ("Gallery Not Available - " in it) {
val error = GalleryNotAvailableParser.parse(it)
if (!error.isNullOrBlank()) {
throw EhException(error)
}
}
}
@@ -108,12 +127,15 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
}
if (e is ParseException || e is SerializationException) {
if ("<" !in body) {
throw EhException(body)
} else {
if (Settings.saveParseErrorBody) AppConfig.saveParseErrorBody(e, body)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
body.onLeft { if ("<" !in it) throw EhException(it) }
when (val message = e.cause?.message) {
"No hits found!" -> throw EhException(R.string.gallery_list_empty_hit)
"No watched tags!" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
"Not logged in!" -> throw NotLoggedInException()
is String -> if (message.startsWith("Your IP address")) throw EhException(message)
}
if (Settings.saveParseErrorBody) body.saveParseError(e)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
}
// We can't translate it, rethrow it anyway
@@ -122,145 +144,94 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
val httpContentPool = DirectByteBufferPool(8, 0x80000)
suspend fun <T> fetchCompat(
url: String,
referer: String? = null,
origin: String? = null,
reqBody: RequestBody? = null,
parser: suspend (ByteBuffer) -> T,
): T {
Log.d(TAG, url)
fun commonChecks(body: ByteBuffer) {
// Check sad panda(without panda)
if (!body.hasRemaining()) {
if (EhUtils.isExHentai) {
throw EhException("Sad Panda\n(without panda)")
} else {
throw EhException("IP banned")
}
}
}
return if (isCronetSupported) {
cronetRequest(url, referer, origin) {
reqBody?.let { withRequestBody(it) }
}.execute {
@Suppress("NewApi")
val code = it.httpStatusCode
if (code >= 400) {
throw StatusCodeException(code)
}
httpContentPool.useInstance { buffer ->
awaitBodyFully(buffer)
buffer.flip()
commonChecks(buffer)
parser(buffer)
}
}
} else {
ehRequest(url, referer, origin) {
reqBody?.let { post(it) }
}.execute {
httpContentPool.useInstance { buffer ->
while (true) {
if (body.source().read(buffer) == -1) break
}
buffer.flip()
commonChecks(buffer)
parser(buffer)
}
}
}
suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend String.() -> T) = execute { response ->
val body = response.bodyAsUtf8Text()
runSuspendCatching {
block(body)
}.onFailure {
rethrowExactly(response.status.value, body.left(), it)
}.getOrThrow()
}
@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE")
private suspend inline fun <T> Request.executeAndParsingWith(block: suspend String.() -> T): T {
Log.d(TAG, url.toString())
return execute {
val body = body.string()
try {
block(body)
} catch (e: Exception) {
rethrowExactly(code, body, e)
}
suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = execute { response ->
httpContentPool.useInstance { buffer ->
with(response.bodyAsChannel()) { while (!isClosedForRead) readAvailable(buffer) }
buffer.flip()
runSuspendCatching {
block(buffer)
}.onFailure {
rethrowExactly(response.status.value, buffer.right(), it)
}.getOrThrow()
}
}
object EhEngine {
suspend fun getOriginalImageUrl(url: String, referer: String?): String {
Log.d(TAG, url)
return ehRequest(url, referer).executeNoRedirect {
header("Location")?.apply {
if (contains("bounce_login")) {
throw NotLoggedInException()
}
} ?: throw InsufficientFundsException()
}
suspend fun getOriginalImageUrl(url: String, referer: String?) = noRedirectEhRequest(url, referer).execute { response ->
val location = response.headers["Location"] ?: throw InsufficientFundsException()
location.takeIf { "bounce_login" !in it } ?: throw NotLoggedInException()
}
suspend fun getTorrentList(url: String, gid: Long, token: String?): TorrentResult {
suspend fun getTorrentList(url: String, gid: Long, token: String): TorrentResult {
val referer = EhUrl.getGalleryDetailUrl(gid, token)
return fetchCompat(url, referer, parser = TorrentParser::parse)
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
}
suspend fun getArchiveList(url: String, gid: Long, token: String?) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
.executeAndParsingWith(ArchiveParser::parse)
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) }
suspend fun getArchiveList(url: String, gid: Long, token: String) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
.fetchUsingAsText(ArchiveParser::parse)
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) }
suspend fun getImageLimits() = parZip(
{ fetchCompat(EhUrl.URL_HOME, parser = HomeParser::parse) },
{ ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) },
{ ehRequest(EhUrl.URL_HOME).fetchUsingAsByteBuffer(HomeParser::parse) },
{ ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) },
{ limits, funds -> HomeParser.Result(limits, funds) },
)
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
.executeAndParsingWith { if (parse) EventPaneParser.parse(this) else null }
.fetchUsingAsText { if (parse) EventPaneParser.parse(this) else null }
suspend fun getProfile(): ProfileParser.Result {
val url = ehRequest(EhUrl.URL_FORUMS).executeAndParsingWith(ForumsParser::parse)
return ehRequest(url, EhUrl.URL_FORUMS).executeAndParsingWith(ProfileParser::parse)
val url = ehRequest(EhUrl.URL_FORUMS).fetchUsingAsText(ForumsParser::parse)
return ehRequest(url, EhUrl.URL_FORUMS).fetchUsingAsText(ProfileParser::parse)
}
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
runSuspendCatching {
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
}.onFailure {
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
}.onFailure { throwable ->
// It may get redirected when accessing ex for the first time
if (url == EhUrl.URL_UCONFIG_EX) {
it.printStackTrace()
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
logcat(throwable)
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
} else {
throw it
throw throwable
}
}
}
suspend fun getGalleryPage(
url: String,
gid: Long,
token: String?,
): GalleryPageParser.Result {
suspend fun getGalleryPage(url: String, gid: Long, token: String): GalleryPageParser.Result {
val referer = EhUrl.getGalleryDetailUrl(gid, token)
return ehRequest(url, referer).executeAndParsingWith(GalleryPageParser::parse)
return ehRequest(url, referer).fetchUsingAsText(GalleryPageParser::parse)
}
suspend fun getGalleryList(url: String) = fetchCompat(url, EhUrl.referer, parser = GalleryListParser::parse)
.apply { fillGalleryList(galleryInfoList, url, true) }
suspend fun getGalleryList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(GalleryListParser::parse)
.apply { galleryInfoList.fillInfo(url, true) }
.takeUnless { it.galleryInfoList.isEmpty() } ?: GalleryListParser.emptyResult
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
EventPaneParser.parse(this)?.let {
Settings.lastDawnDay = today
showEventNotification(it)
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
val eventPane = EventPaneParser.parse(this)
if (eventPane != null) {
Settings.lastDawnDays = today
showEventNotification(eventPane)
}
GalleryDetailParser.parse(this)
}
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
GalleryDetailParser.parsePreviewList(this) to GalleryDetailParser.parsePreviewPages(this)
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
GalleryDetailParser.parsePreviewList(this)
}
suspend fun getFavorites(url: String) = fetchCompat(url, EhUrl.referer, parser = FavoritesParser::parse)
.apply { fillGalleryList(galleryInfoList, url) }
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(FavoritesParser::parse)
.apply { galleryInfoList.fillInfo(url) }
suspend fun signIn(username: String, password: String): String {
val referer = "https://forums.e-hentai.org/index.php?act=Login&CODE=00"
@@ -268,89 +239,81 @@ object EhEngine {
val origin = "https://forums.e-hentai.org"
return ehRequest(url, referer, origin) {
formBody {
add("referer", referer)
add("b", "")
add("bt", "")
add("UserName", username)
add("PassWord", password)
add("CookieDate", "1")
append("referer", referer)
append("b", "")
append("bt", "")
append("UserName", username)
append("PassWord", password)
append("CookieDate", "1")
}
}.executeAndParsingWith(SignInParser::parse)
}.fetchUsingAsText(SignInParser::parse)
}
suspend fun commentGallery(url: String, comment: String, id: Long = -1) = ehRequest(url, url, EhUrl.origin) {
formBody {
if (id == -1L) {
add("commenttext_new", comment)
append("commenttext_new", comment)
} else {
add("commenttext_edit", comment)
add("edit_comment", id.toString())
append("commenttext_edit", comment)
append("edit_comment", id.toString())
}
}
}.executeAndParsingWith {
val document = Jsoup.parse(this)
val elements = document.select("#chd + p")
if (elements.size > 0) {
throw EhException(elements[0].text())
}.execute { response ->
// Ktor does not handle POST redirect, we need to do it manually
// https://youtrack.jetbrains.com/issue/KTOR-478
val location = response.headers["Location"] ?: url
ehRequest(location, url).fetchUsingAsText {
val document = Jsoup.parse(this)
val elements = document.select("#chd + p")
if (elements.size > 0) {
throw EhException(elements[0].text())
}
GalleryDetailParser.parseComments(document)
}
GalleryDetailParser.parseComments(document)
}
suspend fun modifyFavorites(gid: Long, token: String?, dstCat: Int = -1, note: String = "") {
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)
val body = formBody {
add("favcat", catStr)
add("favnote", note)
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
add("apply", "Apply Changes")
add("update", "1")
}
fetchCompat(url, url, EhUrl.origin, body) { }
ehRequest(url, url, EhUrl.origin) {
formBody {
append("favcat", catStr)
append("favnote", note)
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
append("apply", "Apply Changes")
append("update", "1")
}
}.execute { }
}
suspend fun downloadArchive(
gid: Long,
token: String?,
or: String?,
res: String?,
isHAtH: Boolean,
): String? {
if (or.isNullOrEmpty()) {
throw EhException("Invalid form param or: $or")
}
if (res.isNullOrEmpty()) {
throw EhException("Invalid res: $res")
}
suspend fun downloadArchive(gid: Long, token: String, or: String, res: String, isHAtH: Boolean): String? {
val url = EhUrl.getDownloadArchive(gid, token, or)
val referer = EhUrl.getGalleryDetailUrl(gid, token)
val request = ehRequest(url, referer, EhUrl.origin) {
formBody {
if (isHAtH) {
add("hathdl_xres", res)
append("hathdl_xres", res)
} else {
add("dltype", res)
append("dltype", res)
if (res == "org") {
add("dlcheck", "Download Original Archive")
append("dlcheck", "Download Original Archive")
} else {
add("dlcheck", "Download Resample Archive")
append("dlcheck", "Download Resample Archive")
}
}
}
}
var result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
var result = request.fetchUsingAsText(ArchiveParser::parseArchiveUrl)
if (!isHAtH) {
if (result == null) {
// Wait for the server to prepare archives
delay(1000)
result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
if (result == null) {
throw EhException("Archive unavailable")
}
result = request.fetchUsingAsText(ArchiveParser::parseArchiveUrl)
if (result == null) throw EhException("Archive unavailable")
}
return result
}
@@ -359,37 +322,28 @@ object EhEngine {
suspend fun resetImageLimits() = ehRequest(EhUrl.URL_HOME) {
formBody {
add("reset_imagelimit", "Reset Limit")
append("reset_imagelimit", "Reset Limit")
}
}.executeAndParsingWith(HomeParser::parseResetLimits)
}.fetchUsingAsText(HomeParser::parseResetLimits)
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavoritesParser.Result {
val url = ehUrl {
addPathSegments(EhUrl.FAV_PATH)
if (FavListUrlBuilder.isValidFavCat(srcCat)) {
addQueryParameter("favcat", srcCat.toString())
}
}.toString()
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavParserResult {
val url = ehUrl(EhUrl.FAV_PATH) {
if (FavListUrlBuilder.isValidFavCat(srcCat)) addQueryParameter("favcat", srcCat.toString())
}.buildString()
val catStr: String = when (dstCat) {
-1 -> "delete"
in 0..9 -> "fav$dstCat"
else -> throw EhException("Invalid dstCat: $dstCat")
}
val body = formBody {
add("ddact", catStr)
gidArray.forEach { add("modifygids[]", it.toString()) }
}
return fetchCompat(url, url, EhUrl.origin, body, FavoritesParser::parse)
.apply { fillGalleryList(galleryInfoList, url) }
return ehRequest(url, url, EhUrl.origin) {
formBody {
append("ddact", catStr)
gidArray.forEach { append("modifygids[]", it.toString()) }
}
}.fetchUsingAsByteBuffer(FavoritesParser::parse).apply { galleryInfoList.fillInfo(url) }
}
suspend fun getGalleryPageApi(
gid: Long,
index: Int,
pToken: String,
showKey: String?,
previousPToken: String?,
): GalleryPageParser.Result {
suspend fun getGalleryPageApi(gid: Long, index: Int, pToken: String, showKey: String?, previousPToken: String?): GalleryPageParser.Result {
val referer = if (index > 0 && previousPToken != null) EhUrl.getPageUrl(gid, index - 1, previousPToken) else null
return ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
@@ -399,83 +353,70 @@ object EhEngine {
put("imgkey", pToken)
put("showkey", showKey)
}
}.executeAndParsingWith { GalleryPageParser.parse(filterNot { it == '\\' }) }
}.fetchUsingAsText { GalleryPageParser.parse(filterNot { it == '\\' }) }
}
suspend fun rateGallery(
apiUid: Long,
apiKey: String?,
gid: Long,
token: String?,
rating: Float,
): RateGalleryResult = ehRequest(EhUrl.apiUrl, EhUrl.getGalleryDetailUrl(gid, token), EhUrl.origin) {
suspend fun rateGallery(apiUid: Long, apiKey: String?, gid: Long, token: String, rating: Float): RateGalleryResult = ehRequest(EhUrl.apiUrl, EhUrl.getGalleryDetailUrl(gid, token), EhUrl.origin) {
jsonBody {
put("method", "rategallery")
put("apiuid", apiUid)
put("apikey", apiKey)
put("apikey", requireNotNull(apiKey))
put("gid", gid)
put("token", token)
put("rating", ceil((rating * 2).toDouble()).toInt())
}
}.executeAndParsingWith(String::parseAs)
}.fetchUsingAsText(String::parseAs)
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String) =
galleryInfoList.chunked(MAX_REQUEST_SIZE).parMap(concurrency = Settings.multiThreadDownload) {
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
put("method", "gdata")
array("gidlist") {
it.forEach {
addJsonArray {
add(it.gid)
add(it.token)
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String? = null) =
galleryInfoList.chunked(MAX_REQUEST_SIZE).chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
if (index != 0) {
delay(REQUEST_INTERVAL)
}
chunk.parMap {
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
put("method", "gdata")
array("gidlist") {
it.forEach {
addJsonArray {
add(it.gid)
add(it.token)
}
}
}
put("namespace", 1)
}
put("namespace", 1)
}
}.executeAndParsingWith { GalleryApiParser.parse(this, it) }
}.fetchUsingAsText { GalleryApiParser.parse(this, it) }
}
}
suspend fun voteComment(
apiUid: Long,
apiKey: String?,
gid: Long,
token: String?,
commentId: Long,
commentVote: Int,
): VoteCommentResult = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "votecomment")
put("apiuid", apiUid)
put("apikey", apiKey)
put("gid", gid)
put("token", token)
put("comment_id", commentId)
put("comment_vote", commentVote)
}
}.executeAndParsingWith(String::parseAs)
suspend fun voteComment(apiUid: Long, apiKey: String?, gid: Long, token: String, commentId: Long, commentVote: Int): VoteCommentResult =
ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "votecomment")
put("apiuid", apiUid)
put("apikey", requireNotNull(apiKey))
put("gid", gid)
put("token", token)
put("comment_id", commentId)
put("comment_vote", commentVote)
}
}.fetchUsingAsText(String::parseAs)
suspend fun voteTag(
apiUid: Long,
apiKey: String?,
gid: Long,
token: String?,
tags: String?,
vote: Int,
) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "taggallery")
put("apiuid", apiUid)
put("apikey", apiKey)
put("gid", gid)
put("token", token)
put("tags", tags)
put("vote", vote)
}
}.executeAndParsingWith(VoteTagParser::parse)
suspend fun voteTag(apiUid: Long, apiKey: String?, gid: Long, token: String, tags: String, vote: Int) =
ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "taggallery")
put("apiuid", apiUid)
put("apikey", requireNotNull(apiKey))
put("gid", gid)
put("token", token)
put("tags", tags)
put("vote", vote)
}
}.fetchUsingAsText(VoteTagParser::parse)
suspend fun getGalleryToken(gid: Long, gtoken: String?, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
suspend fun getGalleryToken(gid: Long, gtoken: String, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "gtoken")
array("pagelist") {
@@ -486,51 +427,32 @@ object EhEngine {
}
}
}
}.executeAndParsingWith(GalleryTokenApiParser::parse)
}.fetchUsingAsText(GalleryTokenApiParser::parse)
/**
* @param image Must be jpeg
*/
suspend fun imageSearch(image: File, uss: Boolean, osc: Boolean) = fetchCompat(
EhUrl.imageSearchUrl,
EhUrl.referer,
EhUrl.origin,
multipartBody {
setType(MultipartBody.FORM)
addFormDataPart("sfile", "a.jpg", image.asRequestBody(MEDIA_TYPE_JPEG))
if (uss) addFormDataPart("fs_similar", "on")
if (osc) addFormDataPart("fs_covers", "on")
addFormDataPart("f_sfile", "File Search")
},
parser = GalleryListParser::parse,
).apply { fillGalleryList(galleryInfoList, EhUrl.imageSearchUrl) }
private suspend fun fillGalleryList(list: MutableList<BaseGalleryInfo>, url: String, filter: Boolean = false) {
// Filter title and uploader
if (filter) list.removeAllSuspend { EhFilter.filterTitle(it) || EhFilter.filterUploader(it) }
var hasTags = false
var hasPages = false
var hasRated = false
for (gi in list) {
if (!gi.simpleTags.isNullOrEmpty()) {
hasTags = true
}
if (gi.pages != 0) {
hasPages = true
}
if (gi.rated) {
hasRated = true
private suspend fun MutableList<BaseGalleryInfo>.fillInfo(url: String, filter: Boolean = false) = with(EhFilter) {
if (filter) removeAllSuspend { filterTitle(it) || filterUploader(it) }
val hasTags = any { !it.simpleTags.isNullOrEmpty() }
val hasPages = any { it.pages != 0 }
val hasRated = any { it.rated }
val needApi = filter && needTags() && !hasTags || Settings.showGalleryPages.value && !hasPages || hasRated
if (needApi) fillGalleryListByApi(this@fillInfo, url)
if (filter) removeAllSuspend { filterUploader(it) || filterTag(it) || filterTagNamespace(it) }
forEach {
if (it.favoriteSlot == GalleryInfo.NOT_FAVORITED && EhDB.containLocalFavorites(it.gid)) {
it.favoriteSlot = GalleryInfo.LOCAL_FAVORITED
}
if (!needApi) it.generateSLang()
}
val needApi = filter && EhFilter.needTags() && !hasTags || Settings.showGalleryPages && !hasPages || hasRated
if (needApi) fillGalleryListByApi(list, url)
// Filter tag, thumbnail mode need filter uploader again
if (filter) list.removeAllSuspend { EhFilter.filterUploader(it) || EhFilter.filterTag(it) || EhFilter.filterTagNamespace(it) }
}
suspend fun addFavorites(galleryList: List<Pair<Long, String>>, dstCat: Int) {
galleryList.parMap(concurrency = Settings.multiThreadDownload) { (gid, token) -> modifyFavorites(gid, token, dstCat) }
galleryList.chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
if (index != 0) {
delay(REQUEST_INTERVAL)
}
chunk.parMap { (gid, token) ->
modifyFavorites(gid, token, dstCat)
}
}
}
}
@@ -15,111 +15,37 @@
*/
package com.hippo.ehviewer.client
import android.util.Log
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectOkHttpClient
import com.hippo.ehviewer.EhApplication.Companion.okHttpClient
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
import com.hippo.ehviewer.Settings
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import com.hippo.ehviewer.util.bodyAsUtf8Text
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.forms.FormBuilder
import io.ktor.client.request.forms.FormDataContent
import io.ktor.client.request.forms.MultiPartFormDataContent
import io.ktor.client.request.forms.formData
import io.ktor.client.request.header
import io.ktor.client.request.prepareRequest
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpStatement
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.ParametersBuilder
import io.ktor.http.content.TextContent
import io.ktor.http.userAgent
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArrayBuilder
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.okio.decodeFromBufferedSource
import okhttp3.Call
import okhttp3.FormBody
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.executeAsync
import okio.BufferedSource
inline fun ehRequest(url: String, referer: String? = null, origin: String? = null, builder: Request.Builder.() -> Unit = {}) = Request.Builder().url(url).apply {
addHeader("User-Agent", Settings.userAgent)
addHeader("Accept", CHROME_ACCEPT)
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
referer?.let { addHeader("Referer", it) }
origin?.let { addHeader("Origin", it) }
}.apply(builder).build()
inline fun formBody(builder: FormBody.Builder.() -> Unit) = FormBody.Builder().apply(builder).build()
inline fun Request.Builder.formBody(builder: FormBody.Builder.() -> Unit) = post(FormBody.Builder().apply(builder).build())
inline fun multipartBody(builder: MultipartBody.Builder.() -> Unit) = MultipartBody.Builder().apply(builder).build()
val MEDIA_TYPE_JSON: MediaType = "application/json; charset=utf-8".toMediaType()
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
inline fun Request.Builder.jsonBody(builder: JsonObjectBuilder.() -> Unit) = post(buildJsonObject(builder).toString().toRequestBody(MEDIA_TYPE_JSON))
const val TAG_CALL = "CancellableCallReadingScope"
// This scoped response reading scope suspend function aimed at cancelling the call immediately once coroutine is cancelled.
// i.e., bind a call with a suspended coroutine, and launch another coroutine to execute reading
// Once origin coroutine is cancelled, then call cancelled, socket is closed, the reading coroutine will encounter [IOException]
// Since Okio is not suspendable/cancellable, also okhttp, [executeAsync] only make [Request -> Response] cancellable, reading a Response is still not cancellable
// Considering interrupting is not safe and performant, See https://github.com/Kotlin/kotlinx.coroutines/issues/3551#issuecomment-1353245978, we have such [usingCancellable]
// TODO: Remove logging after this function stable
suspend inline fun <R> Call.usingCancellable(crossinline block: suspend Response.() -> R): R = executeAsync().use {
val call = this
Log.d(TAG_CALL, "Got response of call$call")
coroutineScope {
suspendCancellableCoroutine<R> { cont ->
launch {
val r = runCatching {
Log.d(TAG_CALL, "Reading response of call$call")
block(it)
}
if (!isCanceled() && cont.isActive) {
r.exceptionOrNull()?.let {
Log.e(TAG_CALL, "Reading response of call$call failed!")
cont.resumeWithException(it)
}
r.getOrNull()?.let {
Log.d(TAG_CALL, "Reading response of call$call succeed!")
cont.resume(it)
}
} else if (isCanceled() && cont.isActive) {
Log.e(TAG_CALL, "call$call cancelled but coroutine is Active!")
} else if (!isCanceled() && !cont.isActive) {
Log.e(TAG_CALL, "call$call not cancelled but coroutine is Dead!")
} else {
Log.d(TAG_CALL, "Reading response of call$call cancelled")
}
}
cont.invokeOnCancellation {
Log.d(TAG_CALL, "Cancelling call$call")
cancel()
}
}
}.apply {
Log.d(TAG_CALL, "Reading response of call$call finished!")
}
}
suspend inline fun <R> Request.execute(block: Response.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return okHttpClient.newCall(this).executeAsync().use(block)
}
suspend inline fun <R> Request.executeNonCache(crossinline block: suspend Response.() -> R) = baseOkHttpClient.newCall(this).usingCancellable(block)
suspend inline fun <R> Request.executeNoRedirect(block: Response.() -> R) = noRedirectOkHttpClient.newCall(this).executeAsync().use(block)
suspend inline fun <reified T> Request.executeAndParseAs() = execute { parseAs<T>() }
inline fun <reified T> Response.parseAs(): T = body.source().parseAs()
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = execute { it.bodyAsUtf8Text().parseAs<T>() }
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
@@ -128,3 +54,50 @@ val json = Json { ignoreUnknownKeys = true }
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36"
const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
fun HttpRequestBuilder.applyEhConfig(referer: String?, origin: String?) {
method = HttpMethod.Get
header("Referer", referer)
header("Origin", origin)
userAgent(Settings.userAgent)
header(HttpHeaders.Accept, CHROME_ACCEPT)
header(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
}
suspend inline fun ehRequest(
url: String,
referer: String? = null,
origin: String? = null,
verbose: Boolean = true,
builder: HttpRequestBuilder.() -> Unit = {},
) = ktorClient.prepareRequest(url) {
applyEhConfig(referer, origin)
apply(builder)
}.also { if (verbose) logcat("EhRequest") { url } }
suspend inline fun noRedirectEhRequest(
url: String,
referer: String? = null,
origin: String? = null,
builder: HttpRequestBuilder.() -> Unit = {},
) = noRedirectKtorClient.prepareRequest(url) {
applyEhConfig(referer, origin)
apply(builder)
}.also { logcat("EhRequest") { url } }
inline fun HttpRequestBuilder.formBody(builder: ParametersBuilder.() -> Unit) {
method = HttpMethod.Post
val parameters = ParametersBuilder().apply(builder).build()
setBody(FormDataContent(parameters))
}
inline fun HttpRequestBuilder.jsonBody(builder: JsonObjectBuilder.() -> Unit) {
method = HttpMethod.Post
val json = buildJsonObject(builder).toString()
setBody(TextContent(text = json, contentType = ContentType.Application.Json))
}
fun HttpRequestBuilder.multipartBody(builder: FormBuilder.() -> Unit) {
method = HttpMethod.Post
setBody(MultiPartFormDataContent(formData(builder)))
}
@@ -19,11 +19,20 @@ import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.copyToFile
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.copyTo
import com.hippo.unifile.asUniFile
import com.hippo.unifile.sha1
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.isSuccess
import java.io.File
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.CoroutineScope
@@ -35,12 +44,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import moe.tarsin.coroutines.runSuspendCatching
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.executeAsync
import okio.BufferedSource
import okio.HashingSink.Companion.sha1
import okio.blackholeSink
import okio.buffer
import okio.source
import splitties.init.appCtx
@@ -60,7 +64,7 @@ object EhTagDatabase : CoroutineScope {
var initialized by mutableStateOf(false)
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? {
return tagGroups[prefix]?.get(tag)?.trim()?.takeIf { it.isNotEmpty() }
return tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
}
private fun internalSuggestFlow(
@@ -103,7 +107,7 @@ object EhTagDatabase : CoroutineScope {
val prefix = keyword.dropLast(mKeyword.length)
val namespace = mKeyword.substringBefore(':')
val tagKeyword = mKeyword.drop(namespace.length + 1)
val namespacePrefix = namespaceToPrefix(namespace) ?: namespace
val namespacePrefix = TagNamespace(namespace).toPrefix() ?: namespace
val tags = tagGroups[namespacePrefix.takeIf { tagKeyword.isNotEmpty() && it != NAMESPACE_PREFIX }]
tags?.let {
internalSuggestFlow(it, tagKeyword, translate, exactly).collect { (hint, tag) ->
@@ -127,24 +131,6 @@ object EhTagDatabase : CoroutineScope {
private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean =
removeSpace().contains(other.removeSpace(), ignoreCase)
private val NAMESPACE_TO_PREFIX = HashMap<String, String>().also {
it["artist"] = "a"
it["cosplayer"] = "cos"
it["character"] = "c"
it["female"] = "f"
it["group"] = "g"
it["language"] = "l"
it["male"] = "m"
it["mixed"] = "x"
it["other"] = "o"
it["parody"] = "p"
it["reclass"] = "r"
}
fun namespaceToPrefix(namespace: String): String? {
return NAMESPACE_TO_PREFIX[namespace]
}
private fun getMetadata(context: Context): Array<String>? {
return context.resources.getStringArray(R.array.tag_translation_metadata)
.takeIf { it.size == 4 }
@@ -160,39 +146,22 @@ object EhTagDatabase : CoroutineScope {
}.getOrNull()
}
private fun getFileSha1(file: File): String? {
return runCatching {
file.source().buffer().use { source ->
sha1(blackholeSink()).use {
source.readAll(it)
it.hash.hex()
}
}
}.getOrNull()
}
private fun checkData(sha1: String?, data: File): Boolean {
return sha1 != null && sha1 == getFileSha1(data)
return sha1 != null && sha1 == data.asUniFile().sha1()
}
private suspend fun save(client: OkHttpClient, url: String, file: File): Boolean {
val request: Request = Request.Builder().url(url).build()
val call = client.newCall(request)
private suspend fun save(client: HttpClient, url: String, file: File) {
runCatching {
call.executeAsync().use { response ->
if (!response.isSuccessful) {
return false
client.prepareGet(url).execute {
if (it.status.isSuccess()) {
it.bodyAsChannel().copyTo(file)
} else {
throw StatusCodeException(it.status.value)
}
response.body.use {
it.copyToFile(file)
}
return true
}
}.onFailure {
file.delete()
it.printStackTrace()
}
return false
}.getOrThrow()
}
suspend fun update() {
@@ -217,7 +186,7 @@ object EhTagDatabase : CoroutineScope {
runSuspendCatching {
issueUpdateInMemoryData()
}.onFailure {
it.printStackTrace()
logcat(it)
}
updateInternal()
}
@@ -246,7 +215,7 @@ object EhTagDatabase : CoroutineScope {
// Save new sha1
val tempSha1File = File(dir, "$sha1Name.tmp")
check(save(baseOkHttpClient, sha1Url, tempSha1File))
save(ktorClient, sha1Url, tempSha1File)
val tempSha1 = getFileContent(tempSha1File)
// Check new sha1 and current sha1
@@ -258,7 +227,7 @@ object EhTagDatabase : CoroutineScope {
// Save new data
val tempDataFile = File(dir, "$dataName.tmp")
check(save(baseOkHttpClient, dataUrl, tempDataFile))
save(ktorClient, dataUrl, tempDataFile)
// Check new sha1 and new data
if (!checkData(tempSha1, tempDataFile)) {
@@ -276,7 +245,7 @@ object EhTagDatabase : CoroutineScope {
// Read new EhTagDatabase
issueUpdateInMemoryData(dataFile)
}.onFailure {
it.printStackTrace()
logcat(it)
}
}
}
@@ -16,7 +16,10 @@
package com.hippo.ehviewer.client
import com.hippo.ehviewer.Settings
import okhttp3.HttpUrl
import io.ktor.http.Parameters
import io.ktor.http.ParametersBuilder
import io.ktor.http.URLBuilder
import io.ktor.http.URLProtocol
object EhUrl {
const val SITE_E = 0
@@ -49,18 +52,11 @@ object EhUrl {
const val REFERER_E = "https://$DOMAIN_E"
const val ORIGIN_E = REFERER_E
val host: String
get() = when (Settings.gallerySite) {
SITE_E -> HOST_E
SITE_EX -> HOST_EX
else -> HOST_E
}
val domain: String
get() = when (Settings.gallerySite) {
SITE_E -> DOMAIN_E
SITE_EX -> DOMAIN_EX
else -> HOST_E
else -> DOMAIN_E
}
val apiUrl: String
@@ -116,39 +112,54 @@ object EhUrl {
else -> URL_IMAGE_SEARCH_E
}
fun getGalleryDetailUrl(gid: Long, token: String?): String {
return getGalleryDetailUrl(gid, token, 0, false)
}
fun getGalleryDetailUrl(gid: Long, token: String, index: Int = 0, allComment: Boolean = false) =
ehUrl(listOf("g", "$gid", token)) {
if (index != 0) addQueryParameter("p", "$index")
if (allComment) addQueryParameter("hc", "1")
}.buildString()
fun getGalleryDetailUrl(gid: Long, token: String?, index: Int, allComment: Boolean) = ehUrl {
addPathSegments("g/$gid/$token/")
if (index != 0) addQueryParameter("p", "$index")
if (allComment) addQueryParameter("hc", "1")
}.toString()
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl(listOf("mpv", "$gid", token)).buildString()
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl {
addPathSegments("mpv/$gid/$token/")
}.toString()
fun getPageUrl(gid: Long, index: Int, pToken: String, nl: String? = null) = ehUrl(listOf("s", pToken, "$gid-${index + 1}")) {
addQueryParameterIfNotBlank("nl", nl)
}.buildString()
fun getPageUrl(gid: Long, index: Int, pToken: String): String {
return host + "s/" + pToken + '/' + gid + '-' + (index + 1)
}
fun getAddFavorites(gid: Long, token: String) = ehUrl("gallerypopups.php") {
addQueryParameter("gid", "$gid")
addQueryParameter("t", token)
addQueryParameter("act", "addfav")
}.buildString()
fun getAddFavorites(gid: Long, token: String?): String {
return host + "gallerypopups.php?gid=" + gid + "&t=" + token + "&act=addfav"
}
fun getDownloadArchive(gid: Long, token: String, or: String) = ehUrl("archiver.php") {
addQueryParameter("gid", "$gid")
addQueryParameter("token", token)
addQueryParameter("or", or)
}.buildString()
fun getDownloadArchive(gid: Long, token: String?, or: String): String {
return host + "archiver.php?gid=" + gid + "&token=" + token + "&or=" + or
}
fun getTagDefinitionUrl(tag: String): String {
return "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
}
fun getTagDefinitionUrl(tag: String) = "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
}
inline fun httpsUrl(builder: HttpUrl.Builder.() -> Unit) = HttpUrl.Builder().apply(builder).scheme("https").build()
inline fun ehUrl(builder: HttpUrl.Builder.() -> Unit) = httpsUrl { apply(builder).host(EhUrl.domain) }
fun HttpUrl.Builder.addQueryParameterIfNotBlank(name: String, value: String?) = apply {
if (!value.isNullOrBlank()) addQueryParameter(name, value)
inline fun ehUrl(path: String? = null, host: String = EhUrl.domain, builder: ParametersBuilder.() -> Unit) = ehUrl(
path = if (path.isNullOrBlank()) emptyList() else listOf(path),
host = host,
parameters = Parameters.build(builder),
)
inline fun ehUrl(path: List<String>, host: String = EhUrl.domain, builder: ParametersBuilder.() -> Unit) = ehUrl(
path = path,
host = host,
parameters = Parameters.build(builder),
)
fun ehUrl(path: List<String>, host: String = EhUrl.domain, parameters: Parameters = Parameters.Empty) = URLBuilder(
protocol = URLProtocol.HTTPS,
host = host,
pathSegments = path,
parameters = parameters,
)
fun ParametersBuilder.addQueryParameterIfNotBlank(name: String, value: String?) = apply {
if (!value.isNullOrBlank()) append(name, value)
}
fun ParametersBuilder.addQueryParameter(name: String, value: String) = append(name, value)
@@ -15,19 +15,18 @@
*/
package com.hippo.ehviewer.client
import android.annotation.SuppressLint
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.graphics.ColorUtils
import arrow.core.memoize
import com.google.android.material.color.utilities.Hct
import com.google.android.material.color.utilities.MathUtils
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.GalleryInfo
import java.util.regex.Pattern
import kotlin.math.abs
object EhUtils {
const val NONE = -1 // Use it for homepage
@@ -102,14 +101,27 @@ object EhUtils {
return CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
}
@SuppressLint("RestrictedApi")
private fun differenceDegrees(a: Float, b: Float): Float {
return 180.0f - abs(abs(a - b) - 180.0f)
}
private fun sanitizeDegreesDouble(degrees: Float): Float {
val deg = degrees % 360.0f
return if (deg < 0) deg + 360.0f else deg
}
private fun rotationDirection(from: Float, to: Float): Float {
val increasingDifference = sanitizeDegreesDouble(to - from)
return if (increasingDifference <= 180.0) 1.0f else -1.0f
}
val harmonizeWithRole = { primaryContainer: Int, src: Int ->
val fromHct = Hct.fromInt(src)
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))
Hct.from(outputHue, toHct.chroma, toHct.tone).toInt()
val fromHct = FloatArray(3).apply { ColorUtils.colorToM3HCT(src, this) }
val toHct = FloatArray(3).apply { ColorUtils.colorToM3HCT(primaryContainer, this) }
val differenceDegrees = differenceDegrees(fromHct[0], toHct[0])
val rotationDegrees = minOf(differenceDegrees * 0.5f, 15.0f)
val outputHue = sanitizeDegreesDouble(fromHct[0] + rotationDegrees * rotationDirection(fromHct[0], toHct[0]))
ColorUtils.M3HCTToColor(outputHue, toHct[1], toHct[2])
}.memoize()
@Stable
@@ -5,7 +5,6 @@ interface AbstractDownloadInfo {
var legacy: Int
var time: Long
var label: String?
var position: Int
var speed: Long
var remaining: Long
var finished: Int
@@ -16,23 +16,27 @@
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import androidx.annotation.Keep
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Parcelize
@Serializable
@Entity(tableName = "GALLERIES")
data class BaseGalleryInfo @Keep constructor(
@TypeConverters(SimpleTagsConverter::class)
data class BaseGalleryInfo(
@PrimaryKey
@ColumnInfo(name = "GID")
override var gid: Long = 0,
override var gid: Long,
@ColumnInfo(name = "TOKEN")
override var token: String? = null,
override var token: String,
@ColumnInfo(name = "TITLE")
override var title: String? = null,
@@ -61,10 +65,10 @@ data class BaseGalleryInfo @Keep constructor(
@Ignore
override var rated: Boolean = false,
@Ignore
override var simpleTags: ArrayList<String>? = null,
@ColumnInfo(name = "SIMPLE_TAGS")
override var simpleTags: List<String>? = null,
@Ignore
@ColumnInfo(name = "PAGES", defaultValue = "0")
override var pages: Int = 0,
@Ignore
@@ -85,5 +89,13 @@ data class BaseGalleryInfo @Keep constructor(
@Ignore
override var favoriteNote: String? = null,
) : GalleryInfo, Parcelable {
constructor() : this(0)
constructor() : this(0, "")
}
class SimpleTagsConverter {
@TypeConverter
fun fromString(value: String) = value.split(", ")
@TypeConverter
fun toString(value: List<String>) = value.joinToString()
}
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.client.data
import android.os.Parcelable
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.addQueryParameter
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
import com.hippo.ehviewer.client.ehUrl
import kotlinx.parcelize.Parcelize
@@ -34,24 +35,20 @@ data class FavListUrlBuilder(
prev = index.takeUnless { isNext }
}
fun build() = ehUrl {
addPathSegment(EhUrl.FAV_PATH)
if (isValidFavCat(favCat)) {
addQueryParameter("favcat", "$favCat")
} else if (favCat == FAV_CAT_ALL) {
addQueryParameter("favcat", "all")
fun build() = ehUrl(EhUrl.FAV_PATH) {
when {
isValidFavCat(favCat) -> addQueryParameter("favcat", "$favCat")
favCat == FAV_CAT_ALL -> addQueryParameter("favcat", "all")
}
addQueryParameterIfNotBlank("f_search", keyword)
addQueryParameterIfNotBlank("prev", prev)
addQueryParameterIfNotBlank("next", next)
addQueryParameterIfNotBlank("seek", jumpTo)
}.toString()
}.buildString()
companion object {
const val FAV_CAT_ALL = -1
const val FAV_CAT_LOCAL = -2
fun isValidFavCat(favCat: Int): Boolean {
return favCat in 0..9
}
fun isValidFavCat(favCat: Int) = favCat in 0..9
}
}
@@ -45,7 +45,7 @@ data class GalleryDetail(
var torrentUrl: String? = null,
var archiveUrl: String? = null,
var parent: String? = null,
var newerVersions: ArrayList<BaseGalleryInfo> = ArrayList(),
var newerVersions: List<BaseGalleryInfo> = emptyList(),
var visible: String? = null,
var language: String? = null,
var size: String? = null,
@@ -56,8 +56,9 @@ data class GalleryDetail(
val previewPages: Int,
val previewList: List<GalleryPreview>,
) : GalleryInfo by galleryInfo, Parcelable {
override fun generateSLang() {
fun fillInfo() {
val index = LANGUAGES.indexOf(language)
if (index != -1) simpleLanguage = S_LANGS[index]
simpleTags = tags.flatten()
}
}
@@ -20,7 +20,7 @@ import moe.tarsin.kt.unreachable
interface GalleryInfo {
var gid: Long
var token: String?
var token: String
var title: String?
var titleJpn: String?
var thumbKey: String?
@@ -30,7 +30,7 @@ interface GalleryInfo {
var disowned: Boolean
var rating: Float
var rated: Boolean
var simpleTags: ArrayList<String>?
var simpleTags: List<String>?
var pages: Int
var thumbWidth: Int
var thumbHeight: Int
@@ -93,7 +93,7 @@ interface GalleryInfo {
Pattern.compile("[(\\[]hun(?:garian)?[)\\]]|ハンガリー翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]dutch[)\\]]|オランダ翻訳", Pattern.CASE_INSENSITIVE),
)
private val S_LANG_TAGS = arrayOf(
val S_LANG_TAGS = arrayOf(
"language:english",
"language:chinese",
"language:spanish",
@@ -17,13 +17,14 @@ package com.hippo.ehviewer.client.data
import android.os.Parcelable
import androidx.annotation.IntDef
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.addQueryParameter
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
import com.hippo.ehviewer.client.ehUrl
import com.hippo.ehviewer.dao.QuickSearch
import com.hippo.ehviewer.ui.main.AdvanceTable
import com.hippo.ehviewer.util.encodeUTF8
import com.hippo.ehviewer.util.toIntOrDefault
import java.io.UnsupportedEncodingException
import java.net.URLDecoder
@@ -37,6 +38,7 @@ data class ListUrlBuilder(
var mNext: String? = null,
// Reset to null after initial loading
var mJumpTo: String? = null,
private var mRange: Int = 0,
var category: Int = EhUtils.NONE,
private var mKeyword: String? = null,
var hash: String? = null,
@@ -45,19 +47,25 @@ data class ListUrlBuilder(
var pageFrom: Int = -1,
var pageTo: Int = -1,
var imagePath: String? = null,
var isUseSimilarityScan: Boolean = false,
var isOnlySearchCovers: Boolean = false,
) : Parcelable {
fun setIndex(index: String?, isNext: Boolean = true) {
mNext = index?.takeIf { isNext }
mPrev = index?.takeUnless { isNext }
fun setIndex(index: String, isNext: Boolean = true) {
mNext = index.takeIf { isNext }
mPrev = index.takeUnless { isNext }
mRange = 0
}
fun setJumpTo(jumpTo: Int) {
mJumpTo = jumpTo.takeUnless { it == 0 }?.toString()
}
fun setRange(range: Int) {
mRange = range
mPrev = null
mNext = null
mJumpTo = null
}
var keyword: String?
get() = if (MODE_UPLOADER == mode) "uploader:$mKeyword" else mKeyword
set(keyword) {
@@ -72,7 +80,7 @@ data class ListUrlBuilder(
minRating = q.minRating
pageFrom = q.pageFrom
pageTo = q.pageTo
mNext = q.name.substringAfterLast('@', "").takeIf { it.isNotEmpty() }
mNext = q.name.substringAfterLast('@', "").ifEmpty { null }
}
fun toQuickSearch(name: String): QuickSearch {
@@ -253,31 +261,23 @@ data class ListUrlBuilder(
fun build(): String {
return when (mode) {
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl {
if (mode == MODE_SUBSCRIPTION) addPathSegment(EhUrl.WATCHED_PATH)
if (category != EhUtils.NONE) {
addEncodedQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
if (category > 0) {
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
}
addQueryParameterIfNotBlank("f_shash", hash)
val query = mKeyword?.let {
val index = Settings.languageFilter.value
GalleryInfo.S_LANG_TAGS.getOrNull(index)?.let { lang ->
"$it $lang"
} ?: it
}
addQueryParameterIfNotBlank("f_search", query)
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
// Search key
// the settings of ub:UrlBuilder may be overwritten by following Advance search
mKeyword?.split('|')?.forEachIndexed { idx, kwd ->
val keyword = kwd.trim { it <= ' ' }
when (idx) {
0 -> addQueryParameterIfNotBlank("f_search", keyword)
else -> keyword.indexOf(':').takeIf { it >= 0 }?.run {
val key = keyword.substring(0, this).trim { it <= ' ' }
val value = keyword.substring(this + 1).trim { it <= ' ' }
addQueryParameterIfNotBlank(key, value)
}
}
}
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
// Advance search
if (advanceSearch != -1) {
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
addQueryParameter("advsearch", "1")
if (advanceSearch and AdvanceTable.SH != 0) {
addQueryParameter("f_sh", "on")
@@ -295,72 +295,44 @@ data class ListUrlBuilder(
addQueryParameter("f_sft", "on")
}
// Set min star
if (minRating != -1) {
if (minRating > 0) {
addQueryParameter("f_sr", "on")
addQueryParameter("f_srdd", "$minRating")
}
// Pages
if (pageFrom != -1 || pageTo != -1) {
if (pageFrom > 0 || pageTo > 0) {
addQueryParameter("f_sp", "on")
addQueryParameterIfNotBlank(
"f_spf",
pageFrom.takeUnless { it == -1 }?.toString(),
pageFrom.takeIf { it > 0 }?.toString(),
)
addQueryParameterIfNotBlank(
"f_spt",
pageTo.takeUnless { it == -1 }?.toString(),
pageTo.takeIf { it > 0 }?.toString(),
)
}
}
}.toString()
}.buildString()
MODE_UPLOADER -> {
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)
}
}
}
MODE_TAG -> {
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)
}
}
MODE_UPLOADER, MODE_TAG -> {
val path = if (mode == MODE_UPLOADER) "uploader" else "tag"
ehUrl(listOf(path, requireNotNull(mKeyword))) {
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
}.buildString()
}
MODE_WHATS_HOT -> EhUrl.popularUrl
MODE_IMAGE_SEARCH -> EhUrl.imageSearchUrl
MODE_IMAGE_SEARCH -> ehUrl {
addQueryParameter("f_shash", requireNotNull(hash))
}.buildString()
MODE_TOPLIST -> {
buildString {
append(EhUrl.HOST_E)
append("toplist.php?tl=")
append(mKeyword!!)
mJumpTo?.let {
append("&p=").append(it)
}
}
ehUrl("toplist.php", EhUrl.DOMAIN_E) {
addQueryParameter("tl", requireNotNull(mKeyword))
addQueryParameterIfNotBlank("p", mJumpTo)
}.buildString()
}
else -> throw IllegalStateException("Unexpected value: $mode")
@@ -0,0 +1,33 @@
package com.hippo.ehviewer.client.data
@JvmInline
value class TagNamespace(val value: String) {
fun toPrefix(): String? = prefixMap[this]
companion object {
val Artist = TagNamespace("artist")
val Cosplayer = TagNamespace("cosplayer")
val Character = TagNamespace("character")
val Female = TagNamespace("female")
val Group = TagNamespace("group")
val Language = TagNamespace("language")
val Male = TagNamespace("male")
val Mixed = TagNamespace("mixed")
val Other = TagNamespace("other")
val Parody = TagNamespace("parody")
val Reclass = TagNamespace("reclass")
private val prefixMap = mapOf(
Artist to "a",
Cosplayer to "cos",
Character to "c",
Female to "f",
Group to "g",
Language to "l",
Male to "m",
Mixed to "x",
Other to "o",
Parody to "p",
Reclass to "r",
)
}
}
@@ -1,5 +0,0 @@
package com.hippo.ehviewer.client.exception
import com.hippo.ehviewer.R
class CloudflareBypassException : EhException(R.string.cloudflare_bypass_failed)
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.NoHAtHClientException
import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup
object ArchiveParser {
@@ -48,7 +49,7 @@ object ArchiveParser {
}.onSuccess {
archiveList.add(it)
}.onFailure {
it.printStackTrace()
logcat(it)
}
}
}
@@ -87,5 +88,5 @@ object ArchiveParser {
val isHAtH: Boolean,
)
class Result(val paramOr: String?, val archiveList: List<Archive>, var funds: HomeParser.Funds?)
class Result(val paramOr: String, val archiveList: List<Archive>, var funds: HomeParser.Funds?)
}
@@ -0,0 +1,13 @@
package com.hippo.ehviewer.client.parser
import io.ktor.util.moveToByteArray
import java.nio.ByteBuffer
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
inline fun <reified T> unmarshalParsingAs(body: ByteBuffer, parser: (ByteBuffer, Int) -> Int): T {
val cborBytes = parser(body, body.limit())
body.limit(cborBytes)
val array = body.moveToByteArray()
return Cbor.decodeFromByteArray<T>(array)
}
@@ -1,32 +1,29 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import moe.tarsin.coroutines.runSuspendCatching
@Serializable
data class FavParserResult(
val catArray: List<String>,
val countArray: List<Int>,
val galleryListResult: GalleryListResult,
) {
val prev = galleryListResult.prev
val next = galleryListResult.next
val galleryInfoList = galleryListResult.galleryInfoList
}
object FavoritesParser {
suspend fun parse(body: ByteBuffer): Result {
val catArray = arrayOfNulls<String>(10)
val countArray = runSuspendCatching {
parseFav(body, favCat = catArray).also { check(it.isNotEmpty()) }
fun parse(body: ByteBuffer): FavParserResult {
return runSuspendCatching {
unmarshalParsingAs<FavParserResult>(body, ::parseFav)
}.getOrElse {
if (it is RuntimeException && it.message == "Not logged in!") throw NotLoggedInException()
throw ParseException("Parse favorites error", it)
}
val result = GalleryListParser.parse(body)
return Result(catArray.requireNoNulls(), countArray, result)
}
class Result(
val catArray: Array<String>,
val countArray: IntArray,
galleryListResult: GalleryListResult,
) {
val prev = galleryListResult.prev
val next = galleryListResult.next
val galleryInfoList = galleryListResult.galleryInfoList
}
}
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit(), favCat: Array<String?>): IntArray
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit()): Int
@@ -60,6 +60,6 @@ object GalleryApiParser {
@SerialName("filecount")
val pages: Int,
val rating: Float,
val tags: ArrayList<String>,
val tags: List<String>,
)
}
@@ -38,14 +38,15 @@ import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.exception.PiningException
import com.hippo.ehviewer.client.getThumbKey
import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.ehviewer.util.toEpochMillis
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 eu.kanade.tachiyomi.util.system.logcat
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format.MonthNames
import kotlinx.datetime.format.char
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
@@ -77,8 +78,19 @@ object GalleryDetailParser {
private val PATTERN_FAVORITE_SLOT =
Regex("/fav.png\\); background-position:0px -(\\d+)px")
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)
// dd MMMM yyyy, HH:mm
private val WEB_COMMENT_DATE_FORMAT = LocalDateTime.Format {
dayOfMonth()
char(' ')
monthName(MonthNames.ENGLISH_FULL)
char(' ')
year()
chars(", ")
hour()
char(':')
minute()
}
private const val OFFENSIVE_STRING =
"<p>(And if you choose to ignore this warning, you lose all rights to complain about it in the future.)</p>"
private const val PINING_STRING = "<p>This gallery is pining for the fjords.</p>"
@@ -93,8 +105,7 @@ object GalleryDetailParser {
// Error info
PATTERN_ERROR.find(body)?.run { throw EhException(groupValues[1]) }
// Temporary workaround, see https://github.com/jhy/jsoup/issues/1850
val document = Jsoup.parse(body.replace("del>", "s>"))
val document = Jsoup.parse(body)
val galleryDetail = GalleryDetail(
tags = parseTagGroups(document),
comments = parseComments(document),
@@ -103,8 +114,8 @@ object GalleryDetailParser {
)
parseDetail(galleryDetail, document, body)
// Generate simpleLanguage for local favorites
galleryDetail.generateSLang()
// Fill info for database
galleryDetail.fillInfo()
return galleryDetail
}
@@ -220,15 +231,10 @@ object GalleryDetailParser {
// newer version
d.getElementById("gnd")?.run {
val dates = PATTERN_NEWER_DATE.findAll(body).map { it.groupValues[1] }.toList()
select("a").forEachIndexed { index, element ->
val gi = BaseGalleryInfo()
gd.newerVersions = select("a").mapIndexedNotNull { index, element ->
val result = GalleryDetailUrlParser.parse(element.attr("href"))
if (result != null) {
gi.gid = result.gid
gi.token = result.token
gi.title = element.text().trim()
gi.posted = dates[index]
gd.newerVersions.add(gi)
result?.run {
BaseGalleryInfo(gid, token, element.text().trim(), posted = dates[index])
}
}
}
@@ -299,7 +305,7 @@ object GalleryDetailParser {
if (group.size > 0) group else null
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
logcat(e)
null
}
}
@@ -314,7 +320,7 @@ object GalleryDetailParser {
parseTagGroups(tagGroups)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
logcat(e)
emptyList()
}
}
@@ -324,7 +330,7 @@ object GalleryDetailParser {
trs.mapNotNull { parseTagGroup(it) }
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
logcat(e)
emptyList()
}
}
@@ -377,7 +383,7 @@ object GalleryDetailParser {
} else {
temp.substring("Posted on ".length) to null
}
val time = Instant.from(WEB_COMMENT_DATE_FORMAT.parse(timeStr)).toEpochMilli()
val time = WEB_COMMENT_DATE_FORMAT.parse(timeStr).toEpochMillis()
// comment
val c6 = element.getElementsByClass("c6").first()
// fix underline support
@@ -396,14 +402,14 @@ object GalleryDetailParser {
}
// last edited
val c8 = element.getElementsByClass("c8").first()
val lastEdited = c8?.children()?.first()?.run { Instant.from(WEB_COMMENT_DATE_FORMAT.parse(text())).toEpochMilli() } ?: 0
val lastEdited = c8?.children()?.first()?.run { WEB_COMMENT_DATE_FORMAT.parse(text()).toEpochMillis() } ?: 0
GalleryComment(
id, score, editable, voteUpAble, voteUpEd, voteDownAble,
voteDownEd, uploader, voteState, time, user, commentHtml, lastEdited,
)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
logcat(e)
null
}
}
@@ -412,6 +418,8 @@ object GalleryDetailParser {
* Parse comments with html parser
*/
suspend fun parseComments(document: Document): GalleryCommentList {
// Disable pretty print to get comments in raw html
document.outputSettings().prettyPrint(false)
return try {
val cdiv = document.getElementById("cdiv")!!
val c1s = cdiv.getElementsByClass("c1")
@@ -433,7 +441,7 @@ object GalleryDetailParser {
GalleryCommentList(list, hasMore)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
logcat(e)
EMPTY_GALLERY_COMMENT_LIST
}
}
@@ -40,5 +40,5 @@ object GalleryDetailUrlParser {
}
}
class Result(val gid: Long, val token: String)
data class Result(val gid: Long, val token: String)
}
@@ -1,48 +1,26 @@
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
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 kotlinx.serialization.Serializable
import moe.tarsin.coroutines.runSuspendCatching
object GalleryListParser {
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)
}
}
fun parse(body: ByteBuffer) = runSuspendCatching {
unmarshalParsingAs<GalleryListResult>(body, ::parseGalleryInfoList)
}.getOrElse {
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 @Keep constructor(
@Serializable
data class GalleryListResult(
val prev: String?,
val next: String?,
val galleryInfoList: ArrayList<BaseGalleryInfo>,
)
private external fun parseGalleryInfoList(body: ByteBuffer, size: Int = body.limit()): GalleryListResult
private external fun parseGalleryInfoList(body: ByteBuffer, size: Int = body.limit()): Int
@@ -19,6 +19,7 @@ import android.net.Uri
import android.os.Build
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.ListUrlBuilder
import eu.kanade.tachiyomi.util.system.logcat
import java.io.UnsupportedEncodingException
import java.net.MalformedURLException
import java.net.URL
@@ -77,7 +78,7 @@ object GalleryListUrlParser {
try {
URLDecoder.decode(uploader, StandardCharsets.UTF_8.displayName())
} catch (e: UnsupportedEncodingException) {
e.printStackTrace()
logcat(e)
return null
}
}
@@ -106,7 +107,7 @@ object GalleryListUrlParser {
try {
URLDecoder.decode(tag, StandardCharsets.UTF_8.displayName())
} catch (e: UnsupportedEncodingException) {
e.printStackTrace()
logcat(e)
return null
}
}
@@ -18,6 +18,7 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.util.ExceptionUtils
import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup
object GalleryNotAvailableParser {
@@ -27,7 +28,7 @@ object GalleryNotAvailableParser {
e!!.child(0).html().replace("<br>", "\n")
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
it.printStackTrace()
logcat(it)
null
}
}
@@ -20,7 +20,7 @@ import com.hippo.ehviewer.util.unescapeXml
object GalleryPageParser {
private val PATTERN_IMAGE_URL = Regex("<img[^>]*src=\"([^\"]+)\" style")
private val PATTERN_SKIP_HATH_KEY = Regex("onclick=\"return nl\\('([\\d-]+)'\\)")
private val PATTERN_SKIP_HATH_KEY = Regex("onclick=\"return nl\\('([^)]+)'\\)")
private val PATTERN_ORIGIN_IMAGE_URL = Regex("<a href=\"([^\"]+/fullimg/[^\"]+)\">")
// TODO Not sure about the size of show keys
@@ -1,18 +1,18 @@
package com.hippo.ehviewer.client.parser
import android.os.Parcelable
import androidx.annotation.Keep
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
object HomeParser {
private val PATTERN_FUNDS = Regex("Available: ([\\d,]+) Credits.*Available: ([\\d,]+) kGP", RegexOption.DOT_MATCHES_ALL)
private const val INSUFFICIENT_FUNDS = "Insufficient funds."
fun parse(body: ByteBuffer) = runCatching {
parseLimit(body)
unmarshalParsingAs<Limits>(body, ::parseLimit)
}.getOrElse { throw ParseException("Parse image limits error", it) }
fun parseResetLimits(body: String) {
@@ -32,10 +32,13 @@ object HomeParser {
@Parcelize
data class Funds(val fundsGP: Int, val fundsC: Int) : Parcelable
class Result(val limits: Limits, val funds: Funds)
@Parcelize
data class Result(val limits: Limits, val funds: Funds) : Parcelable
}
@Parcelize
data class Limits @Keep constructor(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
@Serializable
data class Limits(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
private external fun parseLimit(body: ByteBuffer, limit: Int = body.limit()): Limits
private external fun parseLimit(body: ByteBuffer, limit: Int = body.limit()): Int
@@ -16,20 +16,29 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.util.toIntOrDefault
import com.hippo.ehviewer.util.toLocalDateTime
import com.hippo.ehviewer.util.toLongOrDefault
import com.hippo.ehviewer.util.unescapeXml
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format.char
object ParserUtils {
private val formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.US).withZone(ZoneOffset.UTC)
// yyyy-MM-dd HH:mm
private val formatter = LocalDateTime.Format {
year()
char('-')
monthNumber()
char('-')
dayOfMonth()
char(' ')
hour()
char(':')
minute()
}
@Synchronized
fun formatDate(time: Long): String {
return formatter.format(Instant.ofEpochMilli(time))
return formatter.format(time.toLocalDateTime())
}
fun trim(str: String?): String {
@@ -15,14 +15,13 @@
*/
package com.hippo.ehviewer.client.parser
import android.util.Log
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.util.ExceptionUtils
import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup
object ProfileParser {
private val TAG = ProfileParser::class.java.simpleName
fun parse(body: String): Result {
return runCatching {
val d = Jsoup.parse(body)
@@ -31,7 +30,7 @@ object ProfileParser {
val avatar = runCatching {
val avatar =
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
if (avatar.isNullOrEmpty()) {
if (avatar.isEmpty()) {
null
} else if (!avatar.startsWith("http")) {
EhUrl.URL_FORUMS + avatar
@@ -40,7 +39,7 @@ object ProfileParser {
}
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
Log.i(TAG, "No avatar")
logcat { "No avatar" }
null
}
Result(displayName, avatar)
@@ -1,16 +1,19 @@
package com.hippo.ehviewer.client.parser
import androidx.annotation.Keep
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
object TorrentParser {
fun parse(body: ByteBuffer) = runCatching {
parseTorrent(body)
}.getOrElse { throw ParseException("Can't parse torrent list", it) }
unmarshalParsingAs<TorrentResult>(body, ::parseTorrent)
}.getOrElse {
throw ParseException("Can't parse torrent list", it)
}
}
class Torrent @Keep constructor(
@Serializable
data class Torrent(
val posted: String,
val size: String,
val seeds: Int,
@@ -24,4 +27,4 @@ fun Torrent.format() = "[$posted] $name [$size] [↑$seeds ↓$peers ✓$downloa
typealias TorrentResult = List<Torrent>
private external fun parseTorrent(body: ByteBuffer, size: Int = body.limit()): ArrayList<Torrent>
private external fun parseTorrent(body: ByteBuffer, size: Int = body.limit()): Int
@@ -1,66 +0,0 @@
@file:Suppress("BlockingMethodInNonBlockingContext")
package com.hippo.ehviewer.coil
import android.net.Uri
import android.webkit.MimeTypeMap
import coil.ComponentRegistry
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.Options
import com.hippo.ehviewer.cronet.copyToChannel
import com.hippo.ehviewer.cronet.cronetRequest
import com.hippo.ehviewer.cronet.execute
import com.hippo.ehviewer.cronet.noCache
import java.io.RandomAccessFile
/**
* A HttpUriFetcher impl use Cronet for jvmheapless IO
* cacheHeader is never respected | recorded since thumb is immutable
*/
class CronetHttpUriFetcher(private val data: String, private val options: Options, private val imageLoader: ImageLoader) : Fetcher {
override suspend fun fetch(): FetchResult {
val diskCacheKey = options.diskCacheKey ?: data
val diskCache = requireNotNull(imageLoader.diskCache)
val snapshot = diskCache.openSnapshot(diskCacheKey) ?: run {
val success = cronetRequest(data) {
noCache()
}.execute {
diskCache.suspendEdit(diskCacheKey) {
RandomAccessFile(data.toFile(), "rw").use {
copyToChannel(it.channel)
}
}
}
check(success)
// diskcache snapshot MUST exist here
requireNotNull(diskCache.openSnapshot(diskCacheKey))
}
return SourceResult(
source = ImageSource(snapshot.data, diskCache.fileSystem, diskCacheKey, snapshot),
mimeType = getMimeType(data),
dataSource = DataSource.DISK,
)
}
}
fun ComponentRegistry.Builder.installCronetHttpUriFetcher() = add { data: Uri, options, loader ->
if (data.scheme != "http" && data.scheme != "https") return@add null
CronetHttpUriFetcher(data.toString(), options, loader)
}
private fun getMimeType(url: String) = MimeTypeMap.getSingleton().getMimeTypeFromUrl(url)
private fun MimeTypeMap.getMimeTypeFromUrl(url: String?): String? {
if (url.isNullOrBlank()) return null
val extension = url
.substringBeforeLast('#') // Strip the fragment.
.substringBeforeLast('?') // Strip the query.
.substringAfterLast('/') // Get the last path segment.
.substringAfterLast('.', missingDelimiterValue = "") // Get the file extension.
return getMimeTypeFromExtension(extension)
}
@@ -0,0 +1,83 @@
package com.hippo.ehviewer.coil
import android.graphics.Bitmap
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Extras
import coil3.Image
import coil3.asCoilImage
import coil3.getExtra
import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
import com.hippo.ehviewer.image.detectBorder
import com.hippo.ehviewer.util.isAtLeastO
private const val CROP_THRESHOLD = 0.75f
private const val WEBTOON_THRESHOLD = 3
private val maybeCropBorderKey = Extras.Key(default = false)
fun ImageRequest.Builder.maybeCropBorder(enable: Boolean) = apply {
extras[maybeCropBorderKey] = enable
}
val ImageRequest.maybeCropBorder: Boolean
get() = getExtra(maybeCropBorderKey)
class BitmapImageWithRect(
val rect: IntRect,
val image: Image,
) : Image by image
object CropBorderInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult {
val req = chain.request
if (req.maybeCropBorder) {
val hw = req.allowHardware && isAtLeastO
val result = if (hw) {
val nonHw = req.newBuilder().allowHardware(false).build()
chain.withRequest(nonHw).proceed()
} else {
chain.proceed()
}
if (result is SuccessResult) {
val image = result.image
if (image is BitmapImage) {
val bitmap = image.bitmap
val rect = if (image.height / image.width < WEBTOON_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
IntRect(array[0], array[1], array[2], array[3]).takeIf {
it.width > minWidth && it.height > minHeight
}
} else {
null
}
val maybeHwImage = if (hw) {
// Upload to Graphical Buffer to accelerate render
// May fail if the image is too long
bitmap.copy(Bitmap.Config.HARDWARE, false)?.run {
bitmap.recycle()
asCoilImage()
} ?: image
} else {
image
}
val new = if (rect != null) {
BitmapImageWithRect(rect, maybeHwImage)
} else {
maybeHwImage
}
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
}
}
return result
}
return chain.proceed()
}
}
@@ -1,6 +1,6 @@
package com.hippo.ehviewer.coil
import coil.disk.DiskCache
import coil3.disk.DiskCache
inline fun DiskCache.edit(key: String, crossinline block: DiskCache.Editor.() -> Unit): Boolean {
val editor = openEditor(key) ?: return false
@@ -0,0 +1,55 @@
package com.hippo.ehviewer.coil
import coil3.intercept.Interceptor
import coil3.request.ErrorResult
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.EhApplication.Companion.imageCache
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.spider.DownloadInfoMagics.decodeMagicRequestOrUrl
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.asUniFile
import eu.kanade.tachiyomi.util.lang.withIOContext
object DownloadThumbInterceptor : Interceptor {
// TODO: Remove this after a few releases
private const val LEGACY_THUMB_FILE = ".thumb"
private const val THUMB_FILE = "thumb.jpg"
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val magicOrUrl = chain.request.data as? String
if (magicOrUrl != null) {
val (url, location) = decodeMagicRequestOrUrl(magicOrUrl)
if (location != null) {
val thumb = withIOContext {
val legacyThumb = downloadLocation / location / LEGACY_THUMB_FILE
if (legacyThumb.isFile) {
legacyThumb.apply { renameTo(THUMB_FILE) }
} else {
downloadLocation / location / THUMB_FILE
}
}
if (withIOContext { thumb.isFile }) {
val new = chain.request.newBuilder().data(thumb.uri.toString()).build()
when (val result = chain.withRequest(new).proceed()) {
is SuccessResult -> return result
is ErrorResult -> withIOContext { thumb.delete() }
}
}
val new = chain.request.newBuilder().data(url).build()
return chain.withRequest(new).proceed().also {
if (it is SuccessResult) {
withIOContext {
if (thumb.ensureFile()) {
val key = requireNotNull(chain.request.memoryCacheKey)
imageCache.read(key) {
data.asUniFile() sendTo thumb
}
}
}
}
}
}
}
return chain.proceed()
}
}
@@ -2,22 +2,36 @@ package com.hippo.ehviewer.coil
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import coil.decode.DecodeResult
import coil.decode.Decoder
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.size.Size
import coil3.asCoilImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.size.Size
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.CHROME_ACCEPT
import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
import com.hippo.ehviewer.client.thumbUrl
import com.hippo.ehviewer.client.url
import com.hippo.ehviewer.spider.DownloadInfoMagics.encodeMagicRequestOrUrl
import io.ktor.http.HttpHeaders
private val header = NetworkHeaders.Builder().apply {
add(HttpHeaders.UserAgent, Settings.userAgent)
add(HttpHeaders.Accept, CHROME_ACCEPT)
add(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
}.build()
fun ImageRequest.Builder.ehUrl(info: GalleryInfo) = apply {
val key = info.thumbKey!!
data(info.thumbUrl)
data(encodeMagicRequestOrUrl(info))
memoryCacheKey(key)
diskCacheKey(key)
httpHeaders(header)
}
fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
@@ -25,9 +39,11 @@ fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
memoryCacheKey(preview.imageKey)
diskCacheKey(preview.imageKey)
if (preview is NormalGalleryPreview) size(Size.ORIGINAL)
httpHeaders(header)
}
private val stubResult = DecodeResult(ColorDrawable(Color.BLACK), false)
private val stubImage = ColorDrawable(Color.BLACK).asCoilImage(true)
private val stubResult = DecodeResult(stubImage, false)
private val stubFactory = Decoder { stubResult }
fun ImageRequest.Builder.justDownload() = apply {
@@ -0,0 +1,24 @@
package com.hippo.ehviewer.coil
import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain
import coil3.request.ErrorResult
import coil3.request.ImageResult
import coil3.request.allowHardware
/**
* Allocating hardware bitmaps may fail on extremely long images.
*
* Retry with [allowHardware] disabled.
*/
object HardwareBitmapInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult {
val result = chain.proceed()
return if (chain.request.allowHardware && result is ErrorResult && result.throwable is OutOfMemoryError) {
val req = chain.request.newBuilder().allowHardware(false).build()
chain.withRequest(req).proceed()
} else {
result
}
}
}
@@ -1,7 +1,7 @@
package com.hippo.ehviewer.coil
import coil.intercept.Interceptor
import coil.request.ImageResult
import coil3.intercept.Interceptor
import coil3.request.ImageResult
import com.hippo.ehviewer.Settings
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -41,7 +41,7 @@ object MergeInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val req = chain.request
val key = req.memoryCacheKey?.key?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed(req) }
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed() }
suspendCancellableCoroutine { continuation ->
synchronized(pendingContinuationMapLock) {
@@ -62,7 +62,7 @@ object MergeInterceptor : Interceptor {
}
try {
return withContext(req.interceptorDispatcher) { chain.proceed(req) }.apply {
return withContext(req.interceptorDispatcher) { chain.proceed() }.apply {
// Wake all pending continuations shared with the same memory key since we have written it to memory cache
notifyScope.launch {
synchronized(pendingContinuationMapLock) {
@@ -1,49 +1,11 @@
package com.hippo.ehviewer.cronet
import com.hippo.ehviewer.EhApplication
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
val pool = DirectByteBufferPool(32)
val cronetHttpClientExecutor = EhApplication.baseOkHttpClient.dispatcher.executorService
@Suppress("NewApi")
suspend inline fun CronetRequest.awaitBodyFully(crossinline callback: (ByteBuffer) -> Unit) {
return pool.useInstance { buffer ->
suspendCancellableCoroutine { cont ->
consumer = {
check(it === buffer)
buffer.flip()
callback(it)
buffer.clear()
request.read(buffer)
}
onError = { readerCont.resumeWithException(it) }
readerCont = cont
request.read(buffer)
}
}
}
@Suppress("NewApi")
suspend inline fun CronetRequest.awaitBodyFully(buffer: ByteBuffer) {
return suspendCancellableCoroutine { cont ->
consumer = {
request.read(buffer)
check(buffer.hasRemaining())
}
onError = { readerCont.resumeWithException(it) }
readerCont = cont
request.read(buffer)
}
}
suspend inline fun CronetRequest.copyToChannel(chan: FileChannel, crossinline listener: ((Int) -> Unit) = {}) = awaitBodyFully {
val bytes = chan.write(it)
listener(bytes)
}
// Limit thread to 1 since we are async & non-blocking
val cronetDispatcher = Dispatchers.Default.limitedParallelism(1)
val cronetHttpClientExecutor = cronetDispatcher.asExecutor()
@@ -24,16 +24,18 @@ 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 eu.kanade.tachiyomi.util.system.logcat
import java.time.Duration
import java.time.Instant
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.days
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import splitties.init.appCtx
private val signedIn
get() = EhCookieStore.hasSignedIn()
private const val CHANNEL_ID = "DailyCheckNotification"
class DailyCheckWork(context: Context, workerParams: WorkerParameters) :
@@ -46,28 +48,20 @@ class DailyCheckWork(context: Context, workerParams: WorkerParameters) :
}
}
val schedHour
get() = Settings.requestNewsTimerHour.takeUnless { it == -1 } ?: 0
val schedMinute
get() = Settings.requestNewsTimerMinute.takeUnless { it == -1 } ?: 0
private val whenToWork
get() = LocalDateTime.now()
.run { withHour(schedHour) }
.run { withMinute(schedMinute) }
private val initialDelay
get() = Duration.between(LocalDateTime.now(), whenToWork)
.run { if (isNegative) plusDays(1) else this }
private fun getDailyCheckWorkRequest(): PeriodicWorkRequest {
val now = Clock.System.now()
val timeZone = TimeZone.currentSystemDefault()
val whenToWork = LocalDateTime(
now.toLocalDateTime(timeZone).date,
LocalTime.fromSecondOfDay(Settings.requestNewsTime),
).toInstant(timeZone)
val initialDelay = (whenToWork - now).run { if (isNegative()) plus(1.days) else this }
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
return PeriodicWorkRequestBuilder<DailyCheckWork>(Duration.ofDays(1))
.setConstraints(constraints)
.setInitialDelay(initialDelay.seconds, TimeUnit.SECONDS)
.setInitialDelay(initialDelay.inWholeSeconds, TimeUnit.SECONDS)
.build()
}
@@ -77,7 +71,7 @@ fun updateDailyCheckWork(context: Context) {
if (Settings.requestNews) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
getDailyCheckWorkRequest(),
)
} else {
@@ -86,17 +80,17 @@ fun updateDailyCheckWork(context: Context) {
}
val today
get() = Instant.now().truncatedTo(ChronoUnit.DAYS).epochSecond
get() = (Clock.System.now().epochSeconds / 86400).toInt()
suspend fun checkDawn() = runCatching {
if (signedIn && Settings.lastDawnDay != today) {
if (EhCookieStore.hasSignedIn() && Settings.lastDawnDays != today) {
EhEngine.getNews(true)?.let {
Settings.lastDawnDay = today
Settings.lastDawnDays = today
showEventNotification(it)
}
}
}.onFailure {
it.printStackTrace()
logcat(WORK_NAME, it)
}
@SuppressLint("MissingPermission")
@@ -125,6 +119,6 @@ fun showEventNotification(html: String) {
runCatching {
notificationManager.notify(1, msg.build())
}.onFailure {
it.printStackTrace()
logcat(WORK_NAME, it)
}
}
@@ -1,30 +0,0 @@
package com.hippo.ehviewer.dao
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "OK_HTTP_3_COOKIE")
data class Cookie(
@ColumnInfo(name = "NAME")
var name: String,
@ColumnInfo(name = "VALUE")
var value: String,
@ColumnInfo(name = "EXPIRES_AT")
var expiresAt: Long,
@ColumnInfo(name = "DOMAIN")
var domain: String,
@ColumnInfo(name = "PATH")
var path: String,
@ColumnInfo(name = "SECURE")
var secure: Boolean,
@ColumnInfo(name = "HTTP_ONLY")
var httpOnly: Boolean,
@ColumnInfo(name = "PERSISTENT")
var persistent: Boolean,
@ColumnInfo(name = "HOST_ONLY")
var hostOnly: Boolean,
@PrimaryKey
@ColumnInfo(name = "_id")
var id: Long?,
)
@@ -1,10 +0,0 @@
package com.hippo.ehviewer.dao
import androidx.room.Dao
import androidx.room.Query
@Dao
interface CookiesDao {
@Query("SELECT * FROM OK_HTTP_3_COOKIE")
suspend fun list(): List<Cookie>
}
@@ -2,171 +2,37 @@ package com.hippo.ehviewer.dao
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteColumn
import androidx.room.DeleteTable
import androidx.room.RenameColumn
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
@DeleteTable(tableName = "BOOKMARKS")
class DropBookMarkDao : AutoMigrationSpec
@RenameColumn(tableName = "HISTORY", fromColumnName = "MODE", toColumnName = "FAVORITE_SLOT")
class HistoryMigration : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
db.execSQL("UPDATE HISTORY SET FAVORITE_SLOT = FAVORITE_SLOT - 2")
}
}
class AddPositionColumn : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
val needMigrationTables = arrayOf(
"QUICK_SEARCH",
"DOWNLOAD_LABELS",
"DOWNLOADS",
)
needMigrationTables.forEach { table ->
// TODO: Rewrite this with row_number() when min sdk is 30 (SQLite 3.28.0)
db.execSQL("UPDATE $table SET POSITION = (SELECT COUNT(*) FROM $table T WHERE T.TIME < $table.TIME)")
}
}
}
@DeleteColumn(tableName = "QUICK_SEARCH", columnName = "TIME")
@DeleteColumn(tableName = "DOWNLOAD_LABELS", columnName = "TIME")
class DropTimeColumn : AutoMigrationSpec
class AddGalleryTable : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
val needMigrationTables = arrayOf(
"HISTORY",
"DOWNLOADS",
"LOCAL_FAVORITES",
)
needMigrationTables.forEachIndexed { index, table ->
db.execSQL(
"INSERT OR IGNORE INTO GALLERIES " +
"SELECT GID, TOKEN, TITLE, TITLE_JPN, THUMB, CATEGORY, POSTED, UPLOADER, RATING, SIMPLE_LANGUAGE, " +
"${if (index == 0) "FAVORITE_SLOT" else NOT_FAVORITED} FROM $table",
)
}
}
}
@DeleteColumn(tableName = "HISTORY", columnName = "TOKEN")
@DeleteColumn(tableName = "HISTORY", columnName = "TITLE")
@DeleteColumn(tableName = "HISTORY", columnName = "TITLE_JPN")
@DeleteColumn(tableName = "HISTORY", columnName = "THUMB")
@DeleteColumn(tableName = "HISTORY", columnName = "CATEGORY")
@DeleteColumn(tableName = "HISTORY", columnName = "POSTED")
@DeleteColumn(tableName = "HISTORY", columnName = "UPLOADER")
@DeleteColumn(tableName = "HISTORY", columnName = "RATING")
@DeleteColumn(tableName = "HISTORY", columnName = "SIMPLE_LANGUAGE")
@DeleteColumn(tableName = "HISTORY", columnName = "FAVORITE_SLOT")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "TOKEN")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "TITLE")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "TITLE_JPN")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "THUMB")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "CATEGORY")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "POSTED")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "UPLOADER")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "RATING")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "SIMPLE_LANGUAGE")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "TOKEN")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "TITLE")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "TITLE_JPN")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "THUMB")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "CATEGORY")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "POSTED")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "UPLOADER")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "RATING")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "SIMPLE_LANGUAGE")
class GalleryTableMigration : AutoMigrationSpec
class ThumbKeyMigration : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
val needMigrationTables = arrayOf(
"DOWNLOADS",
"HISTORY",
"LOCAL_FAVORITES",
)
val prefixToRemove = arrayOf(
"https://ehgt.org/",
"https://s.exhentai.org/t/",
"https://exhentai.org/t/",
)
needMigrationTables.forEach { table ->
prefixToRemove.forEach { prefix ->
db.execSQL("UPDATE $table SET thumb = SUBSTR(thumb ,LENGTH('$prefix') + 1) WHERE thumb LIKE '$prefix%'")
}
}
}
}
@Database(
entities = [BaseGalleryInfo::class, DownloadLabel::class, DownloadEntity::class, DownloadDirname::class, Filter::class, HistoryInfo::class, LocalFavoriteInfo::class, QuickSearch::class],
version = 16,
entities = [
BaseGalleryInfo::class, DownloadLabel::class, DownloadEntity::class, DownloadDirname::class,
Filter::class, HistoryInfo::class, LocalFavoriteInfo::class, ProgressInfo::class, QuickSearch::class,
GalleryEntityFts::class,
],
version = 22,
exportSchema = true,
autoMigrations = [
AutoMigration(
from = 4,
to = 5,
spec = ThumbKeyMigration::class,
),
AutoMigration(
from = 5,
to = 6,
),
AutoMigration(
from = 6,
to = 7,
spec = DropBookMarkDao::class,
),
AutoMigration(
from = 7,
to = 8,
),
AutoMigration(
from = 8,
to = 9,
),
AutoMigration(
from = 9,
to = 10,
spec = HistoryMigration::class,
),
AutoMigration(
from = 10,
to = 11,
spec = AddPositionColumn::class,
),
AutoMigration(
from = 11,
to = 12,
spec = DropTimeColumn::class,
),
AutoMigration(
from = 12,
to = 13,
spec = AddGalleryTable::class,
),
AutoMigration(
from = 13,
to = 14,
spec = GalleryTableMigration::class,
),
AutoMigration(
from = 14,
to = 15,
),
AutoMigration(
from = 15,
to = 16,
),
AutoMigration(from = 4, to = 5, spec = Schema4to5::class),
AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7, spec = Schema6to7::class),
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10, spec = Schema9to10::class),
AutoMigration(from = 10, to = 11, spec = Schema10to11::class),
AutoMigration(from = 11, to = 12, spec = Schema11to12::class),
AutoMigration(from = 12, to = 13, spec = Schema12to13::class),
AutoMigration(from = 13, to = 14, spec = Schema13to14::class),
AutoMigration(from = 14, to = 15),
AutoMigration(from = 15, to = 16),
AutoMigration(from = 16, to = 17),
AutoMigration(from = 17, to = 18),
AutoMigration(from = 18, to = 19, spec = Schema18to19::class),
AutoMigration(from = 19, to = 20),
AutoMigration(from = 20, to = 21, spec = Schema20to21::class),
AutoMigration(from = 21, to = 22, spec = Schema21to22::class),
],
)
@TypeConverters(FilterModeConverter::class)
@@ -178,32 +44,15 @@ abstract class EhDatabase : RoomDatabase() {
abstract fun filterDao(): FilterDao
abstract fun historyDao(): HistoryDao
abstract fun localFavoritesDao(): LocalFavoritesDao
abstract fun progressDao(): ProgressDao
abstract fun quickSearchDao(): QuickSearchDao
}
// 1 -> 2 some nullability changes
@Database(
entities = [Cookie::class],
version = 2,
autoMigrations = [
AutoMigration(
from = 1,
to = 2,
),
],
)
abstract class CookiesDatabase : RoomDatabase() {
abstract fun cookiesDao(): CookiesDao
}
@Database(
entities = [Search::class],
version = 2,
autoMigrations = [
AutoMigration(
from = 1,
to = 2,
),
AutoMigration(from = 1, to = 2),
],
)
abstract class SearchDatabase : RoomDatabase() {
@@ -0,0 +1,117 @@
package com.hippo.ehviewer.dao
import androidx.room.DeleteColumn
import androidx.room.DeleteTable
import androidx.room.RenameColumn
import androidx.room.RenameTable
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
class Schema4to5 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
val needMigrationTables = arrayOf(
"DOWNLOADS",
"HISTORY",
"LOCAL_FAVORITES",
)
val prefixToRemove = arrayOf(
"https://ehgt.org/",
"https://s.exhentai.org/t/",
"https://exhentai.org/t/",
)
needMigrationTables.forEach { table ->
prefixToRemove.forEach { prefix ->
db.execSQL("UPDATE $table SET thumb = SUBSTR(thumb ,LENGTH('$prefix') + 1) WHERE thumb LIKE '$prefix%'")
}
}
}
}
@DeleteTable(tableName = "BOOKMARKS")
class Schema6to7 : AutoMigrationSpec
@RenameColumn(tableName = "HISTORY", fromColumnName = "MODE", toColumnName = "FAVORITE_SLOT")
class Schema9to10 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
db.execSQL("UPDATE HISTORY SET FAVORITE_SLOT = FAVORITE_SLOT - 2")
}
}
class Schema10to11 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
val needMigrationTables = arrayOf(
"QUICK_SEARCH",
"DOWNLOAD_LABELS",
"DOWNLOADS",
)
needMigrationTables.forEach { table ->
// TODO: Rewrite this with row_number() when min sdk is 30 (SQLite 3.28.0)
db.execSQL("UPDATE $table SET POSITION = (SELECT COUNT(*) FROM $table T WHERE T.TIME < $table.TIME)")
}
}
}
@DeleteColumn(tableName = "QUICK_SEARCH", columnName = "TIME")
@DeleteColumn(tableName = "DOWNLOAD_LABELS", columnName = "TIME")
class Schema11to12 : AutoMigrationSpec
class Schema12to13 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
val needMigrationTables = arrayOf(
"HISTORY",
"DOWNLOADS",
"LOCAL_FAVORITES",
)
needMigrationTables.forEachIndexed { index, table ->
db.execSQL(
"INSERT OR IGNORE INTO GALLERIES " +
"SELECT GID, TOKEN, TITLE, TITLE_JPN, THUMB, CATEGORY, POSTED, UPLOADER, RATING, SIMPLE_LANGUAGE, " +
"${if (index == 0) "FAVORITE_SLOT" else NOT_FAVORITED} FROM $table",
)
}
}
}
@DeleteColumn(tableName = "HISTORY", columnName = "TOKEN")
@DeleteColumn(tableName = "HISTORY", columnName = "TITLE")
@DeleteColumn(tableName = "HISTORY", columnName = "TITLE_JPN")
@DeleteColumn(tableName = "HISTORY", columnName = "THUMB")
@DeleteColumn(tableName = "HISTORY", columnName = "CATEGORY")
@DeleteColumn(tableName = "HISTORY", columnName = "POSTED")
@DeleteColumn(tableName = "HISTORY", columnName = "UPLOADER")
@DeleteColumn(tableName = "HISTORY", columnName = "RATING")
@DeleteColumn(tableName = "HISTORY", columnName = "SIMPLE_LANGUAGE")
@DeleteColumn(tableName = "HISTORY", columnName = "FAVORITE_SLOT")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "TOKEN")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "TITLE")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "TITLE_JPN")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "THUMB")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "CATEGORY")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "POSTED")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "UPLOADER")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "RATING")
@DeleteColumn(tableName = "LOCAL_FAVORITES", columnName = "SIMPLE_LANGUAGE")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "TOKEN")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "TITLE")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "TITLE_JPN")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "THUMB")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "CATEGORY")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "POSTED")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "UPLOADER")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "RATING")
@DeleteColumn(tableName = "DOWNLOADS", columnName = "SIMPLE_LANGUAGE")
class Schema13to14 : AutoMigrationSpec
@RenameColumn(tableName = "DOWNLOADS", fromColumnName = "POSITION", toColumnName = "TIME")
class Schema18to19 : AutoMigrationSpec
class Schema20to21 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
db.execSQL("INSERT INTO GalleryFts(GalleryFts) VALUES('rebuild')")
}
}
// Virtual tables can't be altered, so we use rename to recreate it
@RenameTable(fromTableName = "GalleryFts", toTableName = "GALLERIES_FTS")
class Schema21to22 : AutoMigrationSpec
@@ -10,7 +10,7 @@ import androidx.room.Relation
import com.hippo.ehviewer.client.data.AbstractDownloadInfo
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo
import java.time.Instant
import kotlinx.datetime.Clock
@Entity(
tableName = "DOWNLOADS",
@@ -37,14 +37,11 @@ data class DownloadEntity(
override var legacy: Int = 0,
@ColumnInfo(name = "TIME")
override var time: Long = Instant.now().toEpochMilli(),
override var time: Long = Clock.System.now().toEpochMilliseconds(),
@ColumnInfo(name = "LABEL", index = true)
override var label: String? = null,
@ColumnInfo(name = "POSITION")
override var position: Int = 0,
@Ignore
override var speed: Long = 0,
@@ -8,10 +8,10 @@ import androidx.room.Update
@Dao
interface DownloadLabelDao {
@Query("SELECT * FROM DOWNLOAD_LABELS ORDER BY POSITION ASC")
@Query("SELECT * FROM DOWNLOAD_LABELS ORDER BY POSITION")
suspend fun list(): List<DownloadLabel>
@Query("SELECT * FROM DOWNLOAD_LABELS ORDER BY POSITION ASC LIMIT :limit OFFSET :offset")
@Query("SELECT * FROM DOWNLOAD_LABELS ORDER BY POSITION LIMIT :limit OFFSET :offset")
suspend fun list(offset: Int, limit: Int): List<DownloadLabel>
@Query("UPDATE DOWNLOAD_LABELS SET POSITION = POSITION - 1 WHERE POSITION > :position")
@@ -3,23 +3,26 @@ package com.hippo.ehviewer.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface DownloadsDao {
@Query("SELECT * FROM DOWNLOADS ORDER BY POSITION DESC")
@Suppress("ktlint:standard:annotation")
@Query("SELECT LABEL, COUNT(*) AS COUNT FROM DOWNLOADS LEFT JOIN DOWNLOAD_LABELS USING(LABEL) GROUP BY LABEL")
fun count(): Flow<Map<@MapColumn("LABEL") String?, @MapColumn("COUNT") Int>>
@Query("SELECT * FROM DOWNLOADS ORDER BY TIME")
suspend fun list(): List<DownloadEntity>
@Transaction
@Query("SELECT * FROM DOWNLOADS LEFT JOIN DOWNLOAD_DIRNAME USING(GID) ORDER BY POSITION DESC")
@Query("SELECT * FROM DOWNLOADS LEFT JOIN DOWNLOAD_DIRNAME USING(GID) ORDER BY TIME DESC")
suspend fun joinList(): List<DownloadInfo>
@Query("UPDATE DOWNLOADS SET POSITION = POSITION - :offset WHERE POSITION > :position")
suspend fun fill(position: Int, offset: Int = 1)
@Update
suspend fun update(downloadInfo: List<DownloadEntity>)
@@ -23,6 +23,9 @@ interface GalleryDao {
@Update
suspend fun update(galleryInfo: BaseGalleryInfo)
@Update
suspend fun update(galleryInfoList: List<BaseGalleryInfo>)
@Upsert
suspend fun upsert(galleryInfo: BaseGalleryInfo)
@@ -0,0 +1,19 @@
package com.hippo.ehviewer.dao
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Fts4
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@Fts4(contentEntity = BaseGalleryInfo::class)
@Entity(tableName = "GALLERIES_FTS")
class GalleryEntityFts(
@ColumnInfo(name = "TITLE")
val title: String,
@ColumnInfo(name = "TITLE_JPN")
val titleJpn: String,
@ColumnInfo(name = "SIMPLE_TAGS")
val simpleTags: String?,
)
@@ -5,19 +5,23 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Upsert
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@Dao
interface HistoryDao {
@Query("SELECT * FROM HISTORY ORDER BY TIME DESC")
@Query("SELECT * FROM HISTORY ORDER BY TIME")
suspend fun list(): List<HistoryInfo>
@RewriteQueriesToDropUnusedColumns
@Query("SELECT * FROM HISTORY JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
@Query("SELECT GALLERIES.* FROM HISTORY JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
fun joinListLazy(): PagingSource<Int, BaseGalleryInfo>
@Query(
"""SELECT GALLERIES.* FROM HISTORY JOIN GALLERIES USING(GID)
JOIN GALLERIES_FTS ON GALLERIES.rowid = docid WHERE GALLERIES_FTS MATCH :title ORDER BY TIME DESC""",
)
fun joinListLazy(title: String): PagingSource<Int, BaseGalleryInfo>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnore(historyInfoList: List<HistoryInfo>)
@@ -3,7 +3,7 @@ package com.hippo.ehviewer.dao
import androidx.room.Entity
import androidx.room.ForeignKey
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import java.time.Instant
import kotlinx.datetime.Clock
@Entity(tableName = "HISTORY", foreignKeys = [ForeignKey(BaseGalleryInfo::class, ["GID"], ["GID"])])
class HistoryInfo(gid: Long, time: Long = Instant.now().toEpochMilli()) : TimeInfo(gid, time)
class HistoryInfo(gid: Long, time: Long = Clock.System.now().toEpochMilliseconds()) : TimeInfo(gid, time)
@@ -3,7 +3,7 @@ package com.hippo.ehviewer.dao
import androidx.room.Entity
import androidx.room.ForeignKey
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import java.time.Instant
import kotlinx.datetime.Clock
@Entity(tableName = "LOCAL_FAVORITES", foreignKeys = [ForeignKey(BaseGalleryInfo::class, ["GID"], ["GID"])])
class LocalFavoriteInfo(gid: Long = 0, time: Long = Instant.now().toEpochMilli()) : TimeInfo(gid, time)
class LocalFavoriteInfo(gid: Long = 0, time: Long = Clock.System.now().toEpochMilliseconds()) : TimeInfo(gid, time)
@@ -5,7 +5,6 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Upsert
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import kotlinx.coroutines.flow.Flow
@@ -15,17 +14,21 @@ interface LocalFavoritesDao {
@Query("SELECT COUNT(*) FROM LOCAL_FAVORITES")
fun count(): Flow<Int>
@Query("SELECT * FROM LOCAL_FAVORITES ORDER BY TIME DESC")
@Query("SELECT * FROM LOCAL_FAVORITES ORDER BY TIME")
suspend fun list(): List<LocalFavoriteInfo>
@RewriteQueriesToDropUnusedColumns
@Query("SELECT * FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
@Query("SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
fun joinListLazy(): PagingSource<Int, BaseGalleryInfo>
@RewriteQueriesToDropUnusedColumns
@Query("SELECT * FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) WHERE TITLE LIKE :title ORDER BY TIME DESC")
@Query(
"""SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID)
JOIN GALLERIES_FTS ON GALLERIES.rowid = docid WHERE GALLERIES_FTS MATCH :title ORDER BY TIME DESC""",
)
fun joinListLazy(title: String): PagingSource<Int, BaseGalleryInfo>
@Query("SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY RANDOM() LIMIT 1")
suspend fun random(): BaseGalleryInfo
@Query("SELECT EXISTS(SELECT * FROM LOCAL_FAVORITES WHERE GID = :gid)")
suspend fun contains(gid: Long): Boolean
@@ -0,0 +1,29 @@
package com.hippo.ehviewer.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface ProgressDao {
@Query("SELECT IFNULL(PAGE, 0) FROM PROGRESS WHERE GID = :gid")
suspend fun getPage(gid: Long): Int
@Query("SELECT IFNULL(PAGE, 0) FROM PROGRESS WHERE GID = :gid")
fun getPageFlow(gid: Long): Flow<Int>
@Query("SELECT * FROM PROGRESS")
suspend fun list(): List<ProgressInfo>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnore(progress: List<ProgressInfo>)
@Upsert
suspend fun upsert(progress: ProgressInfo)
@Query("DELETE FROM PROGRESS")
suspend fun deleteAll()
}
@@ -0,0 +1,22 @@
package com.hippo.ehviewer.dao
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@Entity(
tableName = "PROGRESS",
foreignKeys = [
ForeignKey(BaseGalleryInfo::class, ["GID"], ["GID"], onDelete = ForeignKey.CASCADE),
],
)
class ProgressInfo(
@PrimaryKey
@ColumnInfo(name = "GID")
val gid: Long,
@ColumnInfo(name = "PAGE")
val page: Int,
)
@@ -8,10 +8,10 @@ import androidx.room.Update
@Dao
interface QuickSearchDao {
@Query("SELECT * FROM QUICK_SEARCH ORDER BY POSITION ASC")
@Query("SELECT * FROM QUICK_SEARCH ORDER BY POSITION")
suspend fun list(): List<QuickSearch>
@Query("SELECT * FROM QUICK_SEARCH ORDER BY POSITION ASC LIMIT :limit OFFSET :offset")
@Query("SELECT * FROM QUICK_SEARCH ORDER BY POSITION LIMIT :limit OFFSET :offset")
suspend fun list(offset: Int, limit: Int): List<QuickSearch>
@Query("UPDATE QUICK_SEARCH SET POSITION = POSITION - 1 WHERE POSITION > :position")
File diff suppressed because it is too large. Load diff
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff. Show more