Compare commits

...
360 Commits
Author SHA1 Message Date
FooIbar 6d1d737f9f Prepare for release 1.11.6-RC1
Releases / build (push) Has been cancelled
2024-06-02 23:38:02 +08:00
FooIbar bc75156232 Simplify ReadableTime.getFilenamableTime 2024-06-02 22:51:58 +08:00
FooIbar 2101ea7a36 Update mime types based on Android's mime.types 2024-06-02 22:22:42 +08:00
FooIbar 26921c8ae1 Fix data export/import on Android V
The mime type mapping for sqlite files was added in
https://r.android.com/2988915.
2024-06-02 22:22:42 +08:00
FooIbar be1eecf3b5 Use official coroutines API instead of manual cast 2024-06-02 19:49:21 +08:00
FooIbar 2d4f154c9b Prevent async failure from cancelling parent scope 2024-06-02 19:49:21 +08:00
renovate[bot] d82cbe51ba Update dependency io.github.fornewid:material-motion-compose-core to v1.2.1 2024-06-02 03:13:01 +00:00
FooIbar d13ffa93ba Workaround for navigation bars padding on API < 28 2024-06-01 22:34:30 +08:00
FooIbar 2cce55953f Fix quick search and download label reordering
This is a regression introduced by 6277d7d.
2024-06-01 22:15:44 +08:00
FooIbar 07d100fcc0 Show navigation drawer only on top-level screens (#1266)
* Show navigation drawer only on top-level screens

* Remove redundant `rememberUpdatedState`
2024-06-01 18:59:16 +08:00
FooIbar cc04371181 Fix navigation drawer overlapping side sheet 2024-06-01 13:08:49 +08:00
github-actions[bot] ee50c88765 Update Chrome version for default User-Agent 2024-06-01 09:02:43 +08:00
renovate[bot] efc5b55ea7 Update dependency gradle to v8.8 2024-05-31 23:42:12 +00:00
FooIbar 94af66156a Replace invalid/consecutive whitespaces in query 2024-06-01 01:52:03 +08:00
FooIbar 4861359f9e Make secondary FAB offset density-aware 2024-06-01 01:51:40 +08:00
renovate[bot] 5af167dab4 Update compose.destinations to v2.1.0-beta09 2024-05-31 14:19:32 +00:00
renovate[bot] db1f0bc6d7 Update dependency com.android.application to v8.6.0-alpha04 2024-05-30 23:33:55 +00:00
renovate[bot] b94a843ec5 Update aboutlibraries to v11.2.1 2024-05-30 08:27:09 +00:00
FooIbar b27d214f24 Use Non-H as default category for guest mode
Don't modify the URL builder.
2024-05-30 10:57:26 +08:00
FooIbar a81eb9d17b Fix default value 2024-05-30 10:57:26 +08:00
FooIbar ec9b85f30f Refactor to not get sign-in state from cookies 2024-05-30 10:57:26 +08:00
FooIbar 8ac2f33949 Update liblzma to v5.6.2 2024-05-30 09:32:18 +08:00
FooIbar d84198ab28 Move Compose out of snapshot 2024-05-30 09:22:20 +08:00
renovate[bot] 34d339d9f1 Update compose.destinations to v2.1.0-beta08 2024-05-30 00:26:53 +00:00
renovate[bot] bcfefc7c76 Update dependency dev.chrisbanes.compose:compose-bom to v2024.05.00-alpha03 2024-05-29 21:16:26 +00:00
renovate[bot] 62539dc48e Update dependency androidx.appcompat:appcompat to v1.7.0 2024-05-29 21:15:22 +00:00
renovate[bot] 453ab11395 Update androidx.lifecycle to v2.8.1 2024-05-29 18:36:58 +00:00
renovate[bot] b2246b980a Update androidx.room to v2.7.0-alpha03 2024-05-29 18:36:07 +00:00
FooIbar 822a461acd Use All as default recent download label 2024-05-28 22:46:18 +08:00
renovate[bot] 25e5c0ab8e Lock file maintenance 2024-05-27 01:53:10 +00:00
FooIbar 2a55db95f4 Workaround for ReaderPageSheet clipped on API < 28 2024-05-26 17:17:50 +08:00
FooIbar a144866182 Use commit time instead for reproducible builds 2024-05-25 16:56:17 +08:00
FooIbar be7a04a4cb Move kotlin options to kotlin block 2024-05-24 22:18:18 +08:00
renovate[bot] fd1b861dab Update dependency com.android.application to v8.6.0-alpha03 2024-05-24 01:09:17 +00:00
FooIbar 957ebd35aa Update Compose snapshot
https://issuetracker.google.com/333584635
2024-05-23 16:31:14 +08:00
FooIbar 29d8b11771 Fix quota exceeded exceptions being swallowed 2024-05-22 18:37:28 +08:00
renovate[bot] c87059e9d7 Update Kotlin & KSP 2024-05-22 09:51:29 +08:00
renovate[bot] 4babe2eec4 Update aboutlibraries to v11.2.0 2024-05-21 23:45:18 +00:00
FooIbar 48fdaaca0c Fix race condition of ComicInfo.xml modification 2024-05-21 19:54:41 +08:00
FooIbar f0ee88b4d6 [no ci] Fix renovate config 2024-05-21 17:32:37 +08:00
FooIbar 9210780b6b Fix renovate false positive 2024-05-21 17:26:30 +08:00
FooIbar b479e28543 Initial support for importing downloaded archives 2024-05-21 14:20:57 +08:00
FooIbar f93b98fc5e Fix user input not being used when rating gallery 2024-05-20 11:51:29 +08:00
renovate[bot] 4721d1011b Lock file maintenance 2024-05-20 00:43:28 +00:00
FooIbar 4da4443e67 [no ci] Remove unused res 2024-05-20 00:25:13 +08:00
FooIbar e5343f4360 Don't package 32-bit x86 libs into universal apk 2024-05-20 00:24:05 +08:00
FooIbar 86323220dc Prepare for next iteration 2024-05-19 23:06:35 +08:00
FooIbar ada8f3af75 Prepare for release 1.11.5
Releases / build (push) Has been cancelled
2024-05-19 23:06:00 +08:00
FooIbar fb940d3982 Group downloads by artist if no labels are defined 2024-05-19 23:02:29 +08:00
FooIbar 8e8729c25f Introduce AppConfig.isSnapshot 2024-05-19 23:01:25 +08:00
FooIbar ffdd063ca1 Revert "Update Compose snapshot"
This reverts commit e6bdaf1172.

Reason for revert: https://issuetracker.google.com/341320533
2024-05-19 20:44:51 +08:00
FooIbar dfd5c6c70e Remove unnecessary synchronization
We use thread-safe methods now.
2024-05-19 20:32:08 +08:00
FooIbar 3068aab681 Use CI update channel by default for snapshots (#1223)
* Use CI update channel by default for snapshots

And use build time as initial update time.

* Fix build time
2024-05-19 20:25:59 +08:00
renovate[bot] e402587f8f Update plugin composeCompilerReportGenerator to v1.3.1 2024-05-19 10:52:18 +00:00
FooIbar 00b1be879d Fix app language out of sync with system settings (#1221)
* Fix app language out of sync with system settings

* Manually update current language

Recomposition may not happen when current language is the same as system
 default.
2024-05-19 10:43:24 +00:00
ɴᴇᴋᴏ e63bd49a4c Update TW Translation (#1081)
* Add new TW Translation

* Fix translation

* change translate

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* change to '圖庫'

* 更新 strings.xml

* space

* fix

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml
2024-05-19 15:41:10 +08:00
renovate[bot] 8131170eb1 Update compose.destinations to v2.1.0-beta07 2024-05-18 20:13:22 +00:00
FooIbar dc1abd3806 Migrate to Compose Material3 Adaptive (#1219)
* Migrate to Compose Material3 Adaptive

* Fix R8
2024-05-18 20:56:03 +08:00
FooIbar 468fbd50da Fix tap zone overlay not following inversion 2024-05-18 18:53:26 +08:00
FooIbar f2011f9b6f Fix reader tap zone inversion not working 2024-05-18 18:53:26 +08:00
FooIbar 1e53a9b389 Make sure favorites don't have the same timestamp 2024-05-18 10:46:46 +08:00
renovate[bot] dc2504b67f Update dependency com.android.application to v8.6.0-alpha02 2024-05-17 23:06:40 +00:00
FooIbar 2ae3e3b64c Reland "Use autogenerated localeConfig"
Reland as https://issuetracker.google.com/294109810 has been fixed.
2024-05-17 23:50:48 +08:00
FooIbar e6bdaf1172 Update Compose snapshot
https://issuetracker.google.com/339188523
2024-05-17 14:34:53 +08:00
FooIbar 36a66d9199 Revert "Workaround for renovate false positive"
This reverts commit fb6fbb8df9.
2024-05-17 14:23:21 +08:00
FooIbar fb6fbb8df9 Workaround for renovate false positive 2024-05-17 12:17:12 +08:00
renovate[bot] ef8449dc02 Update compose.destinations to v2.1.0-beta06 2024-05-16 16:06:28 +00:00
FooIbar b59f8e9ade Use Non-H category as homepage in guest mode 2024-05-16 01:46:14 +08:00
FooIbar a49506f4a0 Update Compose snapshot to fix item clipping
https://issuetracker.google.com/340201510
2024-05-15 21:26:07 +08:00
FooIbar 4ce3d25eed Remove unused code 2024-05-15 17:28:42 +08:00
FooIbar 81b166bee1 Unpin dependencies 2024-05-15 10:58:53 +08:00
renovate[bot] 37a587859a Update dependency dev.chrisbanes.compose:compose-bom to v2024.05.00-alpha02 2024-05-15 10:47:04 +08:00
renovate[bot] b7544630bb Update dependency com.android.application to v8.6.0-alpha01 2024-05-15 02:43:51 +00:00
renovate[bot] 05c7a0fa39 Update dependency androidx.paging:paging-compose to v3.3.0 2024-05-15 02:43:16 +00:00
renovate[bot] eee7655f80 Update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.6.0 2024-05-15 02:42:54 +00:00
renovate[bot] 9390813c48 Update dependency androidx.lifecycle:lifecycle-runtime-compose to v2.8.0 2024-05-15 02:42:37 +00:00
renovate[bot] d032fbdd67 Update dependency androidx.annotation:annotation to v1.8.0 2024-05-14 23:15:41 +00:00
renovate[bot] adcd73a590 Update dependency androidx.appcompat:appcompat to v1.7.0-rc01 2024-05-14 23:15:00 +00:00
renovate[bot] 87b405bf1f Update androidx.room to v2.7.0-alpha02 2024-05-14 17:41:39 +00:00
renovate[bot] d8306e2c80 Update androidx.lifecycle to v2.8.0 2024-05-14 17:41:20 +00:00
FooIbar c03fe9b51e Address java.util.regex.Pattern usage 2024-05-15 01:31:54 +08:00
FooIbar 9099014d28 Remove reference of "Sign in via cookies" 2024-05-15 01:31:54 +08:00
FooIbar d7b3872486 Support autofill in SignInScreen 2024-05-15 01:31:54 +08:00
FooIbar 4ae2af0bef Adjust SignInScreen layout 2024-05-15 01:31:54 +08:00
FooIbar c9bcd46b77 Revert "Drop basic login as captcha is mandatory now"
This reverts commit e07aab4434.
2024-05-15 01:31:54 +08:00
FooIbar e531653634 Workaround for archive list crash when scrolling
It has fixed size and we don't actually expect its items to be reused.
2024-05-14 13:59:24 +08:00
renovate[bot]andFooIbar a29e4c72b1 Update Kotlin & KSP (#1171)
* Update Kotlin & KSP

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-05-14 12:14:54 +08:00
FooIbar 9e1f7ca051 Fix crash in favorites caused by duplicate items 2024-05-13 22:42:39 +08:00
renovate[bot] fbbc347411 Update dependency sh.calvin.reorderable:reorderable to v2.1.1 2024-05-13 04:11:24 +00:00
renovate[bot] ea364536a4 Lock file maintenance 2024-05-13 01:32:13 +00:00
github-actions[bot] 575fc9e94c Update Chrome version for default User-Agent 2024-05-12 14:53:50 +08:00
FooIbar 703c63125a [no ci] Don't push to main branch directly 2024-05-12 14:42:53 +08:00
FooIbar b205b5e120 Keep default User-Agent updated 2024-05-12 11:44:28 +08:00
FooIbar e27f36d2c8 Don't swallow exceptions when importing database 2024-05-12 11:39:29 +08:00
FooIbar d582eddc03 Update Corrosion to v0.5 2024-05-12 02:08:29 +08:00
FooIbar 3b83d8744c Revert "Update dependency com.android.application to v8.5.0-beta01"
This reverts commit 7c39b95025.
2024-05-11 13:18:20 +08:00
renovate[bot] 68a5217b1c Lock file maintenance 2024-05-11 04:39:15 +00:00
FooIbar c01082b822 Revert pointless bumps and check in Cargo.lock 2024-05-11 12:26:25 +08:00
FooIbar bb95c86320 Update Compose snapshot 2024-05-11 11:56:41 +08:00
FooIbar d398926aeb Simplify rating widget implementation 2024-05-11 03:23:40 +08:00
FooIbar 7b23efb0ce Fix rememberMutableStateInDataStore persistence 2024-05-11 03:22:22 +08:00
FooIbar 7818ff197c Fix R8 2024-05-11 03:21:37 +08:00
FooIbar 95bfc4f8c5 Introduce AnchoredHorizontalDraggable to prevent unintentional drags 2024-05-11 03:21:37 +08:00
FooIbar c750016034 Revert "Revert "Drop experimental touch slop factor setting" (#1162)"
This reverts commit a4619547f1.
2024-05-11 03:21:37 +08:00
FooIbar 6298e501cd Save minimum rating search option in preference (#1163) 2024-05-11 03:18:01 +08:00
renovate[bot] 7c39b95025 Update dependency com.android.application to v8.5.0-beta01 2024-05-10 18:46:00 +00:00
renovate[bot] 02c1e630d1 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.8.1 2024-05-10 11:36:12 +00:00
GG Bond 8f3b0cc536 Refine naming 2024-05-10 19:27:45 +08:00
GG Bond d0c5ef8e8a Refine naming 2024-05-10 19:27:45 +08:00
GG Bond d628024637 Refine 2024-05-10 19:27:45 +08:00
GG Bond fe1249630b Convert FavDialogTheta to use rememberInDataStore 2024-05-10 19:27:45 +08:00
GG Bond cdbe3cf728 Drop unused avatar pref 2024-05-10 19:27:45 +08:00
GG Bond f5e36bc0cf Initialize dataStateFlow on app start 2024-05-10 19:27:45 +08:00
GG Bond 4f9bfe13cb Move SearchCategory to use rememberInDataStore 2024-05-10 19:27:45 +08:00
GG Bond 959da21b9d Introduce rememberInDataStore 2024-05-10 19:27:45 +08:00
renovate[bot]andGG Bond fae2b1f416 Cleanup rememberInVM family (#1166)
* Update DataStore to 1.1.1 for future `rememberInDataStore` impl

* Cleanup `rememberInVM` family

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-05-10 06:15:18 +00:00
GG Bond a054633d0a Update DataStore to 1.1.1 for future rememberInDataStore impl 2024-05-10 13:49:36 +08:00
FooIbar a4619547f1 Revert "Drop experimental touch slop factor setting" (#1162)
* Revert "Drop experimental touch slop factor setting"

This reverts commit d5d8a828ef.

Reason for revert: After some more testing I think sadly we still need it.

* Read touch slop factor as state
2024-05-10 13:08:32 +08:00
FooIbar 5d53febb15 Show gallery pages in thumbnail mode (#1164) 2024-05-10 04:41:42 +00:00
FooIbar 3da1587924 Use HapticFeedback in ChapterNavigator as well 2024-05-10 12:22:09 +08:00
FooIbar 971d0307fe Inline DragHandle as it's redundant now 2024-05-10 12:22:09 +08:00
FooIbar 705d3afb78 Refactor haptic feedback on reordering 2024-05-10 12:22:09 +08:00
renovate[bot] bc34b4b9c1 Update dependency sh.calvin.reorderable:reorderable to v2.1.0 2024-05-09 09:59:57 +00:00
FooIbar a2ac01fcdc Revert "Update dependency sh.calvin.reorderable:reorderable to v2.0.4"
This reverts commit 4cea8d0090.

Reason for revert: The item being dragged becomes invisible.
2024-05-09 14:44:10 +08:00
FooIbar 645ebddd4f Fix crash when saving unloaded images 2024-05-09 14:23:11 +08:00
FooIbar d5d8a828ef Drop experimental touch slop factor setting 2024-05-09 12:18:59 +08:00
FooIbar 96d4193d2d Fix system bars insets in fullscreen on API < 30 2024-05-09 12:14:59 +08:00
FooIbar 18e4afdff9 Update Compose snapshot 2024-05-09 12:13:28 +08:00
FooIbar 19a702ffc4 Migrate to main artifact of AndroidX WorkManager
`work-runtime-ktx` is empty since 2.9.0
2024-05-08 17:55:39 +08:00
renovate[bot] cfd44c256f Update compose.destinations to v2.1.0-beta05 2024-05-07 23:13:30 +00:00
renovate[bot] 4cea8d0090 Update dependency sh.calvin.reorderable:reorderable to v2.0.4 2024-05-07 19:49:57 +00:00
FooIbar 30e166b29c Fix DownloadService state not cleared 2024-05-07 16:34:23 +08:00
FooIbar c50f3d8c5b Fix tap control area shifting after zooming out 2024-05-07 15:37:38 +08:00
FooIbar d9249eeedb Fix tap control area shifting in multi-window mode 2024-05-07 15:37:38 +08:00
FooIbar 51699cafe6 Constrain minimum width of reader bottom bar menus 2024-05-07 15:37:17 +08:00
FooIbar ce97976bdd Refactor bottom bar icon dropdown menus in reader
Avoid hard-coded width and offset.
2024-05-07 15:37:17 +08:00
renovate[bot]andFooIbar 7b8a12e3eb Update dependency sh.calvin.reorderable:reorderable to v2.0.3 (#1143)
* Update dependency sh.calvin.reorderable:reorderable to v2.0.3

* Address deprecation

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-05-07 03:49:33 +00:00
FooIbar 7276c37c54 Don't constrain menu's width 2024-05-07 01:17:55 +08:00
FooIbar 4ec7de611c Introduce DropdownFilterChip for SearchFilter 2024-05-07 01:17:55 +08:00
FooIbar 0295c40b02 Implement fullscreen mode in new reader 2024-05-06 19:53:32 +08:00
FooIbar 035fa44523 Correctly handle display cutout (#1139) 2024-05-06 14:34:22 +08:00
renovate[bot] 60c6618db1 Update compose.destinations to v2.1.0-beta04 2024-05-05 23:53:49 +00:00
FooIbar f35643b2b6 Correctly handle window insets in reader
Now it's actual edge-to-edge.
2024-05-06 07:46:15 +08:00
FooIbar b25661c436 Revert "Clarify message for no current torrents"
This reverts commit 49d4ae07

Reason for revert: We list outdated torrents as well after torrent
dialog overhaul.
2024-05-05 18:05:40 +08:00
FooIbar cea06f0c7e Disable abortOnError for lint in debug build
[no ci]
2024-05-05 17:16:16 +08:00
FooIbar 7de6a821d9 Drop 32-bit x86 support 2024-05-05 11:17:26 +08:00
FooIbar 13be4e4eb1 Remove Navigation Compose dependency
It's transitively provided by Compose Destinations, we should not
directly depend on it in case they are incompatible.
2024-05-05 07:58:46 +08:00
renovate[bot] 691e7c3ba7 Update compose.destinations to v2.1.0-beta03 2024-05-04 18:08:26 +00:00
FooIbar 31aea1cdbf Fix duplicate key in search suggestions 2024-05-05 01:59:49 +08:00
FooIbar 97d5642427 Run download item restoration in parallel 2024-05-04 18:52:05 +08:00
FooIbar c68272c1a4 Fix download item restoration not bailing on error 2024-05-04 17:59:34 +08:00
renovate[bot] a69dc994b3 Update dependency sh.calvin.reorderable:reorderable to v2.0.1 2024-05-04 09:27:19 +00:00
renovate[bot] d79a71c764 Update dependency dev.chrisbanes.compose:compose-bom to v2024.05.00-alpha01 2024-05-04 08:31:58 +00:00
renovate[bot] 35183de477 Update dependency androidx.navigation:navigation-compose to v2.8.0-alpha08 2024-05-04 16:31:41 +08:00
FooIbar 21b9fe3cb2 Update to snapshot Compose and remove workaround (#1050)
* Update to snapshot Compose

* Fix dep

* Revert "More workaround"

This reverts commit 9013778f58.

* Revert "Workaround for lazy layout lagging (#1042)"

This reverts commit b83e0fd0

* Revert "Workaround for comment list lagging at the bottom"

This reverts commit 45be957df5.

* Fix minOffset for blur radius calculation

Also use `onSizeChanged` instead of `onGloballyPositioned` as
measurement happens before placement.
2024-05-04 16:22:01 +08:00
FooIbar 5762b0bfc3 Disallow screen capture on identity cookies dialog 2024-05-04 11:30:34 +08:00
renovate[bot] 3473e91747 Update dependency com.android.application to v8.5.0-alpha08 2024-05-03 23:07:00 +00:00
renovate[bot]andFooIbar 6277d7d41d Update dependency sh.calvin.reorderable:reorderable to v2 (#1120)
* Update dependency sh.calvin.reorderable:reorderable to v2

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-05-03 09:36:56 +00:00
renovate[bot] f22da2b771 Revert androidx navigation update 2024-05-03 17:07:55 +08:00
FooIbar 3466bbd64a Disable grouping for AndroidX dependencies 2024-05-03 16:33:25 +08:00
FooIbar 49fc87621c Revert "Limit workflow concurrency"
This reverts commit e81092d42c.
2024-05-03 16:33:25 +08:00
GG Bond 7e558f4478 DialogState.bgWork doesn't show dialog if work doesn't suspend 2024-05-03 15:29:13 +08:00
FooIbar 307624ca9a Copy invalid filename handling logic from AOSP 2024-05-03 15:29:00 +08:00
FooIbar 40e66b919a Move ArchiveList and TorrentList to separate files (#1113)
* Move ArchiveList and TorrentList to separate files

GalleryDetailScreen is too long

* Refactor with deferred archive and torrent results
2024-05-03 07:27:57 +08:00
FooIbar cf2e0aadd5 [no ci] Fix renovate config 2024-05-02 13:56:25 +08:00
FooIbar 1b523f6a78 [no ci] Tweak renovate config 2024-05-02 13:48:50 +08:00
renovate[bot] 6a9f6bb645 Drop obsolete flag
Room generate kotlin by default now.
2024-05-02 07:17:16 +08:00
renovate[bot] 45602b05c6 Update aboutlibraries to v11.1.4 2024-05-01 19:09:42 +00:00
renovate[bot] 8dc842e125 Update AndroidX & Jetpack Compose 2024-05-01 19:09:18 +00:00
renovate[bot] 9dfc3a49fe Update Rust crate tl to 0.7.8 2024-05-01 17:10:59 +00:00
renovate[bot] 97d4a61140 Update Rust crate serde_cbor to 0.11.2 2024-05-01 17:10:41 +00:00
renovate[bot] 8b596e2274 Update Rust crate sha1 to 0.10.6 2024-05-01 17:10:15 +00:00
renovate[bot] 123b86830b Update Rust crate serde to 1.0.200 2024-05-01 16:40:54 +00:00
renovate[bot] e11f5615a1 Update Rust crate regex-lite to 0.1.5 2024-05-01 14:16:12 +00:00
FooIbar 31fb2ed2b1 Address renovate warning
[no ci]
2024-05-01 22:15:59 +08:00
renovate[bot] 9c12d11c85 Update Rust crate serde to 1.0.199 2024-05-01 14:14:59 +00:00
renovate[bot] aea0e885c3 Update Rust crate log to 0.4.21 2024-05-01 12:43:51 +00:00
renovate[bot] f34ff71145 Update Rust crate jni to 0.21.1 2024-05-01 12:43:29 +00:00
FooIbar 820c16e86b Tweak image limits dialog 2024-05-01 19:39:24 +08:00
renovate[bot] a335348b1a Update Rust crate android_logger to 0.13.3 2024-05-01 10:41:05 +00:00
renovate[bot] 7f134e1995 Update Rust crate anyhow to 1.0.82 2024-05-01 10:40:43 +00:00
FooIbar 9e13c1c3db Revert "Update Kotlin & KSP (#1084)"
This reverts commit 9ff74e789c.

Reason for revert: crash
2024-05-01 01:29:14 +08:00
FooIbar 61161cef0f Torrent dialog overhaul 2024-05-01 01:06:57 +08:00
renovate[bot]andFooIbar 9ff74e789c Update Kotlin & KSP (#1084)
* Update Kotlin & KSP

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-04-30 12:48:06 +00:00
FooIbar 45183df640 Avoid packing/unpacking and use pre-allocated list 2024-04-30 17:13:59 +08:00
FooIbar 22f11994f5 Remove obsolete workaround 2024-04-30 12:46:46 +08:00
renovate[bot] bc7aa832b7 Update dependency com.android.application to v8.5.0-alpha07 2024-04-29 21:37:45 +00:00
FooIbar b32f78ea9f Archive dialog overhaul 2024-04-29 23:49:40 +08:00
FooIbar 78002dac3a Use proper placeholder instead of 0 in image limit 2024-04-29 16:29:46 +08:00
renovate[bot]andGG Bond aca70354e7 Refactor FabBuilder interface & refine suspend dialog naming (#1082)
* `FabLayoutValue` is `Stable`

* FabBuilder accepts a autoClose

* Refactor control flow to pattern matching expression and avoid exception

* Close fab on DatePicker selected

* Refine naming

* Introduce `SnapshotStateMap<K, V>.takeAndClear()`

* PutAll action use `autoClose = false`

* Handle exceptions

* Cleanup

* Handle potential e

* Refine to use autoClose

* FabLayout should use parent Screen CoroutineScope

* Use implicit context

* Introduce `runSwallowingWithUI`

* log exception as well

* Refine naming

* Cleanup with `runSwallowingWithUI`

* Refine naming again

* Fixup

* Remove redundant logcat

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-04-29 04:20:37 +00:00
FooIbar d7a3471b9b Fix side drawer anchor updates during initial open 2024-04-28 23:43:55 +08:00
FooIbar e81092d42c Limit workflow concurrency
[no ci]
2024-04-28 18:51:11 +08:00
renovate[bot] d06df3c819 Update dependency me.saket.telephoto:zoomable to v0.11.2 2024-04-28 10:17:26 +00:00
FooIbar 050bfea8f2 Parse favorite categories from user config page 2024-04-28 18:09:17 +08:00
renovate[bot] 7b84c74abf Update dependency me.saket.telephoto:zoomable to v0.11.1 2024-04-28 06:46:40 +00:00
FooIbar 009c49cd89 Don't fail silently if can't install update 2024-04-28 14:37:31 +08:00
renovate[bot] ca023f73f4 Update dependency me.saket.telephoto:zoomable to v0.11.0 2024-04-28 05:53:51 +00:00
renovate[bot]andFooIbar 456dfd2aac Update compose.destinations to v2.1.0-beta02 (#1073)
* Update compose.destinations to v2.1.0-beta02

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-04-28 05:45:03 +00:00
renovate[bot] 0c0291127c Revert "Drop Xjvm-default=all"
This reverts commit df1efa7604.
2024-04-27 16:59:41 +08:00
FooIbar fe0c74de23 MimeTypeMap is case-sensitive prior to Android O
https://android-review.googlesource.com/251120
2024-04-27 16:54:06 +08:00
renovate[bot] df1efa7604 Drop Xjvm-default=all
We are not a library intended for Javaers nor do we have any Java code to interop so this is meaningless and safely to drop
2024-04-27 03:41:54 +08:00
FooIbar 73122fc517 Workaround for SearchBar reopen in non-touch mode 2024-04-27 03:34:27 +08:00
renovate[bot] b506ba277c Drop -Xlambdas=indy
https://kotlinlang.org/docs/whatsnew-eap.html#generate-lambda-functions-using-invokedynamic
2024-04-26 23:00:41 +08:00
dasoopsandFooIbar ea3aed8fae Reload metadata (#1058)
* Reload metadata

* Remove pages check and Refactor

* Check metadata stable

* Run in parallel

* Refactor postedTime

* Remove concurrency limit

* Update translation

* Update translate

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

* `parseDate` return timestamps

* Remove unused import

* Show Snackbar after the update

* Revert EndLine

* use localContext

* Extend the range of `runSuspendCatching`

---------

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-04-26 21:05:52 +08:00
FooIbar 481bb8e7d4 Update libarchive to v3.7.4 2024-04-26 19:01:03 +08:00
FooIbar 0ed8c40dbf Improve a11y by limiting grid item's aspect ratio 2024-04-26 18:08:32 +08:00
FooIbar d267d7c3b0 [no ci] Change links in README to use https 2024-04-26 17:48:27 +08:00
FooIbar a04206f600 Refine UniFile and evict lookup cache on delete
Also add comments on functions that rely on implementation details.
2024-04-26 15:46:47 +08:00
FooIbar 8d64a6f1b2 Update ndk and support 16 KB page sizes (#1057)
* Update ndk and support 16 KB page sizes

* Fix CI

* Update graphics-path and pass linker flag to rustc
2024-04-25 14:37:01 +00:00
dasoops b3f614e150 Sort artists by gallery count (#1059)
* Sort artists by number of galleries

* Sorted in DB query
2024-04-25 11:23:11 +00:00
renovate[bot]andGG Bond 988fae56fd Add fake implicit syntax for migration (#1056)
* Add fake `implicit` syntax

* Refine

* Refine

* Refine

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-04-25 00:17:33 +08:00
FooIbar fa439fc62d Switch back to GitHub xz repo
It should be faster.
2024-04-24 22:05:35 +08:00
GG Bond c58b2d6156 Cleanup realPadding calculation 2024-04-24 19:40:30 +08:00
GG Bond 62c56b7918 Add missing lazy item key 2024-04-24 19:40:30 +08:00
GG Bond 73882ab55d Cleanup DownloadsScreen to use implicit context 2024-04-24 19:17:34 +08:00
490ea2b521 Add Option to Select Downloads Grouping Mode (#998)
* Add Option to Select Downloads Grouping Mode

* hide Drag and Edit Button when Mode is Artist

* Revert "hide Drag and Edit Button when Mode is Artist"

some changes are not filtered when pushed

This reverts commit 319812e156f61c32250d908ed5ed22720ea0c6b7.

* hide Drag and Edit Button when Mode is Artist

* declared is typo

* use ComicInfo.penciller

* remove BaseGalleryInfo.artist

* Correct invalidate key

* reImport kotlin.math.roundToInt, it was incorrectly deleted

* use movableContentOf

* optimize imports

* use remember not rememberInVM

* Get DownloadInfo.artists from Database or `ComicInfo.xml`

* refactor

* unknown artist group

* redundant parameter

* reformat code

* SearchBar title and hint

* Translation

* Fix when initially loaded, the count of DownloadLabel is zero.

* lazy invalid

* use remember not rememberInVM

* refactor

* Fix LazyColumn not updated

* refactor

* Hide some Button in Artist Mode

* Revert

* Fix update labelList not updated UI

* Revert reFormat

* use trailing lambda

* Revert rememberUpdatedState

* Translate

* use buildMap

* Move ARTIST to DOWNLOAD_ARTIST

* Revert multi-line for chained Modifier

* Generate DownloadInfo.artistInfoList after writeComicInfo and restoreDownload

* Fixed crash after download gallery

* Use a composite key for DOWNLOAD_ARTISTS

* Make artists non-null

* Refactor

* Only update artist info when writing comic info

* Separate list for pending artist info

* Remove unused

* Put download artist when import database

* Use a composite key of GID and ARTIST

* Refactor

* Cache labelList

* named lambda parameters

* Refactor to avoid `mutex`

* follow up on `Make artists non-null`

* ArtistList order by ARTIST

* Lift EhTagDatabase check

* Translate artist in download title

* Refactor

* Refactor readMetadataFromLocal

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

* Do not update the DB when the `artists` is empty

* Remove unused

---------

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
Co-authored-by: GG Bond <whatever@tagkong.com>
2024-04-24 11:01:53 +00:00
GG Bond 7116871652 Avoid implement a CancellableContinuation
Let compiler do CPS transform
2024-04-24 18:43:21 +08:00
FooIbar cca374c774 Refactor UniFile.fromUri for tree uri
This reverts b39e244 ("Fix `UniFile.fromUri` for children of tree uri")
and 3978abe ("Fix UniFile.fromUri() for 2 path segments tree uri").
2024-04-24 17:48:41 +08:00
FooIbar 3e0dbf92d0 Fix search bar offset not being reset 2024-04-22 12:49:03 +08:00
renovate[bot] 2747ae674c Update dependency sh.calvin.reorderable:reorderable to v1.5.2 2024-04-21 20:20:31 +00:00
FooIbar e85ec5aaba Code cleanup 2024-04-21 23:42:55 +08:00
FooIbar d4894e066b Prepare for next iteration 2024-04-21 22:47:04 +08:00
FooIbar 71a4e84d7c Prepare for release 1.11.4
Releases / build (push) Has been cancelled
2024-04-21 22:45:54 +08:00
FooIbar d6c36146ba Fix losing reading progress on reading mode change 2024-04-21 22:35:22 +08:00
FooIbar 9013778f58 More workaround 2024-04-21 18:05:25 +08:00
renovate[bot] 1af03dd145 Update dependency sh.calvin.reorderable:reorderable to v1.5.1 2024-04-20 16:46:53 +00:00
FooIbar b83e0fd092 Workaround for lazy layout lagging (#1042) 2024-04-20 16:37:41 +00:00
GG Bond 1f8d786d05 Simplify to use Modifier.offset 2024-04-20 18:31:22 +08:00
GG Bond 5d87a4ada0 Never have a clipped shadow 2024-04-20 18:31:22 +08:00
GG Bond 4d4fb1e398 Eliminate unnecessary Column 2024-04-20 17:10:33 +08:00
GG Bond 05eea54f06 Fix primaryFab appear anim 2024-04-20 17:10:33 +08:00
GG Bond a00d9ef765 Mitigate shadow being clipped 2024-04-20 17:10:33 +08:00
GG Bond aa6ddac9c5 Avoid unnecessary zIndex 2024-04-20 17:10:33 +08:00
GG Bond 9a6f17c745 Image Search Card doesn't need to be scrollable now 2024-04-20 17:10:33 +08:00
GG Bond 980977aaf3 Ensure Image Search Card's shadow not being clipped 2024-04-20 17:10:33 +08:00
GG Bond 3ed0afaf91 Restore an alpha usage can be safely applied 2024-04-20 17:10:33 +08:00
GG Bond 250adb4b33 Fix shadow being clipped 2024-04-20 17:10:33 +08:00
GG Bond 0baf09cb53 Codestyle fix for DownloadCard 2024-04-20 17:10:33 +08:00
FooIbar d04a914b9b Tweak gallery card alignment and ensure min height 2024-04-19 19:08:21 +08:00
renovate[bot] 59775cb27c Update dependency com.android.application to v8.5.0-alpha06 2024-04-18 22:06:24 +00:00
GG Bond fb76cd7b1b Move compose out of snapshot 2024-04-18 11:25:52 +08:00
renovate[bot] 52fb5306b6 Update dependency dev.chrisbanes.compose:compose-bom to v2024.04.00-alpha02 2024-04-18 01:03:11 +00:00
renovate[bot] 74f7d95718 Update AndroidX & Jetpack Compose 2024-04-17 18:12:27 +00:00
renovate[bot] bcc1b78c7c Update compose snapshot 2024-04-18 00:00:56 +08:00
FooIbar 3978abea87 Fix UniFile.fromUri() for 2 path segments tree uri 2024-04-17 17:38:51 +08:00
FooIbar 556b726349 Update to follow the latest material slider specs
Tick size is 4 dp now.
2024-04-16 10:49:10 +08:00
FooIbar e0f16f31e6 Automatically backup data before updating 2024-04-16 00:08:30 +08:00
FooIbar b39e24467c Fix UniFile.fromUri for children of tree uri 2024-04-15 23:50:54 +08:00
FooIbar 65613f07ba Optimize DownloadCard interaction in select mode 2024-04-15 21:53:37 +08:00
FooIbar 666a7e4d77 Allow disable list item animations on API 29+ (#1024)
* Allow disable list item animations on API 29+

* Update list item animations setting description
2024-04-15 14:54:13 +08:00
renovate[bot] cf08bfdd9b Update dependency me.saket.telephoto:zoomable to v0.10.0 2024-04-14 21:58:40 +00:00
renovate[bot]andFooIbar 1f47d56c55 Update Kotlin, KSP and Compose Compiler (#1009)
* Update Kotlin, KSP and Compose Compiler

* Migrate to new compose compiler gradle plugin

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-04-14 14:38:22 +00:00
FooIbar 1a7dde848f Don't treat warnings as errors on debug build 2024-04-14 17:56:55 +08:00
GG Bond c8207308a8 Update predictive back animations to use interpolation curve of (0.1, 0.1, 0, 1) 2024-04-14 17:38:03 +08:00
renovate[bot] f23020cc6f Update dependency sh.calvin.reorderable:reorderable to v1.5.0 2024-04-12 18:46:12 +00:00
renovate[bot] 555e487f1e Update dependency com.android.application to v8.5.0-alpha05 2024-04-11 18:20:21 +00:00
renovate[bot] 107bb0b15d Update aboutlibraries to v11.1.3 2024-04-09 19:14:59 +00:00
FooIbar 87a2de393e Tweak select item dialog style 2024-04-10 03:04:49 +08:00
renovate[bot] a7e4412454 Update dependency com.mikepenz.aboutlibraries.plugin to v11.1.2 2024-04-09 14:42:54 +00:00
FooIbar 7dce4cfa8e Update Compose snapshot 2024-04-09 22:12:49 +08:00
FooIbar 2b30aca47c Update R8 and remove workaround 2024-04-09 21:44:57 +08:00
FooIbar 0073d2862b Fixed crash on deleting a label after renaming it 2024-04-09 18:39:25 +08:00
FooIbar b182c97185 Fix titleless select item dialog top padding 2024-04-09 10:46:44 +08:00
renovate[bot] a32119bada Update dependency com.android.application to v8.5.0-alpha04 2024-04-08 19:37:35 +00:00
FooIbar a928cfd066 Update libarchive to v3.7.3 2024-04-09 00:08:01 +08:00
FooIbar 8c7a371336 Highlight selected item in select item dialog 2024-04-07 23:32:47 +08:00
GG Bond 6f61eb4946 Update outdated comments 2024-04-07 21:21:37 +08:00
GG Bond 1096f05344 Move navigation out of snapshot 2024-04-07 21:21:37 +08:00
GG Bond 26f96d3e38 Move to telephoto 0.10.0-SNAPSHOT 2024-04-07 21:21:37 +08:00
FooIbar 4a02189a59 Rearrange code to move plurals resources together 2024-04-07 17:13:35 +08:00
FooIbar 7a5bab41f6 Add option to disable list item animations (#997)
It may crash on some devices.
2024-04-07 13:57:27 +08:00
FooIbar 84c32fb28d Prepare for next iteration 2024-04-06 17:10:28 +08:00
FooIbar a3cb6d2782 Prepare for release 1.11.3
Releases / build (push) Has been cancelled
2024-04-06 17:09:38 +08:00
FooIbar 45be957df5 Workaround for comment list lagging at the bottom 2024-04-06 15:25:26 +08:00
FooIbar 8e45014e3f Align load comments button and progress indicator 2024-04-06 15:24:54 +08:00
FooIbar d84e948352 Add padding for progress indicator in DetailScreen 2024-04-06 15:24:54 +08:00
FooIbar 68bd835038 Fallback to transferTo if sendfile failed 2024-04-06 10:52:25 +08:00
FooIbar c297bebea1 Fix resource leak when decode job is canceled 2024-04-05 20:29:04 +08:00
FooIbar 6c0a823216 Fix typo (#991)
* Fix typo

* Fix lint
2024-04-05 06:57:34 +00:00
renovate[bot]andFooIbar 067a701bdc Update KSP to v2.0.0-Beta5-1.0.20 (#989)
* Update Kotlin, KSP and Compose Compiler

* Fix compose compiler version

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-04-05 05:03:58 +00:00
GG Bond c3fb008c92 Introduce HttpStatement.executeSafely 2024-04-05 13:03:39 +08:00
GG Bond bc4321da2e Cleanup CronetEngine 2024-04-05 13:03:39 +08:00
FooIbar 8dec040803 Fix FileNotFoundException on saving download thumb 2024-04-05 00:04:02 +08:00
FooIbar 29e4e77c44 Open file descriptor in "wt" mode for overwriting 2024-04-05 00:04:02 +08:00
FooIbar 11429ef7fe Log image loading errors in release build 2024-04-05 00:04:02 +08:00
FooIbar f5fcd96a1e Fix DownloadService stopped during onFinish call 2024-04-04 19:17:35 +08:00
renovate[bot]andFooIbar 19120038c5 Update AndroidX & Jetpack Compose (#985)
* Update AndroidX & Jetpack Compose

* Fix nullability

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-04-04 06:19:08 +00:00
FooIbar 9021f28ead Allow update to unstable AndroidX libraries
Most of them are already transitively updated.
2024-04-04 13:52:50 +08:00
renovate[bot] 62934f72e3 Update dependency dev.chrisbanes.compose:compose-bom to v2024.04.00-alpha01 2024-04-03 23:36:15 +00:00
renovate[bot] 6c534e1049 Update dependency androidx.paging:paging-compose to v3.3.0-beta01 2024-04-03 19:00:38 +00:00
renovate[bot] f80cd90792 Update dependency androidx.core:core-splashscreen to v1.1.0-rc01 2024-04-03 19:00:00 +00:00
renovate[bot] 3999c0c114 Update dependency io.arrow-kt:arrow-stack to v1.2.4 2024-04-03 13:21:01 +00:00
FooIbar 59399453c4 Fix IP ban error message and parse error logging (#979) 2024-04-03 08:56:40 +00:00
renovate[bot] 694bb19e2a Update dependency sh.calvin.reorderable:reorderable to v1.4.0 2024-04-03 06:25:14 +00:00
FooIbar 9928ecd4d9 Refactor database export 2024-04-03 11:54:06 +08:00
renovate[bot] 2c5293af6e Update dependency com.android.application to v8.5.0-alpha03 2024-04-02 13:39:05 +00:00
FooIbar 9569ca774d Fix unable to refresh images due to resource leak 2024-04-02 00:38:38 +08:00
FooIbar 3a04460926 Abort if no image entries found in archive 2024-04-01 18:18:19 +08:00
FooIbar acb4fb6166 Don't run decode job if fetch job failed
Fix fetch error being suppressed by decode job result.
2024-03-31 20:39:27 +08:00
renovate[bot]andGG Bond 7d137bb016 Fast move to compose destinations v2.1 (#965)
* Fast move to compose destinations v2.1

* Remove default parameters

* Cleanup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-03-31 16:07:06 +08:00
FooIbar 8c2ed37b72 Update build script due to xz Github repo shutdown 2024-03-31 01:31:22 +08:00
FooIbar 749b230250 Fix download label not being draggable 2024-03-30 10:19:08 +08:00
renovate[bot]andGG Bond 23531cb6e0 Upstream SearchBar (#960)
* Upstream SearchBar

* Fix SearchBar label

* Fix SearchBar padding

* Use BoxWithConstraints

* Refine

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-03-29 10:01:42 +00:00
FooIbar 0ac11dbe0b Fix SpiderQueen not respecting FIFO order
And optimize preloading strategy.
2024-03-29 17:50:53 +08:00
FooIbar 9b0e017542 Use Compose UI implementation of parseAsHtml 2024-03-28 15:48:28 +08:00
FooIbar f2f5fcacf9 Use upstream navigation drawer 2024-03-28 15:48:16 +08:00
FooIbar 52ded265f3 Update Compose to snapshot and remove workaround 2024-03-28 08:41:01 +08:00
FooIbar e77f905c4a Revert "Downgrade to stable AGP"
This reverts commit 2d14d34d53.

Reason for revert: Workaround added
2024-03-28 08:39:15 +08:00
FooIbar 2d14d34d53 Downgrade to stable AGP 2024-03-27 13:26:34 +08:00
renovate[bot] 78facda18a Crossinline (#952) 2024-03-26 22:32:42 +08:00
renovate[bot]andrenovate[bot] 1fe1b43015 Update Kotlin, KSP and Compose Compiler (#950)
* Update Kotlin, KSP and Compose Compiler

* Update libs.versions.toml

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <99822064+revonateB0T@users.noreply.github.com>
2024-03-26 14:19:00 +00:00
FooIbar 63164dc70a Scale wide images (banners) to fit target width
And don't scale tall images (webtoons).
2024-03-26 15:50:45 +08:00
renovate[bot] 0d39092a7f Update dependency me.saket.telephoto:zoomable to v0.9.0 2024-03-26 00:34:29 +00:00
renovate[bot] c50db8a327 Update plugin composeCompilerReportGenerator to v1.2.0 2024-03-25 11:58:03 +00:00
renovate[bot] 7e107cc5e2 Update dependency com.mikepenz.aboutlibraries.plugin to v11.1.1 2024-03-23 15:15:45 +00:00
renovate[bot] 260949e3a7 Update dependency gradle to v8.7 2024-03-22 20:28:33 +00:00
renovate[bot] d9ec108721 Update dependency com.android.application to v8.5.0-alpha02 2024-03-22 20:25:51 +00:00
FooIbar 1e97407936 Fix recycled item's height being 0 in webtoon mode
Refactor reader page holder and miscellaneous cleanups
2024-03-22 22:10:00 +08:00
FooIbar 73afe05a18 Manually migrate from schema 17 to 18 for non-null 2024-03-22 20:59:45 +08:00
FooIbar 23475de932 Fix layout inspector not working 2024-03-21 20:31:10 +08:00
FooIbar 6fb2172c2c Drop obsolete snapshot 2024-03-21 19:02:34 +08:00
renovate[bot] 9226b71119 Update dependency dev.chrisbanes.compose:compose-bom to v2024.03.00-alpha02 2024-03-21 10:42:27 +00:00
FooIbar 58e38b58bf Fix renovate configuration
[no ci]
2024-03-21 18:18:29 +08:00
renovate[bot] db0d110627 Update dependency androidx.paging:paging-compose to v3.3.0-alpha05 2024-03-21 10:17:09 +00:00
FooIbar 08812bf7ff Tweak renovate configuration
[no ci]
2024-03-21 18:09:13 +08:00
renovate[bot] daf7f8ffca Update dependency com.android.application to v8.5.0-alpha01 2024-03-21 09:52:03 +00:00
FooIbar 74564fa07f Tweak renovate configuration
[no ci]
2024-03-21 17:41:35 +08:00
FooIbar fc51d3fc81 Allow update to unstable AGP and Compose BOM
[no ci]
2024-03-21 17:32:50 +08:00
renovate[bot]andFooIbar c5d5b8abd2 Update dependency androidx.navigation:navigation-compose to v2.8.0-alpha05 (#929)
* Update dependency androidx.navigation:navigation-compose to v2.8.0-alpha05

* Fix deprecated

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-03-21 17:31:54 +08:00
FooIbar 193badccb8 Fix crash when reading galleries from gallery list 2024-03-21 16:52:17 +08:00
FooIbar 2653d0568a Fix false positive update notifications 2024-03-21 16:31:48 +08:00
FooIbar adf5a65371 Group Kotlin, KSP and Compose Compiler
[no ci]
2024-03-21 15:54:08 +08:00
FooIbar 5b59dabc66 Fix generating simple language from title 2024-03-21 15:19:46 +08:00
GG Bond f7259c5b40 Individual zoomableState per pager item 2024-03-17 14:56:44 +08:00
FooIbar 6f9fceaef8 Prepare for next iteration 2024-03-17 14:45:19 +08:00
FooIbar 6a64ff51ed Prepare for release 1.11.2
Releases / build (push) Has been cancelled
2024-03-17 14:44:16 +08:00
FooIbar ea9d9cfba7 Prepare changelog for 1.11.2 2024-03-17 14:41:43 +08:00
FooIbar 75c6561896 Fix app updater getting wrong download link 2024-03-16 20:13:42 +08:00
FooIbar 8f8db82e9d Fix disowned gallery uploader parsing (#918)
* Fix disowned gallery uploader parsing

* Run rustfmt

* Refine

* Run rustfmt
2024-03-16 17:48:10 +08:00
FooIbar a114a8ac72 Minor tweak to some settings' visibility 2024-03-16 11:10:08 +08:00
FooIbar 2c6216057f Revert "Merge default and marshmallow flavors"
This reverts commit 5676de24da.

Reason for revert: Desugaring causes regression on API 26+
2024-03-15 19:55:59 +08:00
FooIbar c5cbcf7418 Reintroduce confirm dialog for swipe-to-dismiss (#914)
Observe the state changes instead of using `confirmValueChange`, which
will be called twice.
2024-03-15 18:40:51 +08:00
FooIbar 5746e3bb57 Pin compose foundation version to 1.7.0-alpha03 2024-03-14 16:05:18 +08:00
FooIbar fd3c87be22 Make reader bottom sheet scrollable and fix insets 2024-03-14 16:05:08 +08:00
FooIbar 866fff3e45 Fix favorites modification blocked by snackbar 2024-03-14 16:04:55 +08:00
FooIbar 06df7befb6 Fix tag namespace for cloud favorites search 2024-03-13 21:38:16 +08:00
FooIbar 8021f86473 Address java.util.regex.Pattern usage 2024-03-13 21:34:04 +08:00
renovate[bot]andGG Bond c9919a8b39 Cleanup CronetEngine (#901)
* Cleanup CronetEngine with UploadDataProviders

* Remove WriteChannelContent as no user now

* Cleanup

* Cleanup

* Remove unnecessary invokeOnCancellation

* Refine

* Refine

* Format

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-03-13 14:40:37 +08:00
renovate[bot] 386015a9ec Update dependency com.squareup.okio:okio-jvm to v3.9.0 2024-03-13 04:00:26 +00:00
FooIbar cbce98b34e Prepare for next iteration 2024-03-12 22:47:24 +08:00
FooIbar f69521fc1a Prepare for release 1.11.1
Releases / build (push) Has been cancelled
2024-03-12 22:45:48 +08:00
FooIbar 01b4054368 Prepare changelog for 1.11.1 2024-03-12 22:34:27 +08:00
FooIbar e07aab4434 Drop basic login as captcha is mandatory now 2024-03-12 21:27:01 +08:00
FooIbar 18caa87512 Implement group by download label 2024-03-12 21:26:09 +08:00
FooIbar 5676de24da Merge default and marshmallow flavors 2024-03-12 12:05:12 +08:00
FooIbar 57bbb1fe38 Embed Cronet and drop OkHttp (#895)
* Embed Cronet and drop OkHttp

* Fix updater
2024-03-11 12:22:39 +00:00
FooIbar dcbfbc2e5f Fix concurrent modification 2024-03-11 15:04:56 +08:00
FooIbar e1f473d301 Convert remaining icons to ImageVector 2024-03-11 12:43:52 +08:00
renovate[bot] a33256d29f Update dependency sh.calvin.reorderable:reorderable to v1.3.3 2024-03-10 11:12:12 +00:00
FooIbar 6a81877b09 Prepare for next iteration 2024-03-09 19:17:18 +08:00
249 changed files with 7287 additions and 4829 deletions

No files matched your search

+47 -207
View File
@@ -21,7 +21,7 @@ jobs:
java-version: 21
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -42,10 +42,13 @@ jobs:
with:
cache-read-only: true
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014"
- name: Spotless Check
run: ./gradlew spotlessCheck
oss:
default:
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -57,8 +60,11 @@ jobs:
distribution: temurin
java-version: 21
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014"
- 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
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -80,46 +86,40 @@ jobs:
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: Build
run: ./gradlew assembleDefaultRelease
- name: Upload OSS ARM64
- name: Upload ARM64
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: default-arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/default/release/app-default-arm64-v8a-release.apk
- name: Upload OSS x86_64
- name: Upload x86_64
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: default-x86_64-${{ github.sha }}
path: app/build/outputs/apk/default/release/app-default-x86_64-release.apk
- name: Upload OSS ARM32
- name: Upload ARM32
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: default-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk
- name: Upload OSS x86
- name: Upload mapping
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: default-mapping-${{ github.sha }}
path: app/build/outputs/mapping/defaultRelease/mapping.txt
- name: Upload OSS mapping
- name: Upload native debug symbols
uses: actions/upload-artifact@v4
with:
name: default-oss-mapping-${{ github.sha }}
path: app/build/outputs/mapping/defaultOssRelease/mapping.txt
name: default-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/defaultRelease/native-debug-symbols.zip
- name: Upload OSS native debug symbols
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:
marshmallow:
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -131,85 +131,11 @@ jobs:
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@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@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@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@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@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@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 NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014"
- 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
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -231,121 +157,35 @@ jobs:
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
run: ./gradlew assembleMarshmallowRelease
- name: Build Marshmallow OSS
run: ./gradlew assembleMarshmallowOssRelease
- name: Upload Marshmallow OSS ARM64
- name: Upload ARM64
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: marshmallow-arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-arm64-v8a-release.apk
- name: Upload Marshmallow OSS x86_64
- name: Upload x86_64
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: marshmallow-x86_64-${{ github.sha }}
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-x86_64-release.apk
- name: Upload Marshmallow OSS ARM32
- name: Upload ARM32
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: marshmallow-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk
- name: Upload Marshmallow OSS x86
- name: Upload mapping
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: marshmallow-mapping-${{ github.sha }}
path: app/build/outputs/mapping/marshmallowRelease/mapping.txt
- name: Upload Marshmallow OSS mapping
- name: Upload native debug symbols
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@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@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@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@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@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@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@v4
with:
name: marshmallow-gms-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/marshmallowGmsRelease/native-debug-symbols.zip
name: marshmallow-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/marshmallowRelease/native-debug-symbols.zip
+32 -68
View File
@@ -6,8 +6,7 @@ on:
- "*"
jobs:
linux:
name: Build
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -19,8 +18,11 @@ jobs:
distribution: temurin
java-version: 21
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014"
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -30,11 +32,9 @@ jobs:
- name: CMake Cache
uses: actions/cache@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-
path: app/.cxx
key: cmake-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
restore-keys: cmake-
- name: Gradle Build
uses: gradle/actions/setup-gradle@v3
@@ -43,71 +43,35 @@ jobs:
- name: Rename Apks
run: |
mv app/build/outputs/apk/defaultOss/release/app-default-oss-universal-release.apk EhViewer-${{ github.ref_name }}-default-oss-universal.apk
mv app/build/outputs/apk/defaultOss/release/app-default-oss-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-default-oss-arm64-v8a.apk
mv app/build/outputs/apk/defaultOss/release/app-default-oss-x86_64-release.apk EhViewer-${{ github.ref_name }}-default-oss-x86_64.apk
mv app/build/outputs/apk/defaultOss/release/app-default-oss-x86-release.apk EhViewer-${{ github.ref_name }}-default-oss-x86.apk
mv app/build/outputs/apk/defaultOss/release/app-default-oss-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-default-oss-armeabi-v7a.apk
mv app/build/outputs/mapping/defaultOssRelease/mapping.txt EhViewer-${{ github.ref_name }}-default-oss-mapping.txt
mv app/build/outputs/native-debug-symbols/defaultOssRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-default-oss-native-debug-symbols.zip
mv app/build/outputs/apk/default/release/app-default-universal-release.apk EhViewer-${{ github.ref_name }}-default-universal.apk
mv app/build/outputs/apk/default/release/app-default-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
mv app/build/outputs/apk/default/release/app-default-x86_64-release.apk EhViewer-${{ github.ref_name }}-default-x86_64.apk
mv app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
mv app/build/outputs/mapping/defaultRelease/mapping.txt EhViewer-${{ github.ref_name }}-default-mapping.txt
mv app/build/outputs/native-debug-symbols/defaultRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
mv app/build/outputs/apk/defaultGms/release/app-default-gms-universal-release.apk EhViewer-${{ github.ref_name }}-default-gms-universal.apk
mv app/build/outputs/apk/defaultGms/release/app-default-gms-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-default-gms-arm64-v8a.apk
mv app/build/outputs/apk/defaultGms/release/app-default-gms-x86_64-release.apk EhViewer-${{ github.ref_name }}-default-gms-x86_64.apk
mv app/build/outputs/apk/defaultGms/release/app-default-gms-x86-release.apk EhViewer-${{ github.ref_name }}-default-gms-x86.apk
mv app/build/outputs/apk/defaultGms/release/app-default-gms-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-default-gms-armeabi-v7a.apk
mv app/build/outputs/mapping/defaultGmsRelease/mapping.txt EhViewer-${{ github.ref_name }}-default-gms-mapping.txt
mv app/build/outputs/native-debug-symbols/defaultGmsRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-default-gms-native-debug-symbols.zip
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-universal-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-universal.apk
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-arm64-v8a.apk
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-x86_64-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-x86_64.apk
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-x86-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-x86.apk
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-armeabi-v7a.apk
mv app/build/outputs/mapping/marshmallowOssRelease/mapping.txt EhViewer-${{ github.ref_name }}-marshmallow-oss-mapping.txt
mv app/build/outputs/native-debug-symbols/marshmallowOssRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-oss-native-debug-symbols.zip
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-universal-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-universal.apk
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-arm64-v8a.apk
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-x86_64-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-x86_64.apk
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-x86-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-x86.apk
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-armeabi-v7a.apk
mv app/build/outputs/mapping/marshmallowGmsRelease/mapping.txt EhViewer-${{ github.ref_name }}-marshmallow-gms-mapping.txt
mv app/build/outputs/native-debug-symbols/marshmallowGmsRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-gms-native-debug-symbols.zip
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-universal-release.apk EhViewer-${{ github.ref_name }}-marshmallow-universal.apk
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-arm64-v8a.apk
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-x86_64-release.apk EhViewer-${{ github.ref_name }}-marshmallow-x86_64.apk
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-armeabi-v7a.apk
mv app/build/outputs/mapping/marshmallowRelease/mapping.txt EhViewer-${{ github.ref_name }}-marshmallow-mapping.txt
mv app/build/outputs/native-debug-symbols/marshmallowRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-native-debug-symbols.zip
- name: Releases
uses: softprops/action-gh-release@v2
with:
body: Bump Version
files: |
EhViewer-${{ github.ref_name }}-default-oss-universal.apk
EhViewer-${{ github.ref_name }}-default-oss-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-default-oss-x86_64.apk
EhViewer-${{ github.ref_name }}-default-oss-x86.apk
EhViewer-${{ github.ref_name }}-default-oss-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-default-oss-mapping.txt
EhViewer-${{ github.ref_name }}-default-oss-native-debug-symbols.zip
EhViewer-${{ github.ref_name }}-default-universal.apk
EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-default-x86_64.apk
EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-default-mapping.txt
EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
EhViewer-${{ github.ref_name }}-default-gms-universal.apk
EhViewer-${{ github.ref_name }}-default-gms-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-default-gms-x86_64.apk
EhViewer-${{ github.ref_name }}-default-gms-x86.apk
EhViewer-${{ github.ref_name }}-default-gms-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-default-gms-mapping.txt
EhViewer-${{ github.ref_name }}-default-gms-native-debug-symbols.zip
EhViewer-${{ github.ref_name }}-marshmallow-oss-universal.apk
EhViewer-${{ github.ref_name }}-marshmallow-oss-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-marshmallow-oss-x86_64.apk
EhViewer-${{ github.ref_name }}-marshmallow-oss-x86.apk
EhViewer-${{ github.ref_name }}-marshmallow-oss-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-marshmallow-oss-mapping.txt
EhViewer-${{ github.ref_name }}-marshmallow-oss-native-debug-symbols.zip
EhViewer-${{ github.ref_name }}-marshmallow-gms-universal.apk
EhViewer-${{ github.ref_name }}-marshmallow-gms-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-marshmallow-gms-x86_64.apk
EhViewer-${{ github.ref_name }}-marshmallow-gms-x86.apk
EhViewer-${{ github.ref_name }}-marshmallow-gms-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-marshmallow-gms-mapping.txt
EhViewer-${{ github.ref_name }}-marshmallow-gms-native-debug-symbols.zip
EhViewer-${{ github.ref_name }}-marshmallow-universal.apk
EhViewer-${{ github.ref_name }}-marshmallow-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-marshmallow-x86_64.apk
EhViewer-${{ github.ref_name }}-marshmallow-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-marshmallow-mapping.txt
EhViewer-${{ github.ref_name }}-marshmallow-native-debug-symbols.zip
@@ -0,0 +1,29 @@
name: 'Update Chrome Version'
on:
schedule:
- cron: '0 0 1 * *'
workflow_dispatch:
jobs:
action:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Update Chrome Version
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd chrome-for-testing
curl -OLf https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_STABLE
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config push.autoSetupRemote true
git switch -c chore/chrome
if git commit -am "Update Chrome version for default User-Agent"; then
git push
gh pr create -f
gh pr merge chore/chrome --auto -dr
fi
+137 -1
View File
@@ -1,5 +1,141 @@
# 变更日志
## [1.11.6-RC1] - 2024-06-02
### 改进
* 下载列表默认显示全部下载项目
* 替换搜索文本中的换行符为空格 [#1258](https://github.com/FooIbar/EhViewer/issues/1258)
### Bug 修复
* 评价画廊时未使用输入的评分
* 搜索时意外选择 Non-H 类别 [#1246](https://github.com/FooIbar/EhViewer/issues/1246)
* 侧边栏与导航抽屉重叠 [#1262](https://github.com/FooIbar/EhViewer/issues/1262)
* 快速搜索/下载标签排序失效 [#1267](https://github.com/FooIbar/EhViewer/issues/1267)
* 加载压缩包/种子列表出错时 UI 冻结
* 低版本 Android 上某些 UI 被遮挡
## [1.11.5] - 2024-05-19
### 重大变更
* 不再支持 32 位 x86
### 新功能
* 支持按作者对下载分组
* 由于 E 站不再强制要求人机验证,重新支持账号密码登录
### 改进
* 在登录时加载收藏名称 [#1077](https://github.com/FooIbar/EhViewer/issues/1077)
* 优化归档和种子界面 UI
* 禁止在 cookie 界面截图以防止泄露
* 在缩略图模式下显示画廊页数
* 记住最低评分和页数搜索参数
* 更新 User-Agent 以缓解 IP 封禁 [#1182](https://github.com/FooIbar/EhViewer/issues/1182)
* 账号密码登录支持自动填充
* 更新中文(台灣)翻译
### Bug 修复
* 非触摸模式下无法退出搜索界面 [#1060](https://github.com/FooIbar/EhViewer/issues/1060)
* 某些设备上无法安装更新 [#1067](https://github.com/FooIbar/EhViewer/issues/1067)
* 某些设备上崩溃/卡顿 [#996](https://github.com/FooIbar/EhViewer/issues/996) [#1023](https://github.com/FooIbar/EhViewer/issues/1023)
* 搜索记录中存在重复条目时崩溃 [#1130](https://github.com/FooIbar/EhViewer/issues/1130)
* 条漫模式下缩小后点按区域偏移 [#127](https://github.com/FooIbar/EhViewer/issues/127)
* 多窗口模式下点按区域偏移
* 点击下载通知时未清除状态
* 保存未完成加载的图片时崩溃 [#1154](https://github.com/FooIbar/EhViewer/issues/1154)
* 历史记录界面无法在项目上滑动打开抽屉 [#464](https://github.com/FooIbar/EhViewer/issues/464)
* 导入数据失败时显示导入成功 [#1174](https://github.com/FooIbar/EhViewer/issues/1174)
* 某些条件下收藏界面崩溃 [#1190](https://github.com/FooIbar/EhViewer/issues/1190)
* 反转点按区域功能失效 [#1217](https://github.com/FooIbar/EhViewer/issues/1217)
### 已知问题
* 某些设备上从后台返回时某些 UI 消失,可通过关闭列表项目动画部分缓解 [#1184](https://github.com/FooIbar/EhViewer/issues/1184)
## [1.11.4] - 2024-04-21
### 新功能
* 更新前自动备份数据
### 改进
* 在 设置-高级 中加入了禁用列表项目动画的选项,该功能在部分设备上导致崩溃/卡顿 [#996](https://github.com/FooIbar/EhViewer/issues/996) [#1023](https://github.com/FooIbar/EhViewer/issues/1023)
* 下载列表多选模式下避免误触 [#1016](https://github.com/FooIbar/EhViewer/issues/1016)
* 杂项 UI 优化
### Bug 修复
* 重命名下载标签后删除该标签时崩溃 [#1008](https://github.com/FooIbar/EhViewer/issues/1008)
* 搜索栏中某些输入法长按退格无法连续删除 [#606](https://github.com/FooIbar/EhViewer/issues/606)
* 评论/屏蔽/搜索建议/下载标签/快速搜索列表卡顿 [#1041](https://github.com/FooIbar/EhViewer/issues/1041)
## [1.11.3] - 2024-04-06
### 改进
* 优化预加载逻辑和图片加载顺序
* 优化画廊详情和评论页面 UI
### Bug 修复
* 某些模式下搜索结果不显示画廊语言 [#923](https://github.com/FooIbar/EhViewer/issues/923)
* 某些情况下在搜索结果中直接阅读画廊后崩溃 [#927](https://github.com/FooIbar/EhViewer/issues/927)
* 更新至最新版本后仍提示有新版本 [#927](https://github.com/FooIbar/EhViewer/issues/927)
* 从旧版本升级时包含不兼容数据导致崩溃/无法导出数据 [#940](https://github.com/FooIbar/EhViewer/issues/940) [#974](https://github.com/FooIbar/EhViewer/issues/974)
* Android 10 以下的设备打开 设置-隐私 时崩溃 [#953](https://github.com/FooIbar/EhViewer/issues/953)
* 下载标签无法拖动
* 刷新 和 刷新(原图) 无法在已下载的图片上使用
* 临时 IP 封禁错误显示为解析失败
* 某些情况下下载时闪退 [#980](https://github.com/FooIbar/EhViewer/issues/980)
* 某些设备上下载列表中的缩略图无法显示 [#977](https://github.com/FooIbar/EhViewer/issues/977)
## [1.11.2] - 2024-03-17
### 重大变更
* Default 变种最低支持版本更改为 Android 8.0,重新添加了最低支持 Android 6.0 的 Marshmallow 变种
### 改进
* 重新添加了删除快速搜索/下载标签时的确认对话框
### Bug 修复
* 收藏页面 搜索栏中文标签转英文异常 [#902](https://github.com/FooIbar/EhViewer/issues/902)
* 在收藏或者取消收藏之后,收藏按钮会无反应 [#908](https://github.com/FooIbar/EhViewer/issues/908)
* 横屏页面下阅读设置UI重合 [#909](https://github.com/FooIbar/EhViewer/issues/909)
* Search bar does not work properly in Japanese [#906](https://github.com/FooIbar/EhViewer/issues/906)
* 搜索结果中的 Disowned 画廊不显示上传者
* 使用 CI 频道更新时,有时会获取到错误的更新链接
## [1.11.1] - 2024-03-12
### 重大变更
* 由于 E 站强制要求人机验证,仅支持通过网页登录
* 默认支持 Android 6,不再提供 Marshmallow 变种
* 集成 Cronet 库,不再提供 GMS 变种
### 改进
* 支持在排序下载列表前按下载标签分组
### Bug 修复
* 下载画廊时崩溃 [#893](https://github.com/FooIbar/EhViewer/issues/893)
## [1.11.0] - 2024-03-09
### 重大变更
@@ -7,6 +143,7 @@
* 由于 E 站 Cookie 策略变更,不再支持 Cookie 登录
* 登录时自动选择 exhentai (如果可用)
* 不再提供默认下载路径
* 手动排序更改为以添加时间/上传时间/标题/下载标签/页数对下载列表排序
* 由于数据库结构变更,此版本导出的数据将无法被旧版本导入
### 新功能
@@ -19,7 +156,6 @@
* 随机打开本地收藏/下载中的画廊 [#695](https://github.com/FooIbar/EhViewer/issues/695)
* 随机在搜索结果中跳转
* 以网格模式查看下载列表
* 以添加时间/上传时间/标题/下载标签/页数对下载列表排序
* 本地收藏/历史/下载中的标签搜索 [#860](https://github.com/FooIbar/EhViewer/issues/860)
### 改进
+6 -12
View File
@@ -66,16 +66,10 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
# Download
| Flavor | Feature |
|-------------|-----------------------------------|
| Default | Android 9+, full support |
| Marshmallow | Android 6.0-8.1, limited support* |
| OSS | FOSS dependencies** |
| GMS | Proprietary GMS component |
\* 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)
| Flavor | Feature |
|-------------|----------------------------------|
| Default | Android 8.0+, full support |
| Marshmallow | Android 6.0-7.1, limited support |
<a href="https://github.com/FooIbar/EhViewer/releases">
<img alt="Get it on GitHub" src="https://github.com/Ehviewer-Overhauled/Art/blob/master/get-it-on-github.svg" width="200px"/>
@@ -91,13 +85,13 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
Here is the libraries
- [Arrow](https://arrow-kt.io/)
- [AOSP & AndroidX](http://source.android.com/)
- [AOSP & AndroidX](https://source.android.com/)
- [Kotlin & KotlinX](https://kotlinlang.org/)
- [Material Icons](https://github.com/google/material-design-icons)
- [Ktor](https://ktor.io/)
- [Coil](https://coil-kt.github.io/coil/)
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
- [libarchive](http://www.libarchive.org/)
- [libarchive](https://www.libarchive.org/)
- [jsoup](https://jsoup.org/)
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
+85 -76
View File
@@ -1,8 +1,5 @@
import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
val isRelease: Boolean
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
@@ -10,6 +7,7 @@ val isRelease: Boolean
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
@@ -18,16 +16,19 @@ plugins {
alias(libs.plugins.composeCompilerReportGenerator)
}
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
android {
compileSdk = 34
ndkVersion = "26.2.11394342"
ndkVersion = "27.0.11718014-beta1"
androidResources.generateLocaleConfig = true
splits {
abi {
isEnable = true
reset()
if (isRelease) {
include("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
include(*supportedAbis)
isUniversalApk = true
} else {
include("arm64-v8a", "x86_64")
@@ -48,22 +49,25 @@ android {
commandLine = "git rev-parse --short=7 HEAD".split(' ')
}.standardOutput.asText.get().trim()
val buildTime by lazy {
val formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm").withZone(ZoneOffset.UTC)
formatter.format(Instant.now())
}
val commitTime = providers.exec {
commandLine = "git log -1 --format=%ct".split(' ')
}.standardOutput.asText.get().trim()
val repoName = providers.exec {
commandLine = "git remote get-url origin".split(' ')
}.standardOutput.asText.get().trim().removePrefix("https://github.com/").removePrefix("git@github.com:")
.removeSuffix(".git")
val chromeVersion = rootProject.layout.projectDirectory.file("chrome-for-testing/LATEST_RELEASE_STABLE").asFile
.readText().substringBefore('.')
defaultConfig {
applicationId = "moe.tarsin.ehviewer"
minSdk = 28
minSdk = 26
targetSdk = 34
versionCode = 180051
versionName = "1.11.0"
versionCode = 180056
versionName = "1.11.6"
versionNameSuffix = "-RC1"
resourceConfigurations.addAll(
listOf(
"zh",
@@ -80,34 +84,32 @@ android {
"nb-rNO",
),
)
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
buildConfigField("long", "COMMIT_TIME", commitTime)
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
buildConfigField("String", "CHROME_VERSION", "\"$chromeVersion\"")
ndk {
if (isRelease) {
abiFilters.addAll(supportedAbis)
}
debugSymbolLevel = "FULL"
}
externalNativeBuild {
cmake {
arguments += "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
}
}
}
flavorDimensions += listOf("api", "oss")
flavorDimensions += "api"
productFlavors {
create("default") {
dimension = "api"
}
create("default")
create("marshmallow") {
dimension = "api"
minSdk = 23
applicationIdSuffix = ".m"
versionNameSuffix = "-M"
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
}
create("oss") {
dimension = "oss"
}
create("gms") {
dimension = "oss"
versionNameSuffix = "-gms"
}
}
@@ -117,46 +119,18 @@ android {
}
}
kotlinOptions {
freeCompilerArgs = listOf(
// https://kotlinlang.org/docs/compiler-reference.html#progressive
"-progressive",
"-Xjvm-default=all",
"-Xlambdas=indy",
"-Xcontext-receivers",
"-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",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-opt-in=androidx.paging.ExperimentalPagingApi",
"-opt-in=kotlin.contracts.ExperimentalContracts",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:nonSkippingGroupOptimization=true",
)
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
lint {
disable += setOf("MissingTranslation", "MissingQuantity")
fatal += setOf("NewApi", "InlineApi")
fatal += setOf("NewApi", "InlinedApi")
}
packaging {
resources {
excludes += "/META-INF/**"
excludes += "/okhttp3/**"
excludes += "/kotlin/**"
excludes += "**.txt"
excludes += "**.bin"
dex {
useLegacyPackaging = false
}
}
@@ -168,11 +142,12 @@ android {
isShrinkResources = true
proguardFiles("proguard-rules.pro")
signingConfig = signConfig
buildConfigField("String", "BUILD_TIME", "\"$buildTime\"")
}
debug {
applicationIdSuffix = ".debug"
buildConfigField("String", "BUILD_TIME", "\"\"")
lint {
abortOnError = false
}
}
}
@@ -182,13 +157,25 @@ android {
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
namespace = "com.hippo.ehviewer"
}
composeCompiler {
enableNonSkippingGroupOptimization = true
enableStrongSkippingMode = true
}
androidComponents {
onVariants(selector().withBuildType("release")) {
it.packaging.resources.excludes.addAll(
"/META-INF/**",
"/kotlin/**",
"**.txt",
"**.bin",
)
}
}
dependencies {
// https://developer.android.com/jetpack/androidx/releases/activity
implementation(libs.androidx.activity.compose)
@@ -207,13 +194,13 @@ dependencies {
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.datastore)
implementation(libs.androidx.graphics.path)
// https://developer.android.com/jetpack/androidx/releases/lifecycle
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.compose)
// https://developer.android.com/jetpack/androidx/releases/navigation
implementation(libs.androidx.navigation.compose)
// https://developer.android.com/jetpack/androidx/releases/paging
implementation(libs.androidx.paging.compose)
@@ -230,10 +217,6 @@ dependencies {
implementation(libs.bundles.splitties)
// https://square.github.io/okhttp/changelogs/changelog/
implementation(platform(libs.okhttp.bom))
implementation(libs.bundles.okhttp)
implementation(libs.okio.jvm)
implementation(libs.logcat)
@@ -257,7 +240,7 @@ dependencies {
implementation(libs.telephoto.zoomable)
implementation(libs.bundles.ktor)
implementation(libs.ktor.client.core)
implementation(libs.bundles.kotlinx.serialization)
@@ -267,16 +250,42 @@ dependencies {
coreLibraryDesugaring(libs.desugar)
"gmsImplementation"(libs.bundles.cronet)
implementation(libs.cronet.embedded)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview)
}
kotlin {
jvmToolchain(21)
compilerOptions {
freeCompilerArgs = listOf(
// https://kotlinlang.org/docs/compiler-reference.html#progressive
"-progressive",
"-Xjvm-default=all",
"-Xcontext-receivers",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-opt-in=androidx.paging.ExperimentalPagingApi",
"-opt-in=kotlin.contracts.ExperimentalContracts",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
)
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.generateKotlin", "true")
arg("compose-destinations.codeGenPackageName", "com.hippo.ehviewer.ui")
}
aboutLibraries {
+6
View File
@@ -16,5 +16,11 @@
-dontwarn org.conscrypt.Conscrypt
# https://issuetracker.google.com/222232895
-dontwarn androidx.window.extensions.**
-dontwarn androidx.window.sidecar.Sidecar*
-keepattributes LineNumberTable
-allowaccessmodification
-repackageclasses
@@ -0,0 +1,564 @@
{
"formatVersion": 1,
"database": {
"version": 23,
"identityHash": "7d1cfc4e9a9d506a73eb0520ae414d25",
"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": "DOWNLOAD_ARTISTS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `ARTIST` TEXT NOT NULL, PRIMARY KEY(`GID`, `ARTIST`), FOREIGN KEY(`GID`) REFERENCES `DOWNLOADS`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "artist",
"columnName": "ARTIST",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID",
"ARTIST"
]
},
"indices": [],
"foreignKeys": [
{
"table": "DOWNLOADS",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"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, '7d1cfc4e9a9d506a73eb0520ae414d25')"
]
}
}
@@ -1,12 +0,0 @@
package com.hippo.ehviewer.util
import com.google.android.gms.net.CronetProviderInstaller
import org.chromium.net.CronetProvider
import splitties.init.appCtx
val isCronetAvailable: Boolean
get() = CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
if (!it) {
CronetProviderInstaller.installProvider(appCtx)
}
}
+9 -9
View File
@@ -29,7 +29,6 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="@bool/large_heap"
android:localeConfig="@xml/locale_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
@@ -101,23 +100,24 @@
<data android:scheme="content" />
<data android:host="*" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/7z" />
<data android:mimeType="application/rar" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/x-7z-compressed" />
<data android:mimeType="application/x-rar-compressed" />
<data android:mimeType="application/x-zip-compressed" />
<data android:mimeType="application/x-tar" />
<data android:mimeType="application/x-xz" />
<data android:mimeType="application/gzip" />
<data android:mimeType="application/vnd.rar" />
<data android:mimeType="application/vnd.comicbook+zip" />
<data android:mimeType="application/vnd.comicbook-rar" />
<!-- unofficial -->
<data android:mimeType="application/7z" />
<data android:mimeType="application/rar" />
<data android:mimeType="application/x-cbz" />
<data android:mimeType="application/x-cbr" />
<data android:mimeType="application/x-cbt" />
<data android:mimeType="application/x-cb7" />
<!-- untested -->
<data android:mimeType="application/vnd.comicbook+zip" />
<data android:mimeType="application/vnd.comicbook-rar" />
<data android:mimeType="application/x-zip-compressed" />
<data android:mimeType="application/x-rar-compressed" />
<data android:mimeType="application/x-compressed-tar" />
<data android:mimeType="application/x-bzip-compressed-tar" />
<data android:mimeType="application/x-lzma-compressed-tar" />
@@ -0,0 +1,52 @@
From d1db03ebd9989d10a5a905b792b712c6487a0057 Mon Sep 17 00:00:00 2001
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
Date: Sat, 24 Feb 2024 12:03:15 +0000
Subject: [PATCH] Don't build other binaries
---
CMakeLists.txt | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index a346dd02..52b5d0d8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1567,7 +1567,7 @@ endif()
# xzdec and lzmadec
#############################################################################
-if(HAVE_DECODERS AND (NOT MSVC OR MSVC_VERSION GREATER_EQUAL 1900))
+if(0)
foreach(XZDEC xzdec lzmadec)
add_executable("${XZDEC}"
src/common/sysdefs.h
@@ -1624,7 +1624,7 @@ endif()
# lzmainfo
#############################################################################
-if(HAVE_DECODERS AND (NOT MSVC OR MSVC_VERSION GREATER_EQUAL 1900))
+if(0)
add_executable(lzmainfo
src/common/sysdefs.h
src/common/tuklib_common.h
@@ -1680,7 +1680,7 @@ endif()
# xz
#############################################################################
-if(NOT MSVC OR MSVC_VERSION GREATER_EQUAL 1900)
+if(0)
add_executable(xz
src/common/mythread.h
src/common/sysdefs.h
@@ -1914,7 +1914,7 @@ endif()
# Scripts
#############################################################################
-if(UNIX)
+if(0)
# NOTE: This isn't as sophisticated as in the Autotools build which
# uses posix-shell.m4 but hopefully this doesn't need to be either.
# CMake likely won't be used on as many (old) obscure systems as the
--
2.34.1
+29
View File
@@ -0,0 +1,29 @@
From 2fa97f75b64c05da506968fa9a1711716f2d45a0 Mon Sep 17 00:00:00 2001
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
Date: Tue, 26 Sep 2023 00:08:03 +0800
Subject: [PATCH 1/2] Hide symbols
---
CMakeLists.txt | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index ec97e4c7..4d859341 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -2129,10 +2129,10 @@ SET(OLD_CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS}")
SET(CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS} -fvisibility=hidden -Werror")
CHECK_C_SOURCE_COMPILES("void __attribute__((visibility(\"default\"))) foo(void);
int main() { return 0; }" HAVE_VISIBILITY_ATTR)
-IF (HAVE_VISIBILITY_ATTR)
+IF (0)
SET(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden")
ADD_DEFINITIONS(-D__LIBARCHIVE_ENABLE_VISIBILITY)
-ENDIF(HAVE_VISIBILITY_ATTR)
+ENDIF(0)
SET(CMAKE_REQUIRED_FLAGS "${OLD_CMAKE_REQUIRED_FLAGS}")
# Generate "config.h" from "build/cmake/config.h.in"
--
2.34.1
@@ -0,0 +1,25 @@
From 2dcc8be937499022c37678eb6bf4bd026b6fcbb0 Mon Sep 17 00:00:00 2001
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
Date: Tue, 26 Sep 2023 00:09:25 +0800
Subject: [PATCH 2/2] Insert link libs
---
CMakeLists.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 4d859341..1cd1cd47 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -438,7 +438,7 @@ IF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
# # endif
ENDIF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
-SET(ADDITIONAL_LIBS "")
+SET(ADDITIONAL_LIBS ${LIBARCHIVE_CUSTOM_LIBS})
#
# Find ZLIB
#
--
2.34.1
+8 -5
View File
@@ -20,8 +20,9 @@ option(BUILD_TESTING OFF)
FetchContent_Declare(
liblzma
GIT_REPOSITORY https://github.com/FooIbar/xz.git
GIT_TAG v5.6.1
GIT_REPOSITORY https://github.com/tukaani-project/xz.git
GIT_TAG v5.6.2
PATCH_COMMAND git apply ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch || cd .
)
FetchContent_MakeAvailable(liblzma)
@@ -68,8 +69,9 @@ SET(LIBARCHIVE_CUSTOM_LIBS "nettle" "liblzma")
FetchContent_Declare(
libarchive
GIT_REPOSITORY https://github.com/FooIbar/libarchive.git
GIT_TAG v3.7.2
GIT_REPOSITORY https://github.com/libarchive/libarchive.git
GIT_TAG v3.7.4
PATCH_COMMAND git apply ${CMAKE_CURRENT_LIST_DIR}/0001-Hide-symbols.patch ${CMAKE_CURRENT_LIST_DIR}/0002-Insert-link-libs.patch || cd .
)
FetchContent_MakeAvailable(libarchive)
@@ -78,11 +80,12 @@ include_directories(${libarchive_SOURCE_DIR}/libarchive)
FetchContent_Declare(
Corrosion
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
GIT_TAG 0a3bdf452995803c334d79dc9a2affbfad51b720
GIT_TAG v0.5
)
FetchContent_MakeAvailable(Corrosion)
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml)
corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384")
# Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c natsort/strnatcmp.c)
+8 -5
View File
@@ -54,8 +54,9 @@ static archive_ctx **ctx_pool;
static void *mempool = NULL;
static size_t *mempoolofs = NULL;
static int page_size = 0;
#define PAGE_ALIGN(x) ((x + ~PAGE_MASK) & PAGE_MASK)
#define PAGE_ALIGN(x) ((x + page_size - 1) & ~(page_size - 1))
#define MEMPOOL_ADDR_BY_SORTED_IDX(x) (mempool + (index ? mempoolofs[index - 1] : 0))
#define MEMPOOL_SIZE (mempoolofs[entryCount - 1])
@@ -127,8 +128,6 @@ static long archive_map_entries_index(archive_ctx *ctx, bool sort) {
count++;
}
}
if (!count)
LOGE("%s", archive_error_string(ctx->arc));
if (sort)
qsort(entries, entryCount, sizeof(entry), compare_entries);
return count;
@@ -171,8 +170,6 @@ static long archive_list_all_entries(archive_ctx *ctx) {
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK)
if (archive_entry_is_playable(ctx->entry))
count++;
if (!count)
LOGE("%s", archive_error_string(ctx->arc));
return count;
}
@@ -286,6 +283,7 @@ JNIEXPORT jint JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd, jlong size, jboolean sort_entries) {
EH_UNUSED(env);
EH_UNUSED(thiz);
page_size = getpagesize();
archive_ctx *ctx = NULL;
archiveAddr = mmap(0, size, PROT_READ, MAP_PRIVATE, fd, 0);
if (archiveAddr == MAP_FAILED) {
@@ -306,6 +304,11 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
} else {
entryCount = archive_list_all_entries(ctx);
LOGI("%s%zu%s", "Found ", entryCount, " image entries in archive");
if (!entryCount) {
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
archive_release_ctx(ctx);
return 0;
}
// We must read through the file|vm then we can know whether it is encrypted
int encryptRet = archive_read_has_encrypted_entries(ctx->arc);
@@ -0,0 +1,278 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation.gestures
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.requireLayoutDirection
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@ExperimentalFoundationApi
fun <T> Modifier.anchoredHorizontalDraggable(
state: AnchoredDraggableState<T>,
enableDragFromStartToEnd: Boolean = true,
enableDragFromEndToStart: Boolean = true,
enabled: Boolean = true,
startDragImmediately: Boolean = state.isAnimationRunning,
): Modifier = this then AnchoredHorizontalDraggableElement(
state = state,
canDrag = { (x, y) ->
if (x > 0) {
enableDragFromStartToEnd
} else {
enableDragFromEndToStart
} && x.absoluteValue > y.absoluteValue * 2f
},
enabled = enabled,
startDragImmediately = startDragImmediately,
)
@OptIn(ExperimentalFoundationApi::class)
private class AnchoredHorizontalDraggableElement<T>(
private val state: AnchoredDraggableState<T>,
private val canDrag: (Offset) -> Boolean,
private val enabled: Boolean,
private val startDragImmediately: Boolean,
) : ModifierNodeElement<AnchoredHorizontalDraggableNode<T>>() {
override fun create() = AnchoredHorizontalDraggableNode(
state,
canDrag,
enabled,
startDragImmediately,
)
override fun update(node: AnchoredHorizontalDraggableNode<T>) {
node.update(
state,
canDrag,
enabled,
startDragImmediately,
)
}
override fun hashCode(): Int {
var result = state.hashCode()
result = 31 * result + canDrag.hashCode()
result = 31 * result + enabled.hashCode()
result = 31 * result + startDragImmediately.hashCode()
return result
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AnchoredHorizontalDraggableElement<*>) return false
if (state != other.state) return false
if (canDrag != other.canDrag) return false
if (enabled != other.enabled) return false
if (startDragImmediately != other.startDragImmediately) return false
return true
}
override fun InspectorInfo.inspectableProperties() {
name = "anchoredDraggable"
properties["state"] = state
properties["canDrag"] = canDrag
properties["enabled"] = enabled
properties["startDragImmediately"] = startDragImmediately
}
}
@OptIn(ExperimentalFoundationApi::class)
private class AnchoredHorizontalDraggableNode<T>(
private var state: AnchoredDraggableState<T>,
private var canDrag: (Offset) -> Boolean,
enabled: Boolean,
private var startDragImmediately: Boolean,
) : HorizontalDragGestureNode(
enabled = enabled,
) {
private val isReverseDirection: Boolean
get() = requireLayoutDirection() == LayoutDirection.Rtl
override fun canDrag(change: PointerInputChange): Boolean {
return canDrag(change.positionChange().reverseIfNeeded())
}
override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) {
state.anchoredDrag {
forEachDelta { dragDelta ->
dragTo(state.newOffsetForDelta(dragDelta.delta.reverseIfNeeded().x))
}
}
}
override fun onDragStarted(startedPosition: Offset) { }
override fun onDragStopped(velocity: Velocity) {
if (!isAttached) return
coroutineScope.launch {
state.settle(velocity.reverseIfNeeded().x)
}
}
override fun startDragImmediately(): Boolean = startDragImmediately
fun update(
state: AnchoredDraggableState<T>,
canDrag: (Offset) -> Boolean,
enabled: Boolean,
startDragImmediately: Boolean,
) {
var resetPointerInputHandling = false
if (this.state != state) {
this.state = state
resetPointerInputHandling = true
}
this.canDrag = canDrag
this.startDragImmediately = startDragImmediately
update(
enabled = enabled,
shouldResetPointerInputHandling = resetPointerInputHandling,
)
}
private fun Velocity.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
private fun Offset.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
}
internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) =
((if (offset.isNaN()) 0f else offset) + delta)
.coerceIn(anchors.minAnchor(), anchors.maxAnchor())
/**
* This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout
* node's size and offsetting it.
* It considers lookahead and reports the appropriate size and measurement for the appropriate
* phase.
*
* @param state The state the anchors should be attached to
* @param orientation The orientation the component should be offset in
* @param anchors Lambda to calculate the anchors based on this layout's size and the incoming
* constraints. These can be useful to avoid subcomposition.
*/
internal fun <T> Modifier.draggableAnchors(
state: AnchoredDraggableState<T>,
orientation: Orientation,
anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
) = this then DraggableAnchorsElement(state, anchors, orientation)
private class DraggableAnchorsElement<T>(
private val state: AnchoredDraggableState<T>,
private val anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
private val orientation: Orientation,
) : ModifierNodeElement<DraggableAnchorsNode<T>>() {
override fun create() = DraggableAnchorsNode(state, anchors, orientation)
override fun update(node: DraggableAnchorsNode<T>) {
node.state = state
node.anchors = anchors
node.orientation = orientation
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is DraggableAnchorsElement<*>) return false
if (state != other.state) return false
if (anchors !== other.anchors) return false
if (orientation != other.orientation) return false
return true
}
override fun hashCode(): Int {
var result = state.hashCode()
result = 31 * result + anchors.hashCode()
result = 31 * result + orientation.hashCode()
return result
}
override fun InspectorInfo.inspectableProperties() {
debugInspectorInfo {
properties["state"] = state
properties["anchors"] = anchors
properties["orientation"] = orientation
}
}
}
private class DraggableAnchorsNode<T>(
var state: AnchoredDraggableState<T>,
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
var orientation: Orientation,
) : Modifier.Node(), LayoutModifierNode {
private var didLookahead: Boolean = false
override fun onDetach() {
didLookahead = false
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
val placeable = measurable.measure(constraints)
// If we are in a lookahead pass, we only want to update the anchors here and not in
// post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead),
// update the anchors in the main pass.
if (!isLookingAhead || !didLookahead) {
val size = IntSize(placeable.width, placeable.height)
val newAnchorResult = anchors(size, constraints)
state.updateAnchors(newAnchorResult.first, newAnchorResult.second)
}
didLookahead = isLookingAhead || didLookahead
return layout(placeable.width, placeable.height) {
// In a lookahead pass, we use the position of the current target as this is where any
// ongoing animations would move. If the component is in a settled state, lookahead
// and post-lookahead will converge.
val offset = if (isLookingAhead) {
state.anchors.positionOf(state.targetValue)
} else {
state.requireOffset()
}
val xOffset = if (orientation == Orientation.Horizontal) offset else 0f
val yOffset = if (orientation == Orientation.Vertical) offset else 0f
placeable.placeRelative(xOffset.roundToInt(), yOffset.roundToInt())
}
}
}
@@ -0,0 +1,113 @@
/*
* 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.compose.foundation.gestures
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.ViewConfiguration
import kotlin.coroutines.cancellation.CancellationException
/**
* A Gesture detector that waits for pointer down and touch slop in the horizontal direction and
* then calls [onDrag] for each drag event.
* It follows the touch slop detection of [awaitHorizontalTouchSlopOrCancellation] but will consume
* the position change automatically once the touch slop has been crossed, the amount of drag over
* the touch slop is reported as the first drag event [onDrag] after the slop is crossed.
* If [shouldAwaitTouchSlop] returns true the touch slop recognition phase will be ignored
* and the drag gesture will be recognized immediately.The first [onDrag] in this case will report
* an [Offset.Zero].
*
* [onDragStart] is called when the touch slop has been passed and includes an [Offset] representing
* the last known pointer position relative to the containing element as well as the initial
* down event that triggered this gesture detection cycle. The [Offset] can be outside
* the actual bounds of the element itself meaning the numbers can be negative or larger than the
* element bounds if the touch target is smaller than the
* [ViewConfiguration.minimumTouchTargetSize].
*
* [onDragEnd] is called after all pointers are up with the event change of the up event
* and [onDragCancel] is called if another gesture has consumed pointer input,
* canceling this gesture.
*
* @param onDragStart A lambda to be called when the drag gesture starts, it contains information
* about the last known [PointerInputChange] relative to the containing element and the post slop
* delta.
* @param onDragEnd A lambda to be called when the gesture ends. It contains information about the
* up [PointerInputChange] that finished the gesture.
* @param onDragCancel A lambda to be called when the gesture is cancelled either by an error or
* when it was consumed.
* @param shouldAwaitTouchSlop Indicates if touch slop detection should be skipped.
* @param onDrag A lambda to be called for each delta event in the gesture. It contains information
* about the [PointerInputChange] and the movement offset.
*/
internal suspend fun PointerInputScope.detectHorizontalDragGestures(
canDrag: (PointerInputChange) -> Boolean,
onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit,
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
shouldAwaitTouchSlop: () -> Boolean,
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit,
) {
awaitEachGesture {
val initialDown =
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val awaitTouchSlop = shouldAwaitTouchSlop()
if (!awaitTouchSlop) {
initialDown.consume()
}
val down = awaitFirstDown(requireUnconsumed = false)
var drag: PointerInputChange?
var overSlop = Offset.Zero
if (awaitTouchSlop) {
do {
drag = awaitHorizontalTouchSlopOrCancellation(
down.id,
) { change, over ->
if (canDrag.invoke(change)) {
change.consume()
overSlop = Offset(over, 0f)
} else {
throw CancellationException()
}
}
} while (drag != null && !drag.isConsumed)
} else {
drag = initialDown
}
if (drag != null) {
onDragStart.invoke(drag, overSlop)
onDrag(drag, overSlop)
val dragEnd = horizontalDrag(
pointerId = drag.id,
onDrag = {
onDrag(it, it.positionChange())
it.consume()
},
)
if (dragEnd) {
onDragEnd()
} else {
onDragCancel()
}
}
}
}
@@ -0,0 +1,228 @@
/*
* Copyright 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation.gestures
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragCancelled
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragStarted
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragStopped
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.PointerInputModifierNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.Velocity
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.sign
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* A node that performs drag gesture recognition and event propagation.
*/
internal abstract class HorizontalDragGestureNode(
enabled: Boolean,
) : DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode {
protected var enabled = enabled
private set
private var channel: Channel<HorizontalDragEvent>? = null
private var isListeningForEvents = false
abstract fun canDrag(change: PointerInputChange): Boolean
/**
* Responsible for the dragging behavior between the start and the end of the drag. It
* continually invokes `forEachDelta` to process incoming events. In return, `forEachDelta`
* calls `dragBy` method to process each individual delta.
*/
abstract suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit)
/**
* Passes the action needed when a drag starts. This gives the ability to pass the desired
* behavior from other nodes implementing AbstractDraggableNode
*/
abstract fun onDragStarted(startedPosition: Offset)
/**
* Passes the action needed when a drag stops. This gives the ability to pass the desired
* behavior from other nodes implementing AbstractDraggableNode
*/
abstract fun onDragStopped(velocity: Velocity)
/**
* If touch slop recognition should be skipped. If this is true, this node will start
* recognizing drag events immediately without waiting for touch slop.
*/
abstract fun startDragImmediately(): Boolean
private fun startListeningForEvents() {
isListeningForEvents = true
/**
* To preserve the original behavior we had (before the Modifier.Node migration) we need to
* scope the DragStopped and DragCancel methods to the node's coroutine scope instead of using
* the one provided by the pointer input modifier, this is to ensure that even when the pointer
* input scope is reset we will continue any coroutine scope scope that we started from these
* methods while the pointer input scope was active.
*/
coroutineScope.launch {
while (isActive) {
var event = channel?.receive()
if (event !is DragStarted) continue
onDragStarted(event.startPoint)
try {
drag { processDelta ->
while (event !is DragStopped && event !is DragCancelled) {
(event as? DragDelta)?.let(processDelta)
event = channel?.receive()
}
}
if (event is DragStopped) {
onDragStopped((event as DragStopped).velocity)
} else if (event is DragCancelled) {
onDragStopped(Velocity.Zero)
}
} catch (c: CancellationException) {
onDragStopped(Velocity.Zero)
}
}
}
}
private var pointerInputNode: SuspendingPointerInputModifierNode? = null
override fun onDetach() {
isListeningForEvents = false
}
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize,
) {
if (enabled && pointerInputNode == null) {
pointerInputNode = delegate(initializePointerInputNode())
}
pointerInputNode?.onPointerEvent(pointerEvent, pass, bounds)
}
private fun initializePointerInputNode(): SuspendingPointerInputModifierNode {
return SuspendingPointerInputModifierNode {
// re-create tracker when pointer input block restarts. This lazily creates the tracker
// only when it is need.
val velocityTracker = VelocityTracker()
val onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit =
{ startEvent, initialDelta ->
if (!isListeningForEvents) {
if (channel == null) {
channel = Channel(capacity = Channel.UNLIMITED)
}
startListeningForEvents()
}
val xSign = sign(startEvent.position.x)
val ySign = sign(startEvent.position.y)
val adjustedStart = startEvent.position -
Offset(initialDelta.x * xSign, initialDelta.y * ySign)
channel?.trySend(DragStarted(adjustedStart))
}
val onDragEnd: () -> Unit = {
val maximumVelocity = currentValueOf(LocalViewConfiguration)
.maximumFlingVelocity
val velocity = velocityTracker.calculateVelocity(
Velocity(maximumVelocity, maximumVelocity),
)
velocityTracker.resetTracking()
channel?.trySend(DragStopped(velocity))
}
val onDragCancel: () -> Unit = {
channel?.trySend(DragCancelled)
}
val shouldAwaitTouchSlop: () -> Boolean = {
!startDragImmediately()
}
val onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit =
{ change, delta ->
velocityTracker.addPointerInputChange(change)
channel?.trySend(DragDelta(delta))
}
coroutineScope {
try {
detectHorizontalDragGestures(
canDrag = ::canDrag,
onDragStart = onDragStart,
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
shouldAwaitTouchSlop = shouldAwaitTouchSlop,
onDrag = onDrag,
)
} catch (cancellation: CancellationException) {
channel?.trySend(DragCancelled)
if (!isActive) throw cancellation
}
}
}
}
override fun onCancelPointerInput() {
pointerInputNode?.onCancelPointerInput()
}
fun update(
enabled: Boolean = this.enabled,
shouldResetPointerInputHandling: Boolean = false,
) {
var resetPointerInputHandling = shouldResetPointerInputHandling
if (this.enabled != enabled) {
this.enabled = enabled
if (!enabled) {
pointerInputNode?.let { undelegate(it) }
pointerInputNode = null
}
resetPointerInputHandling = true
}
if (resetPointerInputHandling) {
pointerInputNode?.resetPointerInputHandler()
}
}
}
internal sealed class HorizontalDragEvent {
class DragStarted(val startPoint: Offset) : HorizontalDragEvent()
class DragStopped(val velocity: Velocity) : HorizontalDragEvent()
object DragCancelled : HorizontalDragEvent()
class DragDelta(val delta: Offset) : HorizontalDragEvent()
}
@@ -0,0 +1,106 @@
@file:Suppress("ktlint:standard:property-naming")
package androidx.compose.material3
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@Composable
fun SearchBarInputField(
state: TextFieldState,
onSearch: () -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
colors: TextFieldColors = SearchBarDefaults.inputFieldColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val focused by interactionSource.collectIsFocusedAsState()
val textColor = LocalTextStyle.current.color.takeOrElse {
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
}
BasicTextField(
state = state,
modifier = modifier.sizeIn(
minWidth = SearchBarMinWidth,
maxWidth = SearchBarMaxWidth,
minHeight = InputFieldHeight,
).focusRequester(focusRequester).onFocusChanged {
if (it.isFocused) onExpandedChange(true)
}.fillMaxWidth(),
enabled = enabled,
lineLimits = TextFieldLineLimits.SingleLine,
textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)),
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
onKeyboardAction = { onSearch() },
interactionSource = interactionSource,
decorator = @Composable { innerTextField ->
TextFieldDefaults.DecorationBox(
value = state.text.toString(),
innerTextField = innerTextField,
enabled = enabled,
singleLine = true,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon?.let { leading -> { Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() } } },
trailingIcon = trailingIcon?.let { trailing -> { Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } } },
shape = SearchBarDefaults.inputFieldShape,
colors = colors,
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
container = {},
)
},
)
val shouldClearFocus = !expanded && focused
LaunchedEffect(expanded) {
if (shouldClearFocus) {
// Not strictly needed according to the motion spec, but since the animation
// already has a delay, this works around b/261632544.
delay(AnimationDelayMillis.toLong())
focusManager.clearFocus()
}
}
}
val SearchBarIconOffsetX = 4.dp
val SearchBarMinWidth = 360.dp
val SearchBarMaxWidth = 720.dp
val SearchBarHorizontalPadding = 16.dp
const val AnimationDelayMillis = 100
@@ -1,591 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("ktlint:standard:property-naming")
package androidx.compose.material3
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.MutableWindowInsets
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.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.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.offset
import androidx.compose.ui.util.lerp
import androidx.compose.ui.zIndex
import kotlin.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.delay
/**
* <a href="https://m3.material.io/components/search/overview" class="external" target="_blank">Material Design search</a>.
*
* A search bar represents a floating search field that allows users to enter a keyword or phrase
* and get relevant information. It can be used as a way to navigate through an app via search
* queries.
*
* An active search bar expands into a search "view" and can be used to display dynamic suggestions.
*
* ![Search bar image](https://developer.android.com/images/reference/androidx/compose/material3/search-bar.png)
*
* A [SearchBar] expands to occupy the entirety of its allowed size when active. For full-screen
* behavior as specified by Material guidelines, parent layouts of the [SearchBar] must not pass
* any [Constraints] that limit its size, and the host activity should set
* `WindowCompat.setDecorFitsSystemWindows(window, false)`.
*
* If this expansion behavior is undesirable, for example on large tablet screens, [DockedSearchBar]
* can be used instead.
*
* @param state the query text to be shown in the search bar's input field
* @param onSearch the callback to be invoked when the input service triggers the [ImeAction.Search]
* action.
* @param active whether this search bar is active
* @param onActiveChange the callback to be invoked when this search bar's active state is changed
* @param modifier the [Modifier] to be applied to this search bar
* @param enabled controls the enabled state of this search bar. When `false`, this component will
* not respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param placeholder the placeholder to be displayed when the search bar's query is empty.
* @param leadingIcon the leading icon to be displayed at the beginning of the search bar container
* @param trailingIcon the trailing icon to be displayed at the end of the search bar container
* @param shape the shape of this search bar when it is not [active]. When [active], the shape will
* always be [SearchBarDefaults.fullScreenShape].
* @param colors [SearchBarColors] that will be used to resolve the colors used for this search bar
* in different states. See [SearchBarDefaults.colors].
* @param tonalElevation when [SearchBarColors.containerColor] is [ColorScheme.surface], a
* translucent primary color overlay is applied on top of the container. A higher tonal elevation
* value will result in a darker color in light theme and lighter color in dark theme. See also:
* [Surface].
* @param shadowElevation the elevation for the shadow below the search bar
* @param windowInsets the window insets that the search bar will respect
* @param interactionSource 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
@Composable
fun SearchBar(
state: TextFieldState,
onSearch: () -> Unit,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
title: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
shape: Shape = SearchBarDefaults.inputFieldShape,
colors: SearchBarColors = SearchBarDefaults.colors(),
tonalElevation: Dp = SearchBarDefaults.TonalElevation,
shadowElevation: Dp = SearchBarDefaults.ShadowElevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
interactionSource: MutableInteractionSource? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val animationProgress = remember { Animatable(initialValue = if (active) 1f else 0f) }
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
val defaultInputFieldShape = SearchBarDefaults.inputFieldShape
val defaultFullScreenShape = SearchBarDefaults.fullScreenShape
val useFullScreenShape by remember {
derivedStateOf(structuralEqualityPolicy()) { animationProgress.value == 1f }
}
val animatedShape = remember(useFullScreenShape, shape) {
when {
shape == defaultInputFieldShape ->
// The shape can only be animated if it's the default spec value
GenericShape { size, _ ->
val radius = with(density) {
(SearchBarCornerRadius * (1 - animationProgress.value)).toPx()
}
addRoundRect(RoundRect(size.toRect(), CornerRadius(radius)))
}
useFullScreenShape -> defaultFullScreenShape
else -> shape
}
}
// The main animation complexity is allowing the component to smoothly expand while keeping the
// input field at the same relative location on screen. `Modifier.windowInsetsPadding` does not
// support animation and thus is not suitable. Instead, we convert the insets to a padding
// applied to the Surface, which gradually becomes padding applied to the input field as the
// animation proceeds.
val unconsumedInsets = remember { MutableWindowInsets() }
val topPadding = remember(density) {
derivedStateOf {
SearchBarVerticalPadding +
unconsumedInsets.asPaddingValues(density).calculateTopPadding()
}
}
Surface(
shape = animatedShape,
color = colors.containerColor,
contentColor = contentColorFor(colors.containerColor),
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
modifier = modifier
.padding(horizontal = lerp(SearchBarHorizontalPadding, 0.dp, animationProgress.value))
.zIndex(1f)
.onConsumedWindowInsetsChanged { consumedInsets ->
unconsumedInsets.insets = windowInsets.exclude(consumedInsets)
}
.consumeWindowInsets(unconsumedInsets)
.layout { measurable, constraints ->
val animatedTopPadding =
lerp(topPadding.value, 0.dp, animationProgress.value).roundToPx()
val 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
val width = lerp(startWidth, endWidth, animationProgress.value)
val height =
lerp(startHeight, endHeight, animationProgress.value) + animatedTopPadding
val minOffsetMargin = SearchBarPredictiveBackMinMargin.roundToPx()
val predictiveBackOffsetX = calculatePredictiveBackOffsetX(
constraints,
minOffsetMargin,
currentBackEvent.value,
animationProgress.value,
predictiveBackMultiplier,
)
val predictiveBackOffsetY = calculatePredictiveBackOffsetY(
constraints,
minOffsetMargin,
currentBackEvent.value,
firstBackEvent.value,
height,
SearchBarPredictiveBackMaxOffsetY.roundToPx(),
predictiveBackMultiplier,
)
val placeable = measurable.measure(
Constraints
.fixed(width, height)
.offset(
vertical = -animatedTopPadding,
),
)
layout(width, height) {
placeable.placeRelative(
predictiveBackOffsetX,
animatedTopPadding + predictiveBackOffsetY,
)
}
},
) {
Column {
val animatedInputFieldPadding = remember {
AnimatedPaddingValues2(animationProgress.asState(), topPadding)
}
SearchBarInputField(
state = state,
onSearch = onSearch,
onActiveChange = onActiveChange,
modifier = Modifier.padding(paddingValues = animatedInputFieldPadding),
enabled = enabled,
label = title.takeUnless { active || animationProgress.value > 0 || state.text.isNotEmpty() },
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
colors = colors.inputFieldColors,
interactionSource = interactionSource,
)
val showResults by remember {
derivedStateOf(structuralEqualityPolicy()) { animationProgress.value > 0 }
}
if (showResults) {
Column(Modifier.graphicsLayer { alpha = animationProgress.value }) {
HorizontalDivider(color = colors.dividerColor)
content()
}
}
}
}
val isFocused = interactionSource.collectIsFocusedAsState().value
val shouldClearFocus = !active && isFocused
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()
}
}
}
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(
constraints: Constraints,
minMargin: Int,
currentBackEvent: BackEventCompat?,
progress: Float,
predictiveBackMultiplier: Float,
): Int {
if (currentBackEvent == null || predictiveBackMultiplier == 0f) {
return 0
}
val directionMultiplier = if (currentBackEvent.swipeEdge == BackEventCompat.EDGE_LEFT) 1 else -1
val maxOffsetX =
(constraints.maxWidth * SearchBarPredictiveBackMaxOffsetXRatio) - minMargin
val interpolatedOffsetX = maxOffsetX * (1 - progress)
return (interpolatedOffsetX * predictiveBackMultiplier * directionMultiplier).roundToInt()
}
private fun calculatePredictiveBackOffsetY(
constraints: Constraints,
minMargin: Int,
currentBackEvent: BackEventCompat?,
firstBackEvent: BackEventCompat?,
height: Int,
maxOffsetY: Int,
predictiveBackMultiplier: Float,
): Int {
if (firstBackEvent == null || currentBackEvent == null || predictiveBackMultiplier == 0f) {
return 0
}
val availableVerticalSpace = max(0, (constraints.maxHeight - height) / 2 - minMargin)
val adjustedMaxOffsetY = min(availableVerticalSpace, maxOffsetY)
val yDelta = currentBackEvent.touchY - firstBackEvent.touchY
val yProgress = abs(yDelta) / constraints.maxHeight
val directionMultiplier = sign(yDelta)
val interpolatedOffsetY = lerp(0, adjustedMaxOffsetY, yProgress)
return (interpolatedOffsetY * predictiveBackMultiplier * directionMultiplier).roundToInt()
}
@Composable
private fun SearchBarInputField(
state: TextFieldState,
onSearch: () -> Unit,
onActiveChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
colors: TextFieldColors = SearchBarDefaults.inputFieldColors(),
interactionSource: MutableInteractionSource? = 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
}
BasicTextField(
state = state,
modifier = modifier
.height(InputFieldHeight)
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { if (it.isFocused) onActiveChange(true) }
.semantics {
onClick {
focusRequester.requestFocus()
true
}
},
enabled = enabled,
lineLimits = TextFieldLineLimits.SingleLine,
textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)),
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
interactionSource = interactionSource,
decorator = @Composable { innerTextField ->
TextFieldDefaults.DecorationBox(
value = state.text.toString(),
innerTextField = innerTextField,
enabled = enabled,
singleLine = true,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon?.let { leading ->
{
Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() }
}
},
trailingIcon = trailingIcon?.let { trailing ->
{
Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() }
}
},
shape = SearchBarDefaults.inputFieldShape,
colors = colors,
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
container = {},
)
},
)
}
@Stable
private class AnimatedPaddingValues2(
val animationProgress: State<Float>,
val topPadding: State<Dp>,
) : PaddingValues {
override fun calculateTopPadding(): Dp = topPadding.value * animationProgress.value
override fun calculateBottomPadding(): Dp = SearchBarVerticalPadding * animationProgress.value
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = 0.dp
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = 0.dp
}
// Measurement specs
private val SearchBarCornerRadius: Dp = InputFieldHeight / 2
internal val SearchBarMinWidth: Dp = 360.dp
private val SearchBarMaxWidth: Dp = 720.dp
internal val SearchBarVerticalPadding: Dp = 8.dp
internal val SearchBarHorizontalPadding: Dp = 16.dp
// Search bar has 16dp padding between icons and start/end, while by default text field has 12dp.
private val SearchBarIconOffsetX: Dp = 4.dp
private const val SearchBarPredictiveBackMinScale: Float = 9f / 10f
private val SearchBarPredictiveBackMinMargin: Dp = 8.dp
private const val SearchBarPredictiveBackMaxOffsetXRatio: Float = 1f / 20f
private val SearchBarPredictiveBackMaxOffsetY: Dp = 24.dp
// Animation specs
private const val AnimationEnterDurationMillis: Int = 600
private const val AnimationExitDurationMillis: Int = 350
private const val AnimationDelayMillis: Int = 100
private val AnimationEnterEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f)
private val AnimationExitEasing = CubicBezierEasing(0.0f, 1.0f, 0.0f, 1.0f)
private val AnimationEnterFloatSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = AnimationEnterDurationMillis,
delayMillis = AnimationDelayMillis,
easing = AnimationEnterEasing,
)
private val AnimationExitFloatSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = AnimationExitDurationMillis,
delayMillis = AnimationDelayMillis,
easing = AnimationExitEasing,
)
private val AnimationPredictiveBackExitFloatSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = AnimationExitDurationMillis,
easing = AnimationExitEasing,
)
@@ -10,11 +10,8 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.horizontalDrag
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -42,17 +39,12 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
@@ -63,7 +55,6 @@ import kotlinx.coroutines.launch
private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
private const val DrawerPositionalThreshold = 0.5f
private val DrawerVelocityThreshold = 400.dp
private val ContainerWidth = 360.0.dp
@Suppress("NotCloseable")
@Stable
@@ -236,7 +227,7 @@ class DrawerState2(
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
fun rememberDrawerState(
fun rememberDrawerState2(
initialValue: DrawerValue,
confirmStateChange: (DrawerValue) -> Boolean = { true },
): DrawerState2 {
@@ -246,69 +237,40 @@ fun rememberDrawerState(
}
@Composable
fun ModalNavigationDrawer(
fun ModalSideDrawer(
drawerContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
drawerState: DrawerState2 = rememberDrawerState2(DrawerValue.Closed),
gesturesEnabled: Boolean = true,
scrimColor: Color = DrawerDefaults.scrimColor,
content: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var minValue by remember { mutableFloatStateOf(-with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
var maxValue by remember { mutableFloatStateOf(with(density) { DrawerDefaults.MaximumDrawerWidth.toPx() }) }
val minValue = 0f
SideEffect {
drawerState.density = density
drawerState.anchoredDraggableState.updateAnchors(
DraggableAnchors {
DrawerValue.Closed at minValue
DrawerValue.Open at maxValue
DrawerValue.Closed at maxValue
DrawerValue.Open at minValue
},
)
}
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val velocityTracker = remember { VelocityTracker() }
val dragModifier = if (gesturesEnabled) {
Modifier.pointerInput(isRtl) {
val multiplier = if (isRtl) -1 else 1
awaitEachGesture {
fun canConsume(slop: Float) = (slop > 0 && drawerState.isClosed) || (slop < 0 && drawerState.isOpen)
val down = awaitFirstDown(requireUnconsumed = false)
var ignore = false
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
val overSlop = over * multiplier
val canConsume = canConsume(overSlop)
if (canConsume && !ignore) {
velocityTracker.addPointerInputChange(change)
change.consume()
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
} else {
ignore = true
}
}
if (drag != null) {
horizontalDrag(drag.id) {
velocityTracker.addPointerInputChange(it)
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
}
val velocity = velocityTracker.calculateVelocity()
velocityTracker.resetTracking()
scope.launch {
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
}
}
}
}
} else {
Modifier
}
Box(modifier.fillMaxSize().then(dragModifier)) {
Box(
modifier.fillMaxSize().anchoredHorizontalDraggable(
state = drawerState.anchoredDraggableState,
enableDragFromStartToEnd = drawerState.isOpen,
enableDragFromEndToStart = drawerState.isClosed,
enabled = gesturesEnabled,
),
) {
val radius by remember {
snapshotFlow {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
val step = calculateFraction(maxValue, minValue, drawerState.currentOffset)
lerp(0, 10, step).dp
}
}.collectAsState(0.dp)
@@ -330,145 +292,7 @@ fun ModalNavigationDrawer(
}
},
fraction = {
calculateFraction(minValue, maxValue, drawerState.requireOffset())
},
color = scrimColor,
)
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(drawerState.isOpen) {
drawerState.close()
}
val absoluteElevation = LocalAbsoluteTonalElevation.current + DrawerDefaults.ModalDrawerElevation
val predictiveModifier = if (drawerState.isOpen) {
if (predictiveState > 0) {
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val multiplierX = lerp(1f, 1.05f, predictiveState)
val multiplierY = lerp(1f, 0.95f, predictiveState)
val scaledWidth = (multiplierX * placeable.width).roundToInt()
val scaledHeight = (multiplierY * placeable.height).roundToInt()
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
layout(scaledWidth, scaledHeight) {
reMeasured.placeRelative(scaledWidth - placeable.width, 0)
}
}.background(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
shape = DrawerDefaults.shape,
)
} else {
Modifier.graphicsLayer {
val scale = lerp(1f, 0.95f, -predictiveState)
scaleX = scale
scaleY = scale
}.offset {
IntOffset(lerp(0f, minValue * 0.05f, -predictiveState).roundToInt(), 0)
}
}
} else {
Modifier.onGloballyPositioned {
val w = it.size.width
if (w != 0) {
minValue = -it.size.width.toFloat()
}
}
}
Box(
modifier = Modifier.offset {
IntOffset(drawerState.requireOffset().roundToInt(), 0)
} then predictiveModifier.align(Alignment.CenterStart),
contentAlignment = Alignment.CenterEnd,
) {
drawerContent()
}
}
}
@Composable
fun SideDrawer(
drawerContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
gesturesEnabled: Boolean = true,
scrimColor: Color = DrawerDefaults.scrimColor,
content: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var minValue by remember { mutableFloatStateOf(with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
SideEffect {
drawerState.density = density
drawerState.anchoredDraggableState.updateAnchors(
DraggableAnchors {
DrawerValue.Closed at minValue
DrawerValue.Open at maxValue
},
)
}
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val velocityTracker = remember { VelocityTracker() }
val dragModifier = if (gesturesEnabled) {
Modifier.pointerInput(isRtl) {
val multiplier = if (isRtl) -1 else 1
awaitEachGesture {
fun canConsume(slop: Float) = (slop > 0 && drawerState.isOpen) || (slop < 0 && drawerState.isClosed)
val down = awaitFirstDown(requireUnconsumed = false)
var ignore = false
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
val overSlop = over * multiplier
val canConsume = canConsume(overSlop)
if (canConsume && !ignore) {
velocityTracker.addPointerInputChange(change)
change.consume()
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
} else {
ignore = true
}
}
if (drag != null) {
horizontalDrag(drag.id) {
velocityTracker.addPointerInputChange(it)
drag.consume()
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
}
val velocity = velocityTracker.calculateVelocity()
velocityTracker.resetTracking()
scope.launch {
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
}
}
}
}
} else {
Modifier
}
Box(modifier.fillMaxSize().then(dragModifier)) {
val radius by remember {
snapshotFlow {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
lerp(0, 10, step).dp
}
}.collectAsState(0.dp)
val blurModifier = Modifier.graphicsLayer {
if (radius != 0.dp) {
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
shape = RectangleShape
clip = true
}
}
Box(modifier = blurModifier) {
content()
}
Scrim(
open = drawerState.isOpen,
onClose = {
if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) {
scope.launch { drawerState.close() }
}
},
fraction = {
calculateFraction(minValue, maxValue, drawerState.requireOffset())
calculateFraction(maxValue, minValue, drawerState.requireOffset())
},
color = scrimColor,
)
@@ -486,7 +310,7 @@ fun SideDrawer(
val scaledHeight = (multiplierY * placeable.height).roundToInt()
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
layout(scaledWidth, scaledHeight) {
reMeasured.placeRelative(-(scaledWidth - placeable.width), 0)
reMeasured.placeRelative(placeable.width - scaledWidth, 0)
}
}.background(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
@@ -498,14 +322,20 @@ fun SideDrawer(
scaleX = scale
scaleY = scale
}.offset {
IntOffset(lerp(0f, minValue * 0.05f, predictiveState).roundToInt(), 0)
IntOffset(lerp(0f, maxValue * 0.05f, predictiveState).roundToInt(), 0)
}
}
} else {
Modifier.onGloballyPositioned {
val w = it.size.width
if (w != 0) {
minValue = it.size.width.toFloat()
Modifier.onSizeChanged {
val width = it.width
if (width != 0) {
maxValue = width.toFloat()
drawerState.anchoredDraggableState.updateAnchors(
DraggableAnchors {
DrawerValue.Closed at maxValue
DrawerValue.Open at minValue
},
)
}
}
}
@@ -0,0 +1,255 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.material3.fork
import androidx.annotation.FloatRange
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.draggableAnchors
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.SwipeToDismissBoxDefaults
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
private val SnapAnimationSpec = SpringSpec<Float>()
// TODO: Update to M3 spec once it's available
private val DecayAnimationSpec = exponentialDecay<Float>()
/**
* State of the [SwipeToDismissBox] composable.
*
* @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 positionalThreshold The positional threshold to be used when calculating the target state
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
* the start of a transition. It will be, depending on the direction of the interaction, added or
* subtracted from/to the origin offset. It should always be a positive value.
*/
class SwipeToDismissBoxState(
initialValue: SwipeToDismissBoxValue,
internal val density: Density,
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
positionalThreshold: (totalDistance: Float) -> Float,
) {
internal val anchoredDraggableState =
AnchoredDraggableState(
initialValue = initialValue,
snapAnimationSpec = SnapAnimationSpec,
decayAnimationSpec = DecayAnimationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = positionalThreshold,
velocityThreshold = { with(density) { DismissVelocityThreshold.toPx() } },
)
internal val offset: Float get() = anchoredDraggableState.offset
/**
* Require the current offset.
*
* @throws IllegalStateException If the offset has not been initialized yet
*/
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
/**
* The current state value of the [SwipeToDismissBoxState].
*/
val currentValue: SwipeToDismissBoxValue get() = anchoredDraggableState.currentValue
/**
* The target state. This is the closest state to the current offset (taking into account
* positional thresholds). If no interactions like animations or drags are in progress, this
* will be the current state.
*/
val targetValue: SwipeToDismissBoxValue get() = anchoredDraggableState.targetValue
/**
* The fraction of the progress going from currentValue to targetValue, within [0f..1f] bounds.
*/
@get:FloatRange(from = 0.0, to = 1.0)
val progress: Float get() = anchoredDraggableState.progress(anchoredDraggableState.settledValue, anchoredDraggableState.targetValue)
/**
* The direction (if any) in which the composable has been or is being dismissed.
*
* Use this to change the background of the [SwipeToDismissBox] if you want different actions
* on each side.
*/
val dismissDirection: SwipeToDismissBoxValue
get() = when {
offset == 0f || offset.isNaN() -> SwipeToDismissBoxValue.Settled
offset > 0f -> SwipeToDismissBoxValue.StartToEnd
else -> SwipeToDismissBoxValue.EndToStart
}
/**
* Set the state without any animation and suspend until it's set
*
* @param targetValue The new target value
*/
suspend fun snapTo(targetValue: SwipeToDismissBoxValue) {
anchoredDraggableState.snapTo(targetValue)
}
/**
* Reset the component to the default position with animation and suspend until it if fully
* reset or animation has been cancelled. This method will throw [CancellationException] if
* the animation is interrupted
*
* @return the reason the reset animation ended
*/
suspend fun reset() = anchoredDraggableState.animateTo(
targetValue = SwipeToDismissBoxValue.Settled,
)
/**
* Dismiss the component in the given [direction], with an animation and suspend. This method
* will throw [CancellationException] if the animation is interrupted
*
* @param direction The dismiss direction.
*/
suspend fun dismiss(direction: SwipeToDismissBoxValue) {
anchoredDraggableState.animateTo(targetValue = direction)
}
companion object {
/**
* The default [Saver] implementation for [SwipeToDismissBoxState].
*/
fun Saver(
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean,
positionalThreshold: (totalDistance: Float) -> Float,
density: Density,
) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
save = { it.currentValue },
restore = {
SwipeToDismissBoxState(
it,
density,
confirmValueChange,
positionalThreshold,
)
},
)
}
}
/**
* Create and [remember] a [SwipeToDismissBoxState].
*
* @param initialValue The initial value of the state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param positionalThreshold The positional threshold to be used when calculating the target state
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
* the start of a transition. It will be, depending on the direction of the interaction, added or
* subtracted from/to the origin offset. It should always be a positive value.
*/
@Composable
fun rememberSwipeToDismissBoxState(
initialValue: SwipeToDismissBoxValue = SwipeToDismissBoxValue.Settled,
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
positionalThreshold: (totalDistance: Float) -> Float =
SwipeToDismissBoxDefaults.positionalThreshold,
): SwipeToDismissBoxState {
val density = LocalDensity.current
return rememberSaveable(
saver = SwipeToDismissBoxState.Saver(
confirmValueChange = confirmValueChange,
density = density,
positionalThreshold = positionalThreshold,
),
) {
SwipeToDismissBoxState(initialValue, density, confirmValueChange, positionalThreshold)
}
}
/**
* A composable that can be dismissed by swiping left or right.
*
* @param state The state of this component.
* @param backgroundContent A composable that is stacked behind the [content] and is exposed when
* the content is swiped. You can/should use the [state] to have different backgrounds on each side.
* @param modifier Optional [Modifier] for this component.
* @param enableDismissFromStartToEnd Whether SwipeToDismissBox can be dismissed from start to end.
* @param enableDismissFromEndToStart Whether SwipeToDismissBox can be dismissed from end to start.
* @param gesturesEnabled Whether swipe-to-dismiss can be interacted by gestures.
* @param content The content that can be dismissed.
*/
@Composable
fun SwipeToDismissBox(
state: SwipeToDismissBoxState,
backgroundContent: @Composable RowScope.() -> Unit,
modifier: Modifier = Modifier,
enableDismissFromStartToEnd: Boolean = true,
enableDismissFromEndToStart: Boolean = true,
gesturesEnabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) {
Box(
modifier
.anchoredHorizontalDraggable(
state = state.anchoredDraggableState,
enableDragFromStartToEnd = enableDismissFromStartToEnd,
enableDragFromEndToStart = enableDismissFromEndToStart,
enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled,
),
propagateMinConstraints = true,
) {
Row(
content = backgroundContent,
modifier = Modifier.matchParentSize(),
)
Row(
content = content,
modifier = Modifier.draggableAnchors(
state.anchoredDraggableState,
Orientation.Horizontal,
) { size, _ ->
val width = size.width.toFloat()
return@draggableAnchors DraggableAnchors {
SwipeToDismissBoxValue.Settled at 0f
if (enableDismissFromStartToEnd) {
SwipeToDismissBoxValue.StartToEnd at width
}
if (enableDismissFromEndToStart) {
SwipeToDismissBoxValue.EndToStart at -width
}
} to state.targetValue
},
)
}
}
private val DismissVelocityThreshold = 125.dp
@@ -56,7 +56,8 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// Current quickscale state
private val quickScaleThreshold: Float
private val quickScaleThreshold =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20F, context.resources.displayMetrics)
// Long click handler
private var longClickJob: Job? = null
@@ -85,9 +86,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
@ScaleType
private var minimumScaleType = SCALE_TYPE_CENTER_INSIDE
// Whether to crop borders.
private var cropBorders = false
// Gesture detection settings
private var panEnabled = true
@@ -186,15 +184,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
var isReady = false
private set
/**
* Call to find whether the main image (base layer tiles where relevant) have been loaded. Before
* this event the view is blank unless a preview was provided.
*
* @return true if the main image (not the preview) has been loaded and is ready to display.
*/
var isImageLoaded = false
private set
// Event listener
private var onImageEventListener: OnImageEventListener? = null
@@ -218,11 +207,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
private val mtrx = Matrix()
init {
setMinimumDpi(160)
setDoubleTapZoomDpi(160)
setGestureDetector(context)
quickScaleThreshold =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20F, context.resources.displayMetrics)
}
/**
@@ -270,7 +255,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
sHeight = 0
srcRect.setEmpty()
isReady = false
isImageLoaded = false
bitmap = null
bitmapIsCached = false
}
@@ -768,19 +752,13 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
}
}
/**
* Checks whether the base layer of tiles or full size bitmap is ready.
*/
private val isBaseLayerReady: Boolean
get() = bitmap != null
/**
* Check whether view and image dimensions are known and either a preview, full size image or
* base layer tiles are loaded. First time, send ready event to listener. The next draw will
* display an image.
*/
private fun checkReady(): Boolean {
val ready = width > 0 && height > 0 && sWidth > 0 && sHeight > 0 && (bitmap != null || isBaseLayerReady)
val ready = width > 0 && height > 0 && sWidth > 0 && sHeight > 0 && bitmap != null
if (!isReady && ready) {
preDraw()
isReady = true
@@ -789,19 +767,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
return ready
}
/**
* Check whether either the full size bitmap or base layer tiles are loaded. First time, send image
* loaded event to listener.
*/
private fun checkImageLoaded(): Boolean {
val imageLoaded = isBaseLayerReady
if (!isImageLoaded && imageLoaded) {
preDraw()
isImageLoaded = true
}
return imageLoaded
}
/**
* Sets scale and translate ready for the next draw.
*/
@@ -832,13 +797,14 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* is set so one dimension fills the view and the image is centered on the other dimension. Used to calculate what the target of an
* animation should be.
*
* @param _center Whether the image should be centered in the dimension it's too small to fill. While animating this can be false to avoid changes in direction as bounds are reached.
* @param maybeCenter Whether the image should be centered in the dimension it's too small to fill. While animating this can be false to avoid changes in direction as bounds are reached.
* @param sat The scale we want and the translation we're aiming for. The values are adjusted to be valid.
*/
private fun fitToBounds(_center: Boolean, sat: ScaleAndTranslate) {
var center = _center
if (panLimit == PAN_LIMIT_OUTSIDE && isReady) {
center = false
private fun fitToBounds(maybeCenter: Boolean, sat: ScaleAndTranslate) {
val center = if (panLimit == PAN_LIMIT_OUTSIDE && isReady) {
false
} else {
maybeCenter
}
val vTranslate = sat.vTranslate
val scale = limitedScale(sat.scale)
@@ -920,21 +886,9 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
this.bitmap = bitmap
sWidth = srcRect.width()
sHeight = srcRect.height()
val ready = checkReady()
val imageLoaded = checkImageLoaded()
if (ready || imageLoaded) {
invalidate()
requestLayout()
}
}
/**
* Set border crop of non-filled (white or black) content.
*
* @param cropBorders Whether to crop image borders.
*/
fun setCropBorders(cropBorders: Boolean) {
this.cropBorders = cropBorders
checkReady()
invalidate()
requestLayout()
}
/**
@@ -987,7 +941,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param sTarget target object for result. The same instance is also returned.
* @return a coordinate representing the corresponding source coordinate. This is the same instance passed to the sTarget param.
*/
fun viewToSourceCoord(vx: Float, vy: Float, sTarget: PointF = PointF()): PointF? {
private fun viewToSourceCoord(vx: Float, vy: Float, sTarget: PointF = PointF()): PointF? {
if (vTranslate == null) {
return null
}
@@ -1027,7 +981,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param vTarget target object for result. The same instance is also returned.
* @return view coordinates. This is the same instance passed to the vTarget param.
*/
fun sourceToViewCoord(sx: Float, sy: Float, vTarget: PointF = PointF()): PointF? {
private fun sourceToViewCoord(sx: Float, sy: Float, vTarget: PointF = PointF()): PointF? {
if (vTranslate == null) {
return null
}
@@ -1336,8 +1290,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
}
/**
* Add a listener allowing notification of load and error events. Extend [DefaultOnImageEventListener]
* to simplify implementation.
* Add a listener allowing notification of load and error events.
*
* @param onImageEventListener an [OnImageEventListener] instance.
*/
@@ -1391,7 +1344,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
/**
* An event listener, allowing subclasses and activities to be notified of significant events.
*/
interface OnImageEventListener {
fun interface OnImageEventListener {
/**
* Called when the dimensions of the image and view are known, and either a preview image,
* the full size image, or base layer tiles are loaded. This indicates the scale and translate
@@ -1399,16 +1352,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* graphic, or inform a subclass that it is safe to draw overlays.
*/
fun onReady()
/**
* Indicates an error initiliasing the decoder when using a tiling, or when loading the full
* size bitmap when tiling is disabled. This method cannot be relied upon; certain encoding
* types of supported image formats can result in corrupt or blank images being loaded and
* displayed with no detectable error.
*
* @param e The exception thrown. This error is also logged by the view.
*/
fun onImageLoadError(e: Exception)
}
/**
@@ -1467,14 +1410,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
private data class ScaleAndTranslate(var scale: Float, val vTranslate: PointF)
/**
* Default implementation of [OnImageEventListener] for extension. This does nothing in any method.
*/
open class DefaultOnImageEventListener : OnImageEventListener {
override fun onReady() {}
override fun onImageLoadError(e: Exception) {}
}
/**
* Builder class used to set additional options for a scale animation. Create an instance using [.animateScale],
* then set your options and call [.start].
@@ -1723,7 +1658,5 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* State change originated from a double tap zoom anim.
*/
const val ORIGIN_DOUBLE_TAP_ZOOM = 4
private val TAG = SubsamplingScaleImageView::class.java.simpleName
}
}
@@ -0,0 +1,152 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.systemuicontroller
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.view.View
import android.view.Window
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
/**
* A class which provides easy-to-use utilities for updating the System UI bar
* colors within Jetpack Compose.
*/
@Stable
interface SystemUiController {
/**
* Control for the behavior of the system bars. This value should be one of the
* [WindowInsetsControllerCompat] behavior constants:
* [WindowInsetsControllerCompat.BEHAVIOR_SHOW_BARS_BY_TOUCH] (Deprecated),
* [WindowInsetsControllerCompat.BEHAVIOR_DEFAULT] and
* [WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE].
*/
var systemBarsBehavior: Int
/**
* Property which holds the status bar visibility. If set to true, show the status bar,
* otherwise hide the status bar.
*/
var isStatusBarVisible: Boolean
/**
* Property which holds the navigation bar visibility. If set to true, show the navigation bar,
* otherwise hide the navigation bar.
*/
var isNavigationBarVisible: Boolean
/**
* Property which holds the status & navigation bar visibility. If set to true, show both bars,
* otherwise hide both bars.
*/
var isSystemBarsVisible: Boolean
get() = isNavigationBarVisible && isStatusBarVisible
set(value) {
isStatusBarVisible = value
isNavigationBarVisible = value
}
}
/**
* Remembers a [SystemUiController] for the given [window].
*
* If no [window] is provided, an attempt to find the correct [Window] is made.
*
* First, if the [LocalView]'s parent is a [DialogWindowProvider], then that dialog's [Window] will
* be used.
*
* Second, we attempt to find [Window] for the [Activity] containing the [LocalView].
*
* If none of these are found (such as may happen in a preview), then the functionality of the
* returned [SystemUiController] will be degraded, but won't throw an exception.
*/
@Composable
fun rememberSystemUiController(
window: Window? = findWindow(),
): SystemUiController {
val view = LocalView.current
return remember(view, window) { AndroidSystemUiController(view, window) }
}
@Composable
private fun findWindow(): Window? =
(LocalView.current.parent as? DialogWindowProvider)?.window
?: LocalView.current.context.findWindow()
private tailrec fun Context.findWindow(): Window? =
when (this) {
is Activity -> window
is ContextWrapper -> baseContext.findWindow()
else -> null
}
/**
* A helper class for setting the navigation and status bar colors for a [View], gracefully
* degrading behavior based upon API level.
*
* Typically you would use [rememberSystemUiController] to remember an instance of this.
*/
internal class AndroidSystemUiController(
private val view: View,
window: Window?,
) : SystemUiController {
private val windowInsetsController = window?.let {
WindowCompat.getInsetsController(it, view)
}
override var systemBarsBehavior: Int
get() = windowInsetsController?.systemBarsBehavior ?: 0
set(value) {
windowInsetsController?.systemBarsBehavior = value
}
override var isStatusBarVisible: Boolean
get() {
return ViewCompat.getRootWindowInsets(view)
?.isVisible(WindowInsetsCompat.Type.statusBars()) == true
}
set(value) {
if (value) {
windowInsetsController?.show(WindowInsetsCompat.Type.statusBars())
} else {
windowInsetsController?.hide(WindowInsetsCompat.Type.statusBars())
}
}
override var isNavigationBarVisible: Boolean
get() {
return ViewCompat.getRootWindowInsets(view)
?.isVisible(WindowInsetsCompat.Type.navigationBars()) == true
}
set(value) {
if (value) {
windowInsetsController?.show(WindowInsetsCompat.Type.navigationBars())
} else {
windowInsetsController?.hide(WindowInsetsCompat.Type.navigationBars())
}
}
}
@@ -25,11 +25,14 @@ import androidx.collection.LruCache
import androidx.compose.runtime.snapshots.Snapshot
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil3.EventListener
import coil3.SingletonImageLoader
import coil3.asCoilImage
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.ktor.KtorNetworkFetcherFactory
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.serviceLoaderEnabled
import coil3.util.DebugLogger
@@ -43,33 +46,30 @@ import com.hippo.ehviewer.coil.MergeInterceptor
import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.DownloadsFilterMode
import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.ktbuilder.imageLoader
import com.hippo.ehviewer.ktor.CronetEngine
import com.hippo.ehviewer.legacy.cleanObsoleteCache
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.lockObserver
import com.hippo.ehviewer.ui.tools.dataStateFlow
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.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastS
import com.hippo.ehviewer.util.isCronetAvailable
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.cookies.HttpCookies
import kotlinx.coroutines.launch
import logcat.AndroidLogcatLogger
import logcat.LogPriority
import logcat.LogcatLogger
import okhttp3.AsyncDns
import okhttp3.android.AndroidAsyncDns
import logcat.asLog
import okio.Path.Companion.toOkioPath
import splitties.arch.room.roomDb
import splitties.init.appCtx
@@ -105,13 +105,13 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
super.onCreate()
System.loadLibrary("ehviewer")
lifecycleScope.launchIO {
launch { EhTagDatabase }
launch { EhDB }
dataStateFlow.value
launch {
EhTagDatabase
}
launch {
EhDB
}
launch {
if (DownloadManager.labelList.isNotEmpty() && Settings.downloadFilterMode.key !in Settings.prefs) {
Settings.downloadFilterMode.value = DownloadsFilterMode.CUSTOM.flag
}
DownloadManager.readMetadataFromLocal()
}
launch {
@@ -180,36 +180,30 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
crossfade(300)
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
if (drawable != null) error(drawable.asCoilImage(true))
if (BuildConfig.DEBUG) logger(DebugLogger())
if (BuildConfig.DEBUG) {
logger(DebugLogger())
} else {
eventListener(object : EventListener() {
override fun onError(request: ImageRequest, result: ErrorResult) {
logcat("ImageLoader", LogPriority.ERROR) {
"🚨 Failed - ${request.data}\n${result.throwable.asLog()}"
}
}
})
}
}
companion object {
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
}
HttpClient(CronetEngine) {
install(HttpCookies) {
storage = EhCookieStore
}
}
}
val noRedirectKtorClient by lazy {
HttpClient(ktorClient.engine) {
HttpClient(CronetEngine) {
followRedirects = false
install(HttpCookies) {
storage = EhCookieStore
+59 -67
View File
@@ -20,6 +20,7 @@ import android.net.Uri
import arrow.fx.coroutines.release
import arrow.fx.coroutines.resource
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.dao.DownloadArtist
import com.hippo.ehviewer.dao.DownloadDirname
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.DownloadLabel
@@ -29,14 +30,19 @@ 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.dao.Schema17to18
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import kotlinx.coroutines.flow.Flow
import splitties.arch.room.roomDb
object EhDB {
private val db = roomDb<EhDatabase>("eh.db")
private const val DB_NAME = "eh.db"
private val db = roomDb<EhDatabase>(DB_NAME) {
addMigrations(Schema17to18())
}
private suspend fun putGalleryInfo(galleryInfo: BaseGalleryInfo) {
db.galleryDao().upsert(galleryInfo)
@@ -108,8 +114,11 @@ object EhDB {
dao.insertOrIgnore(downloadDirnameList)
}
val downloadsCount
get() = db.downloadsDao().count()
val downloadsCountByLabel
get() = db.downloadsDao().countByLabel()
val downloadsCountByArtist
get() = db.downloadsDao().countByArtist()
suspend fun getAllDownloadLabelList() = db.downloadLabelDao().list()
@@ -137,6 +146,14 @@ object EhDB {
dao.fill(raw.position)
}
suspend fun putDownloadArtist(gid: Long, artists: List<DownloadArtist>) {
if (artists.isNotEmpty()) {
val dao = db.downloadArtistDao()
dao.deleteByGid(gid)
dao.insert(artists)
}
}
suspend fun removeLocalFavorites(galleryInfo: BaseGalleryInfo) {
db.localFavoritesDao().deleteByKey(galleryInfo.gid)
deleteGalleryInfo(galleryInfo)
@@ -256,82 +273,57 @@ object EhDB {
db.filterDao().update(filter)
}
suspend fun exportDB(context: Context, uri: Uri) {
val ehExportName = "eh.export.db"
resource {
context.deleteDatabase(ehExportName)
roomDb<EhDatabase>(ehExportName)
} release {
it.close()
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) }
db.historyDao().list().let { newDb.historyDao().insertOrIgnore(it) }
db.quickSearchDao().list().let { newDb.quickSearchDao().insert(it) }
db.localFavoritesDao().list().let { newDb.localFavoritesDao().insertOrIgnore(it) }
db.filterDao().list().let { newDb.filterDao().insert(it) }
newDb.close()
val dbFile = context.getDatabasePath(ehExportName)
dbFile.asUniFile() sendTo uri.asUniFile()
}
fun exportDB(context: Context, file: UniFile) {
db.query("PRAGMA wal_checkpoint(FULL)", null).use { it.moveToNext() }
val dbFile = context.getDatabasePath(DB_NAME)
dbFile.asUniFile() sendTo file
}
suspend fun importDB(context: Context, uri: Uri) {
val tempDBName = "tmp.db"
resource {
context.deleteDatabase(tempDBName)
roomDb<EhDatabase>(tempDBName) { createFromInputStream { context.contentResolver.openInputStream(uri) } }
roomDb<EhDatabase>(tempDBName) {
createFromInputStream { context.contentResolver.openInputStream(uri) }
addMigrations(Schema17to18())
}
} release {
it.close()
context.deleteDatabase(tempDBName)
} use { oldDB ->
runCatching {
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
val downloadLabelList = oldDB.downloadLabelDao().list()
DownloadManager.addDownloadLabel(downloadLabelList)
oldDB.downloadDirnameDao().list().let {
importDownloadDirname(it)
}
runCatching {
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
DownloadManager.addDownload(downloadInfoList)
val historyInfoList = oldDB.historyDao().list()
importHistoryInfo(historyInfoList)
val quickSearchList = oldDB.quickSearchDao().list()
val currentQuickSearchList = db.quickSearchDao().list()
val offset = currentQuickSearchList.size
val importList = quickSearchList.filter { newQS ->
currentQuickSearchList.none { it.name == newQS.name }
}.onEachIndexed { index, q -> q.position = index + offset }
importQuickSearch(importList)
oldDB.localFavoritesDao().list().let {
importLocalFavorites(it)
}
runCatching {
val downloadLabelList = oldDB.downloadLabelDao().list()
DownloadManager.addDownloadLabel(downloadLabelList)
}
runCatching {
oldDB.downloadDirnameDao().list().let {
importDownloadDirname(it)
}
}
runCatching {
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
DownloadManager.addDownload(downloadInfoList)
}
runCatching {
val historyInfoList = oldDB.historyDao().list()
importHistoryInfo(historyInfoList)
}
runCatching {
val quickSearchList = oldDB.quickSearchDao().list()
val currentQuickSearchList = db.quickSearchDao().list()
val offset = currentQuickSearchList.size
val importList = quickSearchList.filter { newQS ->
currentQuickSearchList.none { it.name == newQS.name }
}.onEachIndexed { index, q -> q.position = index + offset }
importQuickSearch(importList)
}
runCatching {
oldDB.localFavoritesDao().list().let {
importLocalFavorites(it)
}
}
runCatching {
val filterList = oldDB.filterDao().list()
val currentFilterList = db.filterDao().list()
filterList.forEach {
if (it !in currentFilterList) addFilter(it)
}
val filterList = oldDB.filterDao().list()
val currentFilterList = db.filterDao().list()
filterList.forEach {
if (it !in currentFilterList) addFilter(it)
}
}
}
@@ -10,9 +10,11 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import com.hippo.ehviewer.client.CHROME_USER_AGENT
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.download.DownloadsFilterMode
import com.hippo.ehviewer.download.SortMode
import com.hippo.ehviewer.util.AppConfig
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import java.util.Locale
@@ -110,12 +112,17 @@ object Settings : DataStorePreferences(null) {
val listThumbSize = intPref("list_tile_size", 40)
val languageFilter = intPref("language_filter", -1)
val downloadSortMode = intPref("download_sort_mode", SortMode.Default.flag)
val downloadFilterMode = intPref("download_filter_mode", DownloadsFilterMode.Default.flag)
val hasSignedIn = boolPref("has_signed_in", EhCookieStore.hasSignedIn())
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
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 security = boolPref("require_unlock", false)
val animateItems = boolPref("animate_items", true)
val displayName = stringOrNullPref("display_name", null)
val recentDownloadLabel = stringOrNullPref("recent_download_label", "")
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", null)
@@ -141,12 +148,12 @@ object Settings : DataStorePreferences(null) {
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 backupBeforeUpdate by boolPref("backup_before_update", true)
var useCIUpdateChannel by boolPref("ci_update_channel", AppConfig.isSnapshot)
var mediaScan by boolPref("media_scan", false).observed { updateWhenKeepMediaStatusChanges() }
var hasDefaultDownloadLabel by boolPref("has_default_download_label", false)
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)
var harmonizeCategoryColor by boolPref("harmonize_category_color", true)
@@ -154,26 +161,17 @@ object Settings : DataStorePreferences(null) {
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 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 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 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)
var lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
// Tachiyomi Reader
var newReader by boolPref("new_compose_reader", false)
@@ -202,7 +200,8 @@ object Settings : DataStorePreferences(null) {
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 readerPagerNavInverted = intPref("reader_tapping_inverted_2", 0)
val readerWebtoonNavInverted = intPref("reader_tapping_inverted_webtoon_2", 0)
val webtoonSidePadding = intPref("webtoon_side_padding", 0)
val navigateToPan = boolPref("navigate_pan", true)
val imageScaleType = intPref("pref_image_scale_type_key", 1)
@@ -2,8 +2,6 @@ package com.hippo.ehviewer
import android.app.UiModeManager
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
@@ -27,15 +25,6 @@ fun <T, R : Settings.Delegate<T>> R.observed(func: (Unit) -> Unit) = apply { col
fun <T, R : Settings.Delegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { flowGetter().collect { flow.emit(Unit) } } }
fun <T, R : PrefDelegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { changesFlow().collect { flow.emit(Unit) } } }
fun updateWhenLocaleChanges() {
val newValue = Settings.language
if ("system" == newValue) {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList())
} else {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(newValue))
}
}
fun updateWhenKeepMediaStatusChanges() {
collectScope.launchIO {
runCatching {
@@ -64,7 +53,7 @@ fun updateWhenRequestNewsChanges() {
}
fun updateWhenGallerySiteChanges() {
if (!Settings.needSignIn && EhCookieStore.hasSignedIn()) {
if (Settings.hasSignedIn.value) {
collectScope.launchIO {
runCatching {
EhEngine.getUConfig()
@@ -12,7 +12,7 @@ import io.ktor.http.renderSetCookieHeader
object EhCookieStore : CookiesStorage {
private val manager = CookieManager.getInstance()
fun signOut() = manager.removeAllCookies(null)
fun removeAllCookies() = manager.removeAllCookies(null)
fun contains(url: String, name: String) = load(Url(url)).fastAny { it.name == name }
fun hasSignedIn(): Boolean {
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.client
import arrow.core.Either
import arrow.core.left
import arrow.core.partially2
import arrow.core.right
import arrow.fx.coroutines.parMap
import arrow.fx.coroutines.parZip
@@ -47,6 +48,7 @@ import com.hippo.ehviewer.client.parser.RateGalleryResult
import com.hippo.ehviewer.client.parser.SignInParser
import com.hippo.ehviewer.client.parser.TorrentParser
import com.hippo.ehviewer.client.parser.TorrentResult
import com.hippo.ehviewer.client.parser.UserConfigParser
import com.hippo.ehviewer.client.parser.VoteCommentResult
import com.hippo.ehviewer.client.parser.VoteTagParser
import com.hippo.ehviewer.dailycheck.showEventNotification
@@ -61,9 +63,9 @@ 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 kotlin.system.measureTimeMillis
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.SerializationException
@@ -72,6 +74,8 @@ import kotlinx.serialization.json.addJsonArray
import kotlinx.serialization.json.put
import moe.tarsin.coroutines.removeAllSuspend
import moe.tarsin.coroutines.runSuspendCatching
import okio.buffer
import okio.sink
import org.jsoup.Jsoup
import splitties.init.appCtx
@@ -80,16 +84,14 @@ private const val MAX_REQUEST_SIZE = 25
private const val MAX_SEQUENTIAL_REQUESTS = 5
private const val REQUEST_INTERVAL = 5000L
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) }
val file = File(dir, ReadableTime.getFilenamableTime() + ".txt")
file.sink().buffer().use { sink ->
with(sink) {
writeUtf8(e.message + "\n")
onLeft { writeUtf8(it) }
onRight { write(it) }
}
}
}
@@ -101,7 +103,7 @@ fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): N
// Check sad panda (without panda)
val empty = body.fold(
{ it.isEmpty() },
{ it.limit() == 0 },
{ !it.hasRemaining() },
)
if (empty) {
if (EhUtils.isExHentai) {
@@ -144,7 +146,7 @@ fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): N
val httpContentPool = DirectByteBufferPool(8, 0x80000)
suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend String.() -> T) = execute { response ->
suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend String.() -> T) = executeSafely { response ->
val body = response.bodyAsUtf8Text()
runSuspendCatching {
block(body)
@@ -153,20 +155,21 @@ suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend
}.getOrThrow()
}
suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = execute { response ->
suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = executeSafely { response ->
httpContentPool.useInstance { buffer ->
with(response.bodyAsChannel()) { while (!isClosedForRead) readAvailable(buffer) }
buffer.flip()
runSuspendCatching {
block(buffer)
}.onFailure {
buffer.rewind()
rethrowExactly(response.status.value, buffer.right(), it)
}.getOrThrow()
}
}
object EhEngine {
suspend fun getOriginalImageUrl(url: String, referer: String?) = noRedirectEhRequest(url, referer).execute { response ->
suspend fun getOriginalImageUrl(url: String, referer: String?) = noRedirectEhRequest(url, referer).executeSafely { response ->
val location = response.headers["Location"] ?: throw InsufficientFundsException()
location.takeIf { "bounce_login" !in it } ?: throw NotLoggedInException()
}
@@ -176,16 +179,20 @@ object EhEngine {
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
}
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 getArchiveList(url: String, gid: Long, token: String): ArchiveParser.Result {
val funds = if (EhUtils.isExHentai) getFunds() else null
return ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
.fetchUsingAsText(ArchiveParser::parse.partially2(funds))
}
suspend fun getImageLimits() = parZip(
{ ehRequest(EhUrl.URL_HOME).fetchUsingAsByteBuffer(HomeParser::parse) },
{ ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) },
{ getFunds() },
{ limits, funds -> HomeParser.Result(limits, funds) },
)
private suspend fun getFunds() = ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds)
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
.fetchUsingAsText { if (parse) EventPaneParser.parse(this) else null }
@@ -196,12 +203,12 @@ object EhEngine {
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
runSuspendCatching {
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
ehRequest(url).fetchUsingAsByteBuffer(UserConfigParser::parse)
}.onFailure { throwable ->
// It may get redirected when accessing ex for the first time
if (url == EhUrl.URL_UCONFIG_EX) {
logcat(throwable)
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
ehRequest(url).fetchUsingAsByteBuffer(UserConfigParser::parse)
} else {
throw throwable
}
@@ -258,7 +265,7 @@ object EhEngine {
append("edit_comment", id.toString())
}
}
}.execute { response ->
}.executeSafely { 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
@@ -287,7 +294,7 @@ object EhEngine {
append("apply", "Apply Changes")
append("update", "1")
}
}.execute { }
}.executeSafely { }
}
suspend fun downloadArchive(gid: Long, token: String, or: String, res: String, isHAtH: Boolean): String? {
@@ -446,13 +453,13 @@ object EhEngine {
}
suspend fun addFavorites(galleryList: List<Pair<Long, String>>, dstCat: Int) {
galleryList.chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
if (index != 0) {
delay(REQUEST_INTERVAL)
}
chunk.parMap { (gid, token) ->
galleryList.forEach { (gid, token) ->
// https://github.com/FooIbar/EhViewer/issues/1190
// Workaround for duplicate items when sorting by favorited time
val timeTaken = measureTimeMillis {
modifyFavorites(gid, token, dstCat)
}
delay(1000 - timeTaken)
}
}
}
@@ -15,19 +15,18 @@
*/
package com.hippo.ehviewer.client
import com.hippo.ehviewer.BuildConfig
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.util.bodyAsUtf8Text
import 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.HttpResponse
import io.ktor.client.statement.HttpStatement
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
@@ -35,6 +34,7 @@ import io.ktor.http.HttpMethod
import io.ktor.http.ParametersBuilder
import io.ktor.http.content.TextContent
import io.ktor.http.userAgent
import kotlinx.coroutines.cancel
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArrayBuilder
import kotlinx.serialization.json.JsonObjectBuilder
@@ -45,13 +45,13 @@ import okio.BufferedSource
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = execute { it.bodyAsUtf8Text().parseAs<T>() }
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely { it.bodyAsUtf8Text().parseAs<T>() }
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
val json = Json { ignoreUnknownKeys = true }
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36"
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
@@ -85,6 +85,16 @@ suspend inline fun noRedirectEhRequest(
apply(builder)
}.also { logcat("EhRequest") { url } }
suspend inline fun <T> HttpStatement.executeSafely(
crossinline block: suspend (response: HttpResponse) -> T,
) = execute { resp ->
try {
block(resp)
} finally {
resp.coroutineContext.cancel()
}
}
inline fun HttpRequestBuilder.formBody(builder: ParametersBuilder.() -> Unit) {
method = HttpMethod.Post
val parameters = ParametersBuilder().apply(builder).build()
@@ -96,8 +106,3 @@ inline fun HttpRequestBuilder.jsonBody(builder: JsonObjectBuilder.() -> Unit) {
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)))
}
@@ -152,7 +152,7 @@ object EhTagDatabase : CoroutineScope {
private suspend fun save(client: HttpClient, url: String, file: File) {
runCatching {
client.prepareGet(url).execute {
client.prepareGet(url).executeSafely {
if (it.status.isSuccess()) {
it.bodyAsChannel().copyTo(file)
} else {
@@ -25,7 +25,6 @@ import androidx.core.graphics.ColorUtils
import arrow.core.memoize
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.GalleryInfo
import java.util.regex.Pattern
import kotlin.math.abs
object EhUtils {
@@ -58,14 +57,14 @@ object EhUtils {
private const val BG_COLOR_UNKNOWN = 0xff000000u
// Remove [XXX], (XXX), {XXX}, ~XXX~ stuff
private val PATTERN_TITLE_PREFIX = Pattern.compile(
"^(?:\\([^)]*\\)|\\[[^]]*]|\\{[^}]*\\}|~[^~]*~|\\s+)*",
private val PATTERN_TITLE_PREFIX = Regex(
"""^(?:\([^)]*\)|\[[^]]*]|\{[^}]*\}|~[^~]*~|\s+)*""",
)
// Remove [XXX], (XXX), {XXX}, ~XXX~ stuff and something like ch. 1-23
private val PATTERN_TITLE_SUFFIX = Pattern.compile(
"(?:\\s+ch.[\\s\\d-]+)?(?:\\([^)]*\\)|\\[[^]]*]|\\{[^}]*\\}|~[^~]*~|\\s+)*$",
Pattern.CASE_INSENSITIVE,
private val PATTERN_TITLE_SUFFIX = Regex(
"""(?:\s+ch.[\s\d-]+)?(?:\([^)]*\)|\[[^]]*]|\{[^}]*\}|~[^~]*~|\s+)*$""",
RegexOption.IGNORE_CASE,
)
private val CATEGORY_VALUES = hashMapOf(
@@ -153,9 +152,9 @@ object EhUtils {
val categoryTextColor = Color(0xffe6e0e9)
fun signOut() {
EhCookieStore.signOut()
Settings.avatar = null
Settings.displayName = null
EhCookieStore.removeAllCookies()
Settings.displayName.value = null
Settings.hasSignedIn.value = false
Settings.gallerySite = EhUrl.SITE_E
Settings.needSignIn = true
}
@@ -170,16 +169,12 @@ object EhUtils {
fun extractTitle(fullTitle: String?): String? {
var title: String = fullTitle ?: return null
title = PATTERN_TITLE_PREFIX.matcher(title).replaceFirst("")
title = PATTERN_TITLE_SUFFIX.matcher(title).replaceFirst("")
title = PATTERN_TITLE_PREFIX.replaceFirst(title, "")
title = PATTERN_TITLE_SUFFIX.replaceFirst(title, "")
// Sometimes title is combined by romaji and english translation.
// Only need romaji.
// TODO But not sure every '|' means that
val index = title.indexOf('|')
if (index >= 0) {
title = title.substring(0, index)
}
return title.ifEmpty { null }
return title.substringBeforeLast('|').trim().ifEmpty { null }
}
fun handleThumbUrlResolution(url: String?): String? {
@@ -15,7 +15,6 @@
*/
package com.hippo.ehviewer.client.data
import java.util.regex.Pattern
import moe.tarsin.kt.unreachable
interface GalleryInfo {
@@ -62,36 +61,36 @@ interface GalleryInfo {
"NL",
)
private val S_LANG_PATTERNS = arrayOf(
Pattern.compile(
Regex(
"[(\\[]eng(?:lish)?[)\\]]|英訳",
Pattern.CASE_INSENSITIVE,
RegexOption.IGNORE_CASE,
),
// [((\[]ch(?:inese)?[))\]]|[汉漢]化|中[国國][语語]|中文|中国翻訳
Pattern.compile(
Regex(
"[(\uFF08\\[]ch(?:inese)?[)\uFF09\\]]|[汉漢]化|中[国國][语語]|中文|中国翻訳",
Pattern.CASE_INSENSITIVE,
RegexOption.IGNORE_CASE,
),
Pattern.compile(
Regex(
"[(\\[]spanish[)\\]]|[(\\[]Español[)\\]]|スペイン翻訳",
Pattern.CASE_INSENSITIVE,
RegexOption.IGNORE_CASE,
),
Pattern.compile("[(\\[]korean?[)\\]]|韓国翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]rus(?:sian)?[)\\]]|ロシア翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]fr(?:ench)?[)\\]]|フランス翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]portuguese|ポルトガル翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile(
Regex("[(\\[]korean?[)\\]]|韓国翻訳", RegexOption.IGNORE_CASE),
Regex("[(\\[]rus(?:sian)?[)\\]]|ロシア翻訳", RegexOption.IGNORE_CASE),
Regex("[(\\[]fr(?:ench)?[)\\]]|フランス翻訳", RegexOption.IGNORE_CASE),
Regex("[(\\[]portuguese|ポルトガル翻訳", RegexOption.IGNORE_CASE),
Regex(
"[(\\[]thai(?: ภาษาไทย)?[)\\]]|แปลไทย|タイ翻訳",
Pattern.CASE_INSENSITIVE,
RegexOption.IGNORE_CASE,
),
Pattern.compile("[(\\[]german[)\\]]|ドイツ翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]italiano?[)\\]]|イタリア翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile(
Regex("[(\\[]german[)\\]]|ドイツ翻訳", RegexOption.IGNORE_CASE),
Regex("[(\\[]italiano?[)\\]]|イタリア翻訳", RegexOption.IGNORE_CASE),
Regex(
"[(\\[]vietnamese(?: Tiếng Việt)?[)\\]]|ベトナム翻訳",
Pattern.CASE_INSENSITIVE,
RegexOption.IGNORE_CASE,
),
Pattern.compile("[(\\[]polish[)\\]]|ポーランド翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]hun(?:garian)?[)\\]]|ハンガリー翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]dutch[)\\]]|オランダ翻訳", Pattern.CASE_INSENSITIVE),
Regex("[(\\[]polish[)\\]]|ポーランド翻訳", RegexOption.IGNORE_CASE),
Regex("[(\\[]hun(?:garian)?[)\\]]|ハンガリー翻訳", RegexOption.IGNORE_CASE),
Regex("[(\\[]dutch[)\\]]|オランダ翻訳", RegexOption.IGNORE_CASE),
)
val S_LANG_TAGS = arrayOf(
"language:english",
@@ -123,7 +122,7 @@ interface GalleryInfo {
private fun generateSLangFromTitle(title: String): String? {
for (i in S_LANGS.indices) {
if (S_LANG_PATTERNS[i].matcher(title).find()) {
if (S_LANG_PATTERNS[i].containsMatchIn(title)) {
return S_LANGS[i]
}
}
@@ -262,6 +262,7 @@ data class ListUrlBuilder(
fun build(): String {
return when (mode) {
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
val category = if (!Settings.hasSignedIn.value && category <= 0) EhUtils.NON_H else category
if (category > 0) {
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
}
@@ -34,7 +34,7 @@ object ArchiveParser {
private const val ERROR_INSUFFICIENT_FUNDS =
"You do not have enough funds to download this archive."
fun parse(body: String): Result {
fun parse(body: String, maybeFunds: Funds?): Result {
val m = PATTERN_HATH_FORM.find(body)!!
val paramOr = m.groupValues[1]
val archiveList = ArrayList<Archive>()
@@ -59,14 +59,12 @@ object ArchiveParser {
val item = Archive(res, name, size, cost, true)
archiveList.add(item)
}
val result = Result(paramOr, archiveList, null)
PATTERN_CURRENT_FUNDS.find(body)?.groupValues?.run {
val funds = maybeFunds ?: PATTERN_CURRENT_FUNDS.find(body)!!.groupValues.run {
val fundsGP = ParserUtils.parseInt(get(1), 0)
val fundsC = ParserUtils.parseInt(get(2), 0)
val funds = HomeParser.Funds(fundsGP, fundsC)
result.funds = funds
Funds("%,d".format(fundsGP), "%,d".format(fundsC))
}
return result
return Result(paramOr, archiveList, funds)
}
fun parseArchiveUrl(body: String): String? {
@@ -80,13 +78,13 @@ object ArchiveParser {
// TODO: Check more errors
}
class Archive(
val res: String,
val name: String,
val size: String,
val cost: String,
val isHAtH: Boolean,
)
class Result(val paramOr: String, val archiveList: List<Archive>, var funds: HomeParser.Funds?)
data class Result(val paramOr: String, val archiveList: List<Archive>, val funds: Funds)
}
class Archive(
val res: String,
val name: String,
val size: String,
val cost: String,
val isHAtH: Boolean,
)
@@ -16,27 +16,21 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.util.toLongOrDefault
import java.util.regex.Pattern
/**
* Like http://exhentai.org/g/1234567/a1b2c3d4e5<br></br>
*/
object GalleryDetailUrlParser {
private val URL_STRICT_PATTERN = Pattern.compile(
private val URL_STRICT_PATTERN = Regex(
"https?://(?:${EhUrl.DOMAIN_EX}|${EhUrl.DOMAIN_E}(?:/lofi)?)/(?:g|mpv)/(\\d+)/([0-9a-f]{10})",
)
private val URL_PATTERN = Pattern.compile("(\\d+)/([0-9a-f]{10})(?:[^0-9a-f]|$)")
private val URL_PATTERN = Regex("(\\d+)/([0-9a-f]{10})(?:[^0-9a-f]|$)")
fun parse(url: String?, strict: Boolean = true): Result? {
url ?: return null
val pattern = if (strict) URL_STRICT_PATTERN else URL_PATTERN
val m = pattern.matcher(url)
return if (m.find()) {
val gid = m.group(1)!!.toLongOrDefault(0).takeIf { it > 0 }
gid?.let { Result(it, m.group(2)!!) }
} else {
null
return pattern.find(url)?.destructured?.let { (gid, token) ->
Result(gid.toLong(), token)
}
}
@@ -16,34 +16,21 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.util.toIntOrDefault
import com.hippo.ehviewer.util.toLongOrDefault
import java.util.regex.Pattern
/**
* Like http://exhentai.org/s/91ea4b6d89/901103-12
*/
object GalleryPageUrlParser {
private val URL_STRICT_PATTERN = Pattern.compile(
private val URL_STRICT_PATTERN = Regex(
"https?://(?:${EhUrl.DOMAIN_EX}|${EhUrl.DOMAIN_E}(?:/lofi)?)/s/([0-9a-f]{10})/(\\d+)-(\\d+)",
)
private val URL_PATTERN = Pattern.compile("([0-9a-f]{10})/(\\d+)-(\\d+)")
private val URL_PATTERN = Regex("([0-9a-f]{10})/(\\d+)-(\\d+)")
fun parse(url: String?, strict: Boolean = true): Result? {
url ?: return null
val pattern = if (strict) URL_STRICT_PATTERN else URL_PATTERN
val m = pattern.matcher(url)
return if (m.find()) {
val gid = m.group(2)!!.toLongOrDefault(-1L)
val pToken = m.group(1)!!
val page = m.group(3)!!.toIntOrDefault(0) - 1
if (gid < 0 || page < 0) {
null
} else {
Result(gid, pToken, page)
}
} else {
null
return pattern.find(url)?.destructured?.let { (pToken, gid, page) ->
Result(gid.toLong(), pToken, page.toInt() - 1)
}
}
@@ -25,18 +25,18 @@ object HomeParser {
PATTERN_FUNDS.find(body)?.groupValues?.run {
val fundsC = ParserUtils.parseInt(get(1), 0)
val fundsGP = ParserUtils.parseInt(get(2), 0) * 1000
return Funds(fundsGP, fundsC)
return Funds("%,d+".format(fundsGP), "%,d".format(fundsC))
}
throw ParseException("Parse funds error")
}
@Parcelize
data class Funds(val fundsGP: Int, val fundsC: Int) : Parcelable
@Parcelize
data class Result(val limits: Limits, val funds: Funds) : Parcelable
}
@Parcelize
data class Funds(val gp: String, val credit: String) : Parcelable
@Parcelize
@Serializable
data class Limits(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
@@ -15,6 +15,7 @@
*/
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.util.toEpochMillis
import com.hippo.ehviewer.util.toIntOrDefault
import com.hippo.ehviewer.util.toLocalDateTime
import com.hippo.ehviewer.util.toLongOrDefault
@@ -36,11 +37,14 @@ object ParserUtils {
minute()
}
@Synchronized
fun formatDate(time: Long): String {
return formatter.format(time.toLocalDateTime())
}
fun parseDate(str: String): Long {
return formatter.parse(str).toEpochMillis()
}
fun trim(str: String?): String {
return str?.unescapeXml()?.trim() ?: ""
}
@@ -17,26 +17,19 @@ package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.ParseException
import java.util.regex.Pattern
object SignInParser {
private val NAME_PATTERN = Pattern.compile("<p>You are now logged in as: (.+?)<")
private val ERROR_PATTERN = Pattern.compile(
private val NAME_PATTERN = Regex("<p>You are now logged in as: (.+?)<")
private val ERROR_PATTERN = Regex(
"<h4>The error returned was:</h4>\\s*<p>(.+?)</p>" +
"|<span class=\"postcolor\">(.+?)</span>",
)
fun parse(body: String): String {
var m = NAME_PATTERN.matcher(body)
return if (m.find()) {
m.group(1)!!
} else {
m = ERROR_PATTERN.matcher(body)
if (m.find()) {
throw EhException(m.group(1) ?: m.group(2))
} else {
throw ParseException("Can't parse sign in")
}
}
return NAME_PATTERN.find(body)?.let {
it.groupValues[1]
} ?: ERROR_PATTERN.find(body)?.let {
throw EhException(it.groupValues[1].ifEmpty { it.groupValues[2] })
} ?: throw ParseException("Can't parse sign in")
}
}
@@ -14,16 +14,18 @@ object TorrentParser {
@Serializable
data class Torrent(
val outdated: Boolean,
val posted: String,
val size: String,
val seeds: Int,
val peers: Int,
val downloads: Int,
val uploader: String,
val url: String,
val name: String,
)
fun Torrent.format() = "[$posted] $name [$size] [↑$seeds ↓$peers ✓$downloads]"
fun Torrent.format() = "↑$seeds ↓$peers ✓$downloads"
typealias TorrentResult = List<Torrent>
@@ -0,0 +1,16 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
object UserConfigParser {
private external fun parseFavCat(body: ByteBuffer, size: Int = body.limit()): Int
fun parse(body: ByteBuffer) = runCatching {
val favCat = unmarshalParsingAs<Array<String>>(body, ::parseFavCat)
Settings.favCat = favCat
}.onFailure {
throw ParseException("Can't parse user config", it)
}
}
@@ -17,7 +17,7 @@ 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 const val RATIO_THRESHOLD = 2
private val maybeCropBorderKey = Extras.Key(default = false)
@@ -48,7 +48,12 @@ object CropBorderInterceptor : Interceptor {
val image = result.image
if (image is BitmapImage) {
val bitmap = image.bitmap
val rect = if (image.height / image.width < WEBTOON_THRESHOLD) {
val ratio = if (image.height > image.width) {
image.height / image.width
} else {
image.width / image.height
}
val rect = if (ratio < RATIO_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
@@ -1,7 +1,6 @@
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
@@ -30,24 +29,24 @@ object DownloadThumbInterceptor : Interceptor {
}
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 result = chain.withRequest(new).proceed()
if (result is SuccessResult) return result
}
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
}
val result = chain.withRequest(new).proceed()
if (result is SuccessResult) {
withIOContext {
// Accessing the recreated file immediately after deleting it throws
// FileNotFoundException, so we just overwrite the existing file.
if (thumb.ensureFile()) {
val key = requireNotNull(chain.request.memoryCacheKey)
imageCache.read(key) {
data.asUniFile() sendTo thumb
}
}
}
}
return result
}
}
return chain.proceed()
@@ -21,7 +21,6 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.hippo.ehviewer.R
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
@@ -83,7 +82,7 @@ val today
get() = (Clock.System.now().epochSeconds / 86400).toInt()
suspend fun checkDawn() = runCatching {
if (EhCookieStore.hasSignedIn() && Settings.lastDawnDays != today) {
if (Settings.hasSignedIn.value && Settings.lastDawnDays != today) {
EhEngine.getNews(true)?.let {
Settings.lastDawnDays = today
showEventNotification(it)
@@ -8,11 +8,11 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
@Database(
entities = [
BaseGalleryInfo::class, DownloadLabel::class, DownloadEntity::class, DownloadDirname::class,
BaseGalleryInfo::class, DownloadLabel::class, DownloadEntity::class, DownloadDirname::class, DownloadArtist::class,
Filter::class, HistoryInfo::class, LocalFavoriteInfo::class, ProgressInfo::class, QuickSearch::class,
GalleryEntityFts::class,
],
version = 22,
version = 23,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 4, to = 5, spec = Schema4to5::class),
@@ -28,16 +28,17 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
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),
AutoMigration(from = 22, to = 23),
],
)
@TypeConverters(FilterModeConverter::class)
abstract class EhDatabase : RoomDatabase() {
abstract fun galleryDao(): GalleryDao
abstract fun downloadArtistDao(): DownloadArtistDao
abstract fun downloadDirnameDao(): DownloadDirnameDao
abstract fun downloadLabelDao(): DownloadLabelDao
abstract fun downloadsDao(): DownloadsDao
@@ -5,6 +5,7 @@ import androidx.room.DeleteTable
import androidx.room.RenameColumn
import androidx.room.RenameTable
import androidx.room.migration.AutoMigrationSpec
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
@@ -103,6 +104,15 @@ class Schema12to13 : AutoMigrationSpec {
@DeleteColumn(tableName = "DOWNLOADS", columnName = "SIMPLE_LANGUAGE")
class Schema13to14 : AutoMigrationSpec
class Schema17to18 : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `_new_GALLERIES` (`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`))")
db.execSQL("INSERT INTO `_new_GALLERIES` (`GID`,`TOKEN`,`TITLE`,`TITLE_JPN`,`THUMB`,`CATEGORY`,`POSTED`,`UPLOADER`,`RATING`,`SIMPLE_LANGUAGE`,`FAVORITE_SLOT`) SELECT `GID`,`TOKEN`,`TITLE`,`TITLE_JPN`,`THUMB`,`CATEGORY`,`POSTED`,`UPLOADER`,`RATING`,`SIMPLE_LANGUAGE`,`FAVORITE_SLOT` FROM `GALLERIES` WHERE `TOKEN` IS NOT NULL")
db.execSQL("DROP TABLE `GALLERIES`")
db.execSQL("ALTER TABLE `_new_GALLERIES` RENAME TO `GALLERIES`")
}
}
@RenameColumn(tableName = "DOWNLOADS", fromColumnName = "POSITION", toColumnName = "TIME")
class Schema18to19 : AutoMigrationSpec
@@ -0,0 +1,24 @@
package com.hippo.ehviewer.dao
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(
tableName = "DOWNLOAD_ARTISTS",
primaryKeys = ["GID", "ARTIST"],
foreignKeys = [
ForeignKey(DownloadEntity::class, ["GID"], ["GID"], onDelete = ForeignKey.CASCADE),
],
)
data class DownloadArtist(
@ColumnInfo(name = "GID")
val gid: Long,
@ColumnInfo(name = "ARTIST")
val artist: String,
) {
companion object {
fun from(gid: Long, artists: Iterable<String>) = artists.map { DownloadArtist(gid, it) }
}
}
@@ -0,0 +1,14 @@
package com.hippo.ehviewer.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface DownloadArtistDao {
@Insert
suspend fun insert(downloadArtists: List<DownloadArtist>)
@Query("DELETE FROM DOWNLOAD_ARTISTS WHERE GID = :gid")
suspend fun deleteByGid(gid: Long)
}
@@ -67,9 +67,13 @@ data class DownloadInfo(
@ColumnInfo(name = "DIRNAME")
val dirname: String?,
@Relation(parentColumn = "GID", entityColumn = "GID")
var artistInfoList: List<DownloadArtist> = emptyList(),
@Embedded
val downloadInfo: DownloadEntity = DownloadEntity(galleryInfo.gid),
) : GalleryInfo by galleryInfo, AbstractDownloadInfo by downloadInfo {
companion object {
const val STATE_INVALID = -1
const val STATE_NONE = 0
@@ -79,3 +83,6 @@ data class DownloadInfo(
const val STATE_FAILED = 4
}
}
val DownloadInfo.artists: List<String>
get() = artistInfoList.map { it.artist }
@@ -14,7 +14,11 @@ import kotlinx.coroutines.flow.Flow
interface DownloadsDao {
@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>>
fun countByLabel(): Flow<Map<@MapColumn("LABEL") String?, @MapColumn("COUNT") Int>>
@Suppress("ktlint:standard:annotation")
@Query("SELECT ARTIST, COUNT(*) AS COUNT FROM DOWNLOADS LEFT JOIN DOWNLOAD_ARTISTS USING(GID) GROUP BY ARTIST ORDER BY COUNT DESC, ARTIST")
fun countByArtist(): Flow<Map<@MapColumn("ARTIST") String?, @MapColumn("COUNT") Int>>
@Query("SELECT * FROM DOWNLOADS ORDER BY TIME")
suspend fun list(): List<DownloadEntity>
@@ -12,11 +12,11 @@ interface SearchDao {
@Query("DELETE FROM suggestions WHERE `query` = :query")
suspend fun deleteQuery(query: String)
@Query("SELECT * FROM suggestions WHERE `query` LIKE :prefix || '%' ORDER BY date DESC LIMIT :limit")
suspend fun rawSuggestions(prefix: String, limit: Int): Array<Search>
@Query("SELECT DISTINCT `query` FROM suggestions WHERE `query` LIKE :prefix || '%' ORDER BY date DESC LIMIT :limit")
suspend fun rawSuggestions(prefix: String, limit: Int): List<String>
@Query("SELECT * FROM suggestions ORDER BY date DESC LIMIT :limit")
suspend fun list(limit: Int): Array<Search>
@Query("SELECT DISTINCT `query` FROM suggestions ORDER BY date DESC LIMIT :limit")
suspend fun list(limit: Int): List<String>
@Insert
suspend fun insert(search: Search)
@@ -30,6 +30,7 @@ import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.dao.DownloadArtist
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.DownloadLabel
import com.hippo.ehviewer.image.Image
@@ -94,12 +95,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
if (label == null) {
return false
}
for ((label1) in labelList) {
if (label == label1) {
return true
}
}
return false
return labelList.any { it.label == label }
}
fun containDownloadInfo(gid: Long) = mAllInfoMap.containsKey(gid)
@@ -179,7 +175,6 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
info = DownloadInfo(galleryInfo, galleryInfo.putToDownloadDir())
info.label = label
info.state = DownloadInfo.STATE_WAIT
// Add to all download list and map
allInfoList.insertWith(info, comparator())
mAllInfoMap[galleryInfo.gid] = info
@@ -271,6 +266,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
// Save to
EhDB.putDownloadInfo(info)
EhDB.putDownloadArtist(info.gid, info.artistInfoList)
}
}
@@ -518,12 +514,14 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
suspend fun deleteLabel(label: String) {
labelList.run {
with(labelList) {
val index = indexOfFirst { it.label == label }
val item = get(index)
EhDB.removeDownloadLabel(item)
subList(index + 1, size).forEach {
it.position--
}
EhDB.removeDownloadLabel(removeAt(index))
removeAt(index)
}
allInfoList.forEach {
if (it.label == label) {
@@ -533,26 +531,63 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
suspend fun readMetadataFromLocal() {
val list = allInfoList.filter { it.pages == 0 || it.simpleTags == null }.parMapNotNull(concurrency = 5) { info ->
val list = allInfoList.mapNotNull {
val updateGallery = it.pages == 0 || it.simpleTags == null
val updateArtist = it.artistInfoList.isEmpty()
if (updateGallery || updateArtist) {
Triple(it, updateGallery, updateArtist)
} else {
null
}
}.parMapNotNull(concurrency = 5) { (info, updateGallery, updateArtist) ->
info.downloadDir?.run {
val comicInfo = findFile(COMIC_INFO_FILE)?.let { readComicInfo(it) }
if (comicInfo != null) {
info.pages = comicInfo.pageCount
info.simpleTags = comicInfo.toSimpleTags()
info.galleryInfo
val galleryInfo = if (updateGallery) {
info.pages = comicInfo.pageCount
info.simpleTags = comicInfo.toSimpleTags()
info.galleryInfo
} else {
null
}
val artistList = if (updateArtist && comicInfo.penciller != null) {
info.artistInfoList = DownloadArtist.from(info.gid, comicInfo.penciller)
info.gid to info.artistInfoList
} else {
null
}
if (galleryInfo != null || artistList != null) {
galleryInfo to artistList
} else {
null
}
} else if (info.pages == 0) {
findFile(SPIDER_INFO_FILENAME)?.let {
val galleryInfo = findFile(SPIDER_INFO_FILENAME)?.let {
readCompatFromUniFile(it)?.run {
info.pages = pages
info.galleryInfo
}
}
galleryInfo?.let { it to null }
} else {
null
}
}
}
EhDB.updateGalleryInfo(list)
val galleryInfoList = mutableListOf<BaseGalleryInfo>()
list.forEach { (info, artists) ->
info?.let {
galleryInfoList.add(it)
}
artists?.let { (gid, updateList) ->
EhDB.putDownloadArtist(gid, updateList)
}
}
if (galleryInfoList.isNotEmpty()) EhDB.updateGalleryInfo(galleryInfoList)
}
val isIdle: Boolean
@@ -686,6 +721,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
private val mSpeedReminder = object {
private val mContentLengthMap = SparseLongArray()
private val mReceivedSizeMap = SparseLongArray()
private val mutex = Mutex()
private var mBytesRead = 0L
private var oldSpeed = -1L
private var currentJob: Job? = null
@@ -699,29 +735,37 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
}
fun stop() {
suspend fun stop() {
mBytesRead = 0
oldSpeed = -1
mContentLengthMap.clear()
mReceivedSizeMap.clear()
mutex.withLock {
mContentLengthMap.clear()
mReceivedSizeMap.clear()
}
currentJob?.cancel()
currentJob = null
}
fun onDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) {
mContentLengthMap.put(index, contentLength)
mReceivedSizeMap.put(index, receivedSize)
suspend fun onDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) {
mutex.withLock {
mContentLengthMap.put(index, contentLength)
mReceivedSizeMap.put(index, receivedSize)
}
mBytesRead += bytesRead.toLong()
}
fun onDone(index: Int) {
mContentLengthMap.delete(index)
mReceivedSizeMap.delete(index)
suspend fun onDone(index: Int) {
mutex.withLock {
mContentLengthMap.delete(index)
mReceivedSizeMap.delete(index)
}
}
fun onFinish() {
mContentLengthMap.clear()
mReceivedSizeMap.clear()
suspend fun onFinish() {
mutex.withLock {
mContentLengthMap.clear()
mReceivedSizeMap.clear()
}
}
private suspend fun runInternal() {
@@ -742,12 +786,14 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
var downloadingCount = 0
var downloadingContentLengthSum = 0L
var totalSize = 0L
for (i in 0..<mContentLengthMap.size()) {
val contentLength = mContentLengthMap.valueAt(i)
val receivedSize = mReceivedSizeMap.valueAt(i)
downloadingCount++
downloadingContentLengthSum += contentLength
totalSize += contentLength - receivedSize
mutex.withLock {
for (i in 0..<mContentLengthMap.size()) {
val contentLength = mContentLengthMap.valueAt(i)
val receivedSize = mReceivedSizeMap.valueAt(i)
downloadingCount++
downloadingContentLengthSum += contentLength
totalSize += contentLength - receivedSize
}
}
if (downloadingCount != 0) {
totalSize += downloadingContentLengthSum * (info.total - info.downloaded - downloadingCount) / downloadingCount
@@ -789,5 +835,5 @@ var downloadLocation: UniFile
}
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation / it }
val DownloadInfo.archiveFile get() = downloadDir?.findFile("$gid.cbz")
val DownloadInfo.archiveFile get() = downloadDir?.run { findFile("$gid.cbz") ?: findFile("$gid.zip") }
val GalleryInfo.tempDownloadDir get() = AppConfig.getTempDir("$gid")
@@ -34,7 +34,6 @@ import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.screen.DownloadsFragment
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.SimpleHandler
@@ -148,9 +147,9 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
ACTION_CLEAR -> {
clear()
checkStopSelf()
}
}
checkStopSelf()
}
override fun onBind(intent: Intent) = error("No bindService")
@@ -187,7 +186,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
clearIntent.action = ACTION_CLEAR
val piClear = PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_IMMUTABLE)
val bundle = Bundle()
bundle.putString(DownloadsFragment.KEY_ACTION, DownloadsFragment.ACTION_CLEAR_DOWNLOAD_SERVICE)
bundle.putString(KEY_ACTION, ACTION_CLEAR_DOWNLOAD_SERVICE)
val activityIntent = Intent(this, MainActivity::class.java)
activityIntent.action = ACTION_START_DOWNLOADSCENE
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
@@ -243,7 +242,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
}
ensureDownloadingBuilder()
val bundle = Bundle()
bundle.putLong(DownloadsFragment.KEY_GID, info.gid)
bundle.putLong(KEY_GID, info.gid)
val activityIntent = Intent(this, MainActivity::class.java)
activityIntent.action = ACTION_START_DOWNLOADSCENE
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
@@ -540,6 +539,8 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
const val KEY_LABEL = "label"
const val KEY_GID = "gid"
const val KEY_GID_LIST = "gid_list"
const val KEY_ACTION = "action"
const val ACTION_CLEAR_DOWNLOAD_SERVICE = "clear_download_service"
private const val ID_DOWNLOADING = 1
private const val ID_DOWNLOADED = 2
private const val ID_509 = 3
@@ -0,0 +1,26 @@
package com.hippo.ehviewer.download
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.artists
enum class DownloadsFilterMode(
val flag: Int,
val take: (info: DownloadInfo, label: String?) -> Boolean,
) {
CUSTOM(flag = 0, take = { info, label ->
label == "" || info.label == label
}),
ARTIST(flag = 1, take = { info, label ->
if (null == label) {
info.artistInfoList.isEmpty()
} else {
label == "" || info.artists.contains(label)
}
}),
;
companion object {
val Default = ARTIST
fun from(flag: Int) = DownloadsFilterMode.entries[flag]
}
}
@@ -3,9 +3,9 @@ package com.hippo.ehviewer.download
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.dao.DownloadInfo
data class SortMode(val field: Field, val order: Order) {
data class SortMode(val field: Field, val order: Order, val groupByDownloadLabel: Boolean = false) {
val flag
get() = field.value shl 1 or order.value
get() = groupByDownloadLabel.compareTo(false) shl 4 or (field.value shl 1) or order.value
enum class Field(val value: Int) {
GID(0),
@@ -21,22 +21,28 @@ data class SortMode(val field: Field, val order: Order) {
}
fun comparator(): Comparator<DownloadInfo> {
val comparator = when (field) {
var comparator = when (field) {
Field.GID -> compareBy { it.gid }
Field.TITLE -> compareBy { EhUtils.getSuitableTitle(it) }
Field.TITLE, Field.LABEL -> compareBy { EhUtils.getSuitableTitle(it) }
Field.TIME -> compareBy { it.time }
Field.LABEL -> compareBy<DownloadInfo> { it.label }.thenBy { EhUtils.getSuitableTitle(it) }
Field.PAGES -> compareBy<DownloadInfo> { it.pages }.thenBy { EhUtils.getSuitableTitle(it) }
}
return if (order == Order.ASC) comparator else comparator.reversed()
if (order == Order.DESC) {
comparator = comparator.reversed()
}
if (groupByDownloadLabel) {
comparator = compareBy<DownloadInfo> { it.label }.then(comparator)
}
return comparator
}
companion object {
fun from(flag: Int): SortMode {
val field = flag shr 1
val order = flag and 1
val field = flag and 0xF shr 1
val order = flag and 0x1
val groupByDownloadLabel = flag shr 4 == 1
require(field in Field.entries.indices && order in Order.entries.indices)
return SortMode(Field.entries[field], Order.entries[order])
return SortMode(Field.entries[field], Order.entries[order], groupByDownloadLabel)
}
val Default = SortMode(Field.TIME, Order.DESC)
val All = listOf(
@@ -46,8 +52,6 @@ data class SortMode(val field: Field, val order: Order) {
SortMode(Field.GID, Order.ASC),
SortMode(Field.TITLE, Order.ASC),
SortMode(Field.TITLE, Order.DESC),
SortMode(Field.LABEL, Order.ASC),
SortMode(Field.LABEL, Order.DESC),
SortMode(Field.PAGES, Order.ASC),
SortMode(Field.PAGES, Order.DESC),
)
@@ -55,7 +55,7 @@ class ArchivePageLoader(
private val hostJob = launch(start = CoroutineStart.LAZY) {
logcat(DEBUG_TAG) { "Open archive ${file.uri.displayPath}" }
pfd = file.openFileDescriptor("r")
size = openArchive(pfd.fd, pfd.statSize, gid == 0L)
size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name!!.endsWith(".zip"))
if (size == 0) {
return@launch
}
@@ -22,7 +22,6 @@ import com.hippo.ehviewer.spider.SpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.obtainSpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.releaseSpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
import com.hippo.ehviewer.util.FileUtils
import com.hippo.unifile.UniFile
class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
@@ -43,11 +42,11 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
}
override val title by lazy {
FileUtils.sanitizeFilename(EhUtils.getSuitableTitle(mGalleryInfo))
EhUtils.getSuitableTitle(mGalleryInfo)
}
override fun getImageExtension(index: Int): String {
return mSpiderQueen.getExtension(index)!!
override fun getImageExtension(index: Int): String? {
return mSpiderQueen.getExtension(index)
}
override fun save(index: Int, file: UniFile): Boolean {
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.gallery
import androidx.annotation.CallSuper
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.util.FileUtils
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import kotlinx.coroutines.CoroutineScope
@@ -59,16 +60,15 @@ abstract class PageLoader2(private val gid: Long, var startPage: Int) : PageLoad
protected abstract val title: String
protected abstract fun getImageExtension(index: Int): String
protected abstract fun getImageExtension(index: Int): String?
fun getImageFilename(index: Int): String {
return "$title - ${index + 1}.${getImageExtension(index)}"
fun getImageFilename(index: Int): String? = getImageExtension(index)?.let {
FileUtils.sanitizeFilename("$title - ${index + 1}.${it.lowercase()}")
}
abstract fun save(index: Int, file: UniFile): Boolean
fun saveToDir(index: Int, dir: UniFile): UniFile? {
val filename = getImageFilename(index)
return (dir / filename).takeIf { save(index, it) }
fun saveToDir(index: Int, dir: UniFile): UniFile? = getImageFilename(index)?.let { filename ->
(dir / filename).takeIf { save(index, it) }
}
}
@@ -14,6 +14,8 @@ object EhIcons {
val Default = Filled
}
object Reader
val Default = Filled
}
@@ -1,7 +1,10 @@
package com.hippo.ehviewer.icons.filled
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
@@ -37,3 +40,10 @@ val EhIcons.Filled.Crop by unsafeLazy {
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconCropPreview() {
Image(imageVector = EhIcons.Default.Crop, contentDescription = null)
}
@@ -1,10 +1,14 @@
package com.hippo.ehviewer.icons.filled
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
@Suppress("BooleanLiteralArgument")
val EhIcons.Filled.CropOff by unsafeLazy {
materialIcon(name = "CropOff") {
materialPath {
@@ -58,3 +62,10 @@ val EhIcons.Filled.CropOff by unsafeLazy {
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconCropOffPreview() {
Image(imageVector = EhIcons.Default.CropOff, contentDescription = null)
}
@@ -1,7 +1,10 @@
package com.hippo.ehviewer.icons.filled
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
@@ -31,3 +34,10 @@ val EhIcons.Filled.GoTo by unsafeLazy {
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconGoToPreview() {
Image(imageVector = EhIcons.Default.GoTo, contentDescription = null)
}
@@ -1,7 +1,10 @@
package com.hippo.ehviewer.icons.filled
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
@@ -45,3 +48,10 @@ val EhIcons.Filled.SadPanda by unsafeLazy {
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconSadPandaPreview() {
Image(imageVector = EhIcons.Default.SadPanda, contentDescription = null)
}
@@ -1,7 +1,10 @@
package com.hippo.ehviewer.icons.filled
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
@@ -59,3 +62,10 @@ val EhIcons.Filled.Subscriptions by unsafeLazy {
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconSubscriptionsPreview() {
Image(imageVector = EhIcons.Default.Subscriptions, contentDescription = null)
}
@@ -0,0 +1,53 @@
package com.hippo.ehviewer.icons.reader
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
@Suppress("BooleanLiteralArgument")
val EhIcons.Reader.ContinuousVertical by unsafeLazy {
materialIcon(name = "ContinuousVertical") {
materialPath {
moveTo(17.3159F, 1.0F)
horizontalLineToRelative(-10.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, -2.0F, 2.0F)
lineTo(5.3159F, 21.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, 2.0F)
horizontalLineToRelative(10.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, -2.0F)
lineTo(19.3159F, 3.0F)
arcTo(2.0059F, 2.0059F, 0.0F, false, false, 17.3159F, 1.0F)
close()
moveTo(17.3159F, 21.0F)
horizontalLineToRelative(-10.0F)
lineTo(7.3159F, 3.0F)
horizontalLineToRelative(10.0F)
close()
moveTo(11.3083F, 5.0F)
horizontalLineToRelative(2.0F)
verticalLineToRelative(5.5F)
horizontalLineToRelative(-2.0F)
close()
moveTo(15.308F, 16.0F)
lineToRelative(-2.0F, 0.0F)
lineToRelative(0.0F, -4.5F)
lineToRelative(-2.0F, 0.0F)
lineToRelative(0.0F, 4.5F)
lineToRelative(-2.0F, 0.0F)
lineToRelative(3.0F, 3.0F)
lineToRelative(3.0F, -3.0F)
close()
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconContinuousVerticalPreview() {
Image(imageVector = EhIcons.Reader.ContinuousVertical, contentDescription = null)
}
@@ -0,0 +1,99 @@
package com.hippo.ehviewer.icons.reader
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
@Suppress("BooleanLiteralArgument")
val EhIcons.Reader.Default by unsafeLazy {
materialIcon(name = "Default") {
materialPath {
moveTo(6.9906F, 18.0F)
lineTo(6.9906F, 6.0F)
horizontalLineToRelative(12.0F)
lineTo(18.9906F, 3.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, -2.0F, -2.0F)
horizontalLineToRelative(-10.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, -2.0F, 2.0F)
lineTo(4.9906F, 21.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, 2.0F)
horizontalLineToRelative(10.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, -2.0F)
lineTo(18.9906F, 18.0F)
horizontalLineToRelative(-12.0F)
close()
moveTo(6.9906F, 3.0F)
horizontalLineToRelative(10.0F)
lineTo(16.9906F, 4.0F)
horizontalLineToRelative(-10.0F)
close()
moveTo(16.9906F, 21.0F)
horizontalLineToRelative(-10.0F)
lineTo(6.9906F, 20.0F)
horizontalLineToRelative(10.0F)
close()
moveTo(21.7706F, 12.44F)
lineToRelative(-0.02F, 0.03F)
arcToRelative(3.5546F, 3.5546F, 0.0F, false, false, 0.05F, -0.47F)
arcToRelative(3.9031F, 3.9031F, 0.0F, false, false, -0.05F, -0.48F)
lineToRelative(0.02F, 0.03F)
lineToRelative(0.85F, -0.68F)
arcToRelative(0.5068F, 0.5068F, 0.0F, false, false, 0.12F, -0.64F)
lineToRelative(-0.84F, -1.46F)
arcToRelative(0.4986F, 0.4986F, 0.0F, false, false, -0.61F, -0.22F)
lineToRelative(-1.01F, 0.39F)
lineToRelative(0.0099F, 0.04F)
arcToRelative(3.6135F, 3.6135F, 0.0F, false, false, -0.8F, -0.48F)
lineToRelative(-0.17F, -1.07F)
arcToRelative(0.4879F, 0.4879F, 0.0F, false, false, -0.49F, -0.43F)
horizontalLineToRelative(-1.68F)
arcToRelative(0.4876F, 0.4876F, 0.0F, false, false, -0.49F, 0.42F)
lineToRelative(-0.17F, 1.08F)
arcToRelative(3.6149F, 3.6149F, 0.0F, false, false, -0.8F, 0.48F)
lineToRelative(0.02F, -0.03F)
lineToRelative(-1.01F, -0.39F)
arcToRelative(0.4878F, 0.4878F, 0.0F, false, false, -0.61F, 0.22F)
lineToRelative(-0.84F, 1.46F)
arcToRelative(0.5068F, 0.5068F, 0.0F, false, false, 0.12F, 0.64F)
lineToRelative(0.85F, 0.68F)
lineToRelative(0.02F, -0.03F)
arcToRelative(3.9148F, 3.9148F, 0.0F, false, false, -0.05F, 0.48F)
arcToRelative(3.915F, 3.915F, 0.0F, false, false, 0.05F, 0.48F)
lineToRelative(-0.02F, -0.03F)
lineToRelative(-0.85F, 0.68F)
arcToRelative(0.5068F, 0.5068F, 0.0F, false, false, -0.12F, 0.64F)
lineToRelative(0.84F, 1.46F)
arcToRelative(0.4985F, 0.4985F, 0.0F, false, false, 0.61F, 0.22F)
lineToRelative(1.01F, -0.39F)
lineToRelative(-0.02F, -0.03F)
arcToRelative(4.4991F, 4.4991F, 0.0F, false, false, 0.8F, 0.46F)
lineToRelative(0.17F, 1.08F)
arcToRelative(0.4876F, 0.4876F, 0.0F, false, false, 0.49F, 0.42F)
horizontalLineToRelative(1.68F)
arcToRelative(0.4876F, 0.4876F, 0.0F, false, false, 0.49F, -0.42F)
lineToRelative(0.17F, -1.08F)
arcToRelative(3.6138F, 3.6138F, 0.0F, false, false, 0.8F, -0.48F)
lineToRelative(-0.02F, 0.03F)
lineToRelative(1.02F, 0.39F)
arcToRelative(0.4879F, 0.4879F, 0.0F, false, false, 0.61F, -0.22F)
lineToRelative(0.84F, -1.46F)
arcToRelative(0.5068F, 0.5068F, 0.0F, false, false, -0.12F, -0.64F)
close()
moveTo(17.9906F, 14.0F)
arcToRelative(2.0F, 2.0F, 0.0F, true, true, 2.0F, -2.0F)
arcTo(2.0059F, 2.0059F, 0.0F, false, true, 17.9906F, 14.0F)
close()
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconDefaultPreview() {
Image(imageVector = EhIcons.Reader.Default, contentDescription = null)
}
@@ -0,0 +1,39 @@
package com.hippo.ehviewer.icons.reader
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Reader.Landscape by unsafeLazy {
materialIcon(name = "Landscape") {
materialPath {
moveTo(1.01F, 7.0F)
lineTo(1.0F, 17.0F)
curveToRelative(0.0F, 1.1F, 0.9F, 2.0F, 2.0F, 2.0F)
horizontalLineToRelative(18.0F)
curveToRelative(1.1F, 0.0F, 2.0F, -0.9F, 2.0F, -2.0F)
verticalLineTo(7.0F)
curveToRelative(0.0F, -1.1F, -0.9F, -2.0F, -2.0F, -2.0F)
horizontalLineTo(3.0F)
curveToRelative(-1.1F, 0.0F, -1.99F, 0.9F, -1.99F, 2.0F)
close()
moveTo(19.0F, 7.0F)
verticalLineToRelative(10.0F)
horizontalLineTo(5.0F)
verticalLineTo(7.0F)
horizontalLineToRelative(14.0F)
close()
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconLandscapePreview() {
Image(imageVector = EhIcons.Reader.Landscape, contentDescription = null)
}
@@ -0,0 +1,59 @@
package com.hippo.ehviewer.icons.reader
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Reader.LandscapeLocked by unsafeLazy {
materialIcon(name = "LandscapeLocked") {
materialPath {
moveTo(21.0F, 5.0F)
lineTo(3.0F, 5.0F)
curveToRelative(-1.1F, 0.0F, -2.0F, 0.9F, -2.0F, 2.0F)
verticalLineToRelative(10.0F)
curveToRelative(0.0F, 1.1F, 0.9F, 2.0F, 2.0F, 2.0F)
horizontalLineToRelative(18.0F)
curveToRelative(1.1F, 0.0F, 2.0F, -0.9F, 2.0F, -2.0F)
lineTo(23.0F, 7.0F)
curveToRelative(0.0F, -1.1F, -0.9F, -2.0F, -2.0F, -2.0F)
close()
moveTo(19.0F, 17.0F)
lineTo(5.0F, 17.0F)
lineTo(5.0F, 7.0F)
horizontalLineToRelative(14.0F)
verticalLineToRelative(10.0F)
close()
moveTo(10.0F, 16.0F)
horizontalLineToRelative(4.0F)
curveToRelative(0.55F, 0.0F, 1.0F, -0.45F, 1.0F, -1.0F)
verticalLineToRelative(-3.0F)
curveToRelative(0.0F, -0.55F, -0.45F, -1.0F, -1.0F, -1.0F)
verticalLineToRelative(-1.0F)
curveToRelative(0.0F, -1.11F, -0.9F, -2.0F, -2.0F, -2.0F)
curveToRelative(-1.11F, 0.0F, -2.0F, 0.9F, -2.0F, 2.0F)
verticalLineToRelative(1.0F)
curveToRelative(-0.55F, 0.0F, -1.0F, 0.45F, -1.0F, 1.0F)
verticalLineToRelative(3.0F)
curveToRelative(0.0F, 0.55F, 0.45F, 1.0F, 1.0F, 1.0F)
close()
moveTo(10.8F, 10.0F)
curveToRelative(0.0F, -0.66F, 0.54F, -1.2F, 1.2F, -1.2F)
curveToRelative(0.66F, 0.0F, 1.2F, 0.54F, 1.2F, 1.2F)
verticalLineToRelative(1.0F)
horizontalLineToRelative(-2.4F)
verticalLineToRelative(-1.0F)
close()
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconLandscapeLockedPreview() {
Image(imageVector = EhIcons.Reader.LandscapeLocked, contentDescription = null)
}
@@ -0,0 +1,75 @@
package com.hippo.ehviewer.icons.reader
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
@Suppress("BooleanLiteralArgument")
val EhIcons.Reader.LeftToRight by unsafeLazy {
materialIcon(name = "LeftToRight") {
materialPath {
moveTo(17.3159F, 18.0F)
horizontalLineToRelative(-10.0F)
lineTo(7.3159F, 16.0F)
horizontalLineToRelative(-2.0F)
verticalLineToRelative(5.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, 2.0F)
horizontalLineToRelative(10.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, -2.0F)
lineTo(19.3159F, 16.0F)
horizontalLineToRelative(-2.0F)
close()
moveTo(17.3159F, 21.0F)
horizontalLineToRelative(-10.0F)
lineTo(7.3159F, 20.0F)
horizontalLineToRelative(10.0F)
close()
moveTo(7.3159F, 6.0F)
horizontalLineToRelative(10.0F)
lineTo(17.3159F, 8.0F)
horizontalLineToRelative(2.0F)
lineTo(19.3159F, 3.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, -2.0F, -2.0F)
horizontalLineToRelative(-10.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, -2.0F, 2.0F)
lineTo(5.3159F, 8.0F)
horizontalLineToRelative(2.0F)
close()
moveTo(7.3159F, 3.0F)
horizontalLineToRelative(10.0F)
lineTo(17.3159F, 4.0F)
horizontalLineToRelative(-10.0F)
close()
moveTo(22.311F, 12.0F)
lineToRelative(-3.0F, -3.0F)
lineToRelative(0.0F, 2.0F)
lineToRelative(-11.99F, 0.0F)
lineToRelative(0.0F, 2.0F)
lineToRelative(11.99F, 0.0F)
lineToRelative(0.0F, 2.0F)
lineToRelative(3.0F, -3.0F)
close()
moveTo(2.3206F, 11.0F)
horizontalLineToRelative(1.5F)
verticalLineToRelative(2.0F)
horizontalLineToRelative(-1.5F)
close()
moveTo(4.8206F, 11.0F)
horizontalLineToRelative(1.5F)
verticalLineToRelative(2.0F)
horizontalLineToRelative(-1.5F)
close()
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconLeftToRightPreview() {
Image(imageVector = EhIcons.Reader.LeftToRight, contentDescription = null)
}
@@ -0,0 +1,39 @@
package com.hippo.ehviewer.icons.reader
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Reader.Portrait by unsafeLazy {
materialIcon(name = "Portrait") {
materialPath {
moveTo(17.0F, 1.01F)
lineTo(7.0F, 1.0F)
curveToRelative(-1.1F, 0.0F, -1.99F, 0.9F, -1.99F, 2.0F)
verticalLineToRelative(18.0F)
curveToRelative(0.0F, 1.1F, 0.89F, 2.0F, 1.99F, 2.0F)
horizontalLineToRelative(10.0F)
curveToRelative(1.1F, 0.0F, 2.0F, -0.9F, 2.0F, -2.0F)
verticalLineTo(3.0F)
curveToRelative(0.0F, -1.1F, -0.9F, -1.99F, -2.0F, -1.99F)
close()
moveTo(17.0F, 19.0F)
horizontalLineTo(7.0F)
verticalLineTo(5.0F)
horizontalLineToRelative(10.0F)
verticalLineToRelative(14.0F)
close()
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconPortraitPreview() {
Image(imageVector = EhIcons.Reader.Portrait, contentDescription = null)
}
@@ -0,0 +1,59 @@
package com.hippo.ehviewer.icons.reader
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Reader.PortraitLocked by unsafeLazy {
materialIcon(name = "PortraitLocked") {
materialPath {
moveTo(10.0F, 16.0F)
horizontalLineToRelative(4.0F)
curveToRelative(0.55F, 0.0F, 1.0F, -0.45F, 1.0F, -1.0F)
verticalLineToRelative(-3.0F)
curveToRelative(0.0F, -0.55F, -0.45F, -1.0F, -1.0F, -1.0F)
verticalLineToRelative(-1.0F)
curveToRelative(0.0F, -1.11F, -0.9F, -2.0F, -2.0F, -2.0F)
curveToRelative(-1.11F, 0.0F, -2.0F, 0.9F, -2.0F, 2.0F)
verticalLineToRelative(1.0F)
curveToRelative(-0.55F, 0.0F, -1.0F, 0.45F, -1.0F, 1.0F)
verticalLineToRelative(3.0F)
curveToRelative(0.0F, 0.55F, 0.45F, 1.0F, 1.0F, 1.0F)
close()
moveTo(10.8F, 10.0F)
curveToRelative(0.0F, -0.66F, 0.54F, -1.2F, 1.2F, -1.2F)
curveToRelative(0.66F, 0.0F, 1.2F, 0.54F, 1.2F, 1.2F)
verticalLineToRelative(1.0F)
horizontalLineToRelative(-2.4F)
verticalLineToRelative(-1.0F)
close()
moveTo(17.0F, 1.0F)
lineTo(7.0F, 1.0F)
curveToRelative(-1.1F, 0.0F, -2.0F, 0.9F, -2.0F, 2.0F)
verticalLineToRelative(18.0F)
curveToRelative(0.0F, 1.1F, 0.9F, 2.0F, 2.0F, 2.0F)
horizontalLineToRelative(10.0F)
curveToRelative(1.1F, 0.0F, 2.0F, -0.9F, 2.0F, -2.0F)
lineTo(19.0F, 3.0F)
curveToRelative(0.0F, -1.1F, -0.9F, -2.0F, -2.0F, -2.0F)
close()
moveTo(17.0F, 19.0F)
lineTo(7.0F, 19.0F)
lineTo(7.0F, 5.0F)
horizontalLineToRelative(10.0F)
verticalLineToRelative(14.0F)
close()
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconPortraitLockedPreview() {
Image(imageVector = EhIcons.Reader.PortraitLocked, contentDescription = null)
}
@@ -0,0 +1,75 @@
package com.hippo.ehviewer.icons.reader
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
@Suppress("BooleanLiteralArgument")
val EhIcons.Reader.RightToLeft by unsafeLazy {
materialIcon(name = "RightToLeft") {
materialPath {
moveTo(7.0F, 6.0F)
horizontalLineTo(17.0F)
verticalLineTo(8.0F)
horizontalLineToRelative(2.0F)
verticalLineTo(3.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, -2.0F, -2.0F)
horizontalLineTo(7.0F)
arcTo(2.0059F, 2.0059F, 0.0F, false, false, 5.0F, 3.0F)
verticalLineTo(8.0F)
horizontalLineTo(7.0F)
close()
moveTo(7.0F, 3.0F)
horizontalLineTo(17.0F)
verticalLineTo(4.0F)
horizontalLineTo(7.0F)
close()
moveTo(17.0F, 18.0F)
lineTo(7.0F, 18.0F)
lineTo(7.0F, 16.0F)
lineTo(5.0F, 16.0F)
verticalLineToRelative(5.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, 2.0F)
lineTo(17.0F, 23.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, -2.0F)
lineTo(19.0F, 16.0F)
lineTo(17.0F, 16.0F)
close()
moveTo(17.0F, 21.0F)
lineTo(7.0F, 21.0F)
lineTo(7.0F, 20.0F)
lineTo(17.0F, 20.0F)
close()
moveTo(5.005F, 13.0F)
lineToRelative(11.99F, 0.0F)
lineToRelative(0.0F, -2.0F)
lineToRelative(-11.99F, 0.0F)
lineToRelative(0.0F, -2.0F)
lineToRelative(-3.0F, 3.0F)
lineToRelative(3.0F, 3.0F)
lineToRelative(0.0F, -2.0F)
close()
moveTo(20.4953F, 11.0F)
horizontalLineToRelative(1.5F)
verticalLineToRelative(2.0F)
horizontalLineToRelative(-1.5F)
close()
moveTo(17.9953F, 11.0F)
horizontalLineToRelative(1.5F)
verticalLineToRelative(2.0F)
horizontalLineToRelative(-1.5F)
close()
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconRightToLeftPreview() {
Image(imageVector = EhIcons.Reader.RightToLeft, contentDescription = null)
}
@@ -0,0 +1,55 @@
package com.hippo.ehviewer.icons.reader
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Reader.ScreenRotation by unsafeLazy {
materialIcon(name = "ScreenRotation") {
materialPath {
moveTo(16.48F, 2.52F)
curveToRelative(3.27F, 1.55F, 5.61F, 4.72F, 5.97F, 8.48F)
horizontalLineToRelative(1.5F)
curveTo(23.44F, 4.84F, 18.29F, 0.0F, 12.0F, 0.0F)
lineToRelative(-0.66F, 0.03F)
lineToRelative(3.81F, 3.81F)
lineToRelative(1.33F, -1.32F)
close()
moveTo(10.23F, 1.75F)
curveToRelative(-0.59F, -0.59F, -1.54F, -0.59F, -2.12F, 0.0F)
lineTo(1.75F, 8.11F)
curveToRelative(-0.59F, 0.59F, -0.59F, 1.54F, 0.0F, 2.12F)
lineToRelative(12.02F, 12.02F)
curveToRelative(0.59F, 0.59F, 1.54F, 0.59F, 2.12F, 0.0F)
lineToRelative(6.36F, -6.36F)
curveToRelative(0.59F, -0.59F, 0.59F, -1.54F, 0.0F, -2.12F)
lineTo(10.23F, 1.75F)
close()
moveTo(14.83F, 21.19F)
lineTo(2.81F, 9.17F)
lineToRelative(6.36F, -6.36F)
lineToRelative(12.02F, 12.02F)
lineToRelative(-6.36F, 6.36F)
close()
moveTo(7.52F, 21.48F)
curveTo(4.25F, 19.94F, 1.91F, 16.76F, 1.55F, 13.0F)
lineTo(0.05F, 13.0F)
curveTo(0.56F, 19.16F, 5.71F, 24.0F, 12.0F, 24.0F)
lineToRelative(0.66F, -0.03F)
lineToRelative(-3.81F, -3.81F)
lineToRelative(-1.33F, 1.32F)
close()
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconScreenRotationPreview() {
Image(imageVector = EhIcons.Reader.ScreenRotation, contentDescription = null)
}
@@ -0,0 +1,58 @@
package com.hippo.ehviewer.icons.reader
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
@Suppress("BooleanLiteralArgument")
val EhIcons.Reader.Vertical by unsafeLazy {
materialIcon(name = "Vertical") {
materialPath {
moveTo(17.0F, 1.0F)
lineTo(7.0F, 1.0F)
arcTo(2.0059F, 2.0059F, 0.0F, false, false, 5.0F, 3.0F)
lineTo(5.0F, 21.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, 2.0F)
lineTo(17.0F, 23.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, -2.0F)
lineTo(19.0F, 3.0F)
arcTo(2.0059F, 2.0059F, 0.0F, false, false, 17.0F, 1.0F)
close()
moveTo(17.0F, 3.0F)
verticalLineToRelative(1.0462F)
lineTo(7.0F, 4.0462F)
lineTo(7.0F, 3.0F)
close()
moveTo(17.0F, 6.0462F)
verticalLineToRelative(12.0F)
lineTo(7.0F, 18.0462F)
verticalLineToRelative(-12.0F)
close()
moveTo(7.0F, 21.0F)
verticalLineToRelative(-0.9538F)
lineTo(17.0F, 20.0462F)
lineTo(17.0F, 21.0F)
close()
moveTo(9.0F, 13.0F)
lineToRelative(3.0F, 3.0F)
lineToRelative(3.0F, -3.0F)
lineToRelative(-2.0F, 0.0F)
lineToRelative(0.0F, -5.0F)
lineToRelative(-2.0F, 0.0F)
lineToRelative(0.0F, 5.0F)
lineToRelative(-2.0F, 0.0F)
close()
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconVerticalPreview() {
Image(imageVector = EhIcons.Reader.Vertical, contentDescription = null)
}
@@ -0,0 +1,48 @@
package com.hippo.ehviewer.icons.reader
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
@Suppress("BooleanLiteralArgument")
val EhIcons.Reader.Webtoon by unsafeLazy {
materialIcon(name = "Webtoon") {
materialPath {
moveTo(11.992F, 19.0F)
lineToRelative(3.0F, -3.0F)
lineToRelative(-2.0F, 0.0F)
lineToRelative(0.0F, -11.0F)
lineToRelative(-2.0F, 0.0F)
lineToRelative(0.0F, 11.0F)
lineToRelative(-2.0F, 0.0F)
lineToRelative(3.0F, 3.0F)
close()
moveTo(17.0F, 1.0F)
lineTo(7.0F, 1.0F)
arcTo(2.0059F, 2.0059F, 0.0F, false, false, 5.0F, 3.0F)
lineTo(5.0F, 21.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, 2.0F)
lineTo(17.0F, 23.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, -2.0F)
lineTo(19.0F, 3.0F)
arcTo(2.0059F, 2.0059F, 0.0F, false, false, 17.0F, 1.0F)
close()
moveTo(17.0F, 21.0F)
lineTo(7.0F, 21.0F)
lineTo(7.0F, 3.0F)
lineTo(17.0F, 3.0F)
close()
}
}
}
@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun IconWebtoonPreview() {
Image(imageVector = EhIcons.Reader.Webtoon, contentDescription = null)
}
@@ -30,8 +30,8 @@ import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ErrorResult
import coil3.request.SuccessResult
import coil3.size.Dimension
import coil3.size.Precision
import coil3.size.Scale
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.coil.BitmapImageWithRect
import com.hippo.ehviewer.coil.maybeCropBorder
@@ -73,15 +73,13 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
}
companion object {
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 2
private val targetHeight = appCtx.resources.displayMetrics.heightPixels * 2
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
private suspend fun Either<ByteBufferSource, UniFileSource>.decodeCoil(): CoilImage {
val req = appCtx.imageRequest {
onLeft { data(it.source) }
onRight { data(it.source.uri) }
size(targetWidth, targetHeight)
scale(Scale.FILL)
size(Dimension(targetWidth), Dimension.Undefined)
precision(Precision.INEXACT)
maybeCropBorder(Settings.cropBorder.value)
memoryCachePolicy(CachePolicy.DISABLED)
@@ -94,7 +92,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
suspend fun decode(src: ImageSource): Image? {
return runCatching {
when (src) {
val image = when (src) {
is UniFileSource -> {
if (isAtLeastP && !isAtLeastU) {
src.source.openFileDescriptor("rw").use {
@@ -112,20 +110,19 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
}
}
}
val image = src.right().decodeCoil()
if (image is BitmapImage) src.close()
Image(image, src)
src.right().decodeCoil()
}
is ByteBufferSource -> {
if (isAtLeastP && !isAtLeastU) {
rewriteGifSource(src.source)
}
val image = src.left().decodeCoil()
if (image is BitmapImage) src.close()
Image(image, src)
src.left().decodeCoil()
}
}
Image(image, src).apply {
if (innerImage is BitmapImage) src.close()
}
}.onFailure {
src.close()
logcat(it)
@@ -24,39 +24,30 @@ 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.job
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
import org.chromium.net.apihelpers.UploadDataProviders
object CronetEngine : HttpClientEngineBase("Cronet") {
override val config = HttpClientEngineConfig()
override val dispatcher = cronetDispatcher
@InternalAPI
override suspend fun execute(data: HttpRequestData): HttpResponseData {
val callContext = callContext()
override suspend fun execute(data: HttpRequestData) = executeHttpRequest(callContext(), data)
return executeHttpRequest(callContext, data)
}
@OptIn(DelicateCoroutinesApi::class)
private suspend fun executeHttpRequest(
callContext: CoroutineContext,
data: HttpRequestData,
): HttpResponseData = suspendCancellableCoroutine { continuation ->
) = suspendCancellableCoroutine { continuation ->
val requestTime = GMTDate()
val callback = object : UrlRequest.Callback() {
lateinit var chunkChan: Channel<ByteBuffer>
val chunkChan = Channel<ByteBuffer>()
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) {
continuation.resume(
info.toHttpResponseData(
@@ -67,23 +58,21 @@ object CronetEngine : HttpClientEngineBase("Cronet") {
}
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,
responseBody = writer(callContext.job) {
pool.useInstance {
request.read(it)
chunkChan.consumeEach { buffer ->
buffer.flip()
channel.writeFully(buffer)
buffer.clear()
request.read(buffer)
}
}
}.channel,
),
)
}
@@ -97,24 +86,24 @@ object CronetEngine : HttpClientEngineBase("Cronet") {
}
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
if (::chunkChan.isInitialized) {
chunkChan.close(error)
} else {
if (continuation.isActive) {
continuation.resumeWithException(error)
} else {
chunkChan.close(error)
}
}
}
val request = cronetHttpClient.newUrlRequestBuilder(data.url.toString(), callback, cronetHttpClientExecutor).apply {
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() }
data.body.toUploadDataProvider()?.let { setUploadDataProvider(it, cronetHttpClientExecutor) }
}.build().apply {
start()
callContext.job.invokeOnCompletion { cancel() }
}
}
}
@@ -122,55 +111,26 @@ 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,
)
}
) = 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) {
private fun OutgoingContent.toUploadDataProvider() = 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()
}
}
is OutgoingContent.ByteArrayContent -> UploadDataProviders.create(bytes())
else -> error("UnsupportedContentType $this")
}
@@ -76,7 +76,7 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
}
return ComicInfo(
series = title,
alternateSeries = titleJpn.takeUnless { it.isNullOrBlank() },
alternateSeries = titleJpn?.ifBlank { null },
writer = groups.ifEmpty { null },
penciller = artists.ifEmpty { null },
genre = otherTags.ifEmpty { null },
@@ -99,7 +99,6 @@ fun ComicInfo.toSimpleTags() = listOfNotNull(
fun ComicInfo.write(file: UniFile) {
file.openOutputStream().use {
it.channel.truncate(0)
OutputStreamWriter(it, Charsets.UTF_8).use { writer ->
xmlStreaming.newGenericWriter(writer).use { xmlWriter ->
xml.encodeToWriter(xmlWriter, ComicInfo.serializer(), this)
@@ -28,9 +28,12 @@ import com.hippo.ehviewer.client.EhUtils.getSuitableTitle
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.client.executeSafely
import com.hippo.ehviewer.client.getImageKey
import com.hippo.ehviewer.coil.read
import com.hippo.ehviewer.coil.suspendEdit
import com.hippo.ehviewer.dao.DownloadArtist
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.download.tempDownloadDir
import com.hippo.ehviewer.image.UniFileSource
@@ -40,7 +43,6 @@ import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import com.hippo.unifile.openOutputStream
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.plugins.onDownload
import io.ktor.client.statement.HttpResponse
@@ -157,7 +159,7 @@ class SpiderDen(val info: GalleryInfo) {
notifyProgress(total!!, done, (done - prev).toInt())
prev = done
}
}.execute {
}.executeSafely {
if (it.status.isSuccess()) {
saveFromHttpResponse(index, it)
} else {
@@ -191,8 +193,7 @@ class SpiderDen(val info: GalleryInfo) {
val extension = MimeTypeMap.getFileExtensionFromUrl(url).ifEmpty { "jpg" }
return saveResponseMeta(index, extension) { outFile ->
outFile.openOutputStream().use {
val chan = it.channel.apply { truncate(0) }
response.bodyAsChannel().copyTo(chan)
response.bodyAsChannel().copyTo(it.channel)
}
}
}
@@ -321,11 +322,15 @@ class SpiderDen(val info: GalleryInfo) {
createFile(COMIC_INFO_FILE)?.also {
runCatching {
if (info !is GalleryDetail && fetchMetadata) {
withNonCancellableContext {
EhEngine.fillGalleryListByApi(listOf(info))
EhEngine.fillGalleryListByApi(listOf(info))
}
info.getComicInfo().apply {
write(it)
DownloadManager.getDownloadInfo(gid)?.let { downloadInfo ->
downloadInfo.artistInfoList = DownloadArtist.from(gid, penciller.orEmpty())
EhDB.putDownloadArtist(gid, downloadInfo.artistInfoList)
}
}
info.getComicInfo().write(it)
}.onFailure {
logcat(it)
}
@@ -244,12 +244,15 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
if (archiveJob.isActive) {
archiveJob.join()
}
runCatching {
if (!mSpiderDen.postArchive()) {
if (!mSpiderDen.postArchive()) {
if (mWorkerScope.isDownloadMode) {
mSpiderDen.writeComicInfo()
}
runCatching {
writeSpiderInfoToLocal()
}.onFailure {
logcat(it)
}
}.onFailure {
logcat(it)
}
queenScope.cancel()
}
@@ -483,13 +486,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
val gid = galleryInfo.gid
return (sQueenMap[gid] ?: SpiderQueen(galleryInfo).also { sQueenMap[gid] = it }).apply {
setMode(mode)
launch {
// Will create download dir if not exists
updateMode()
if (mode == MODE_DOWNLOAD) {
mSpiderDen.writeComicInfo()
}
}
launch { updateMode() }
}
}
@@ -516,7 +513,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private val downloadTimeout = Settings.downloadTimeout.seconds
private val delayLock = Mutex()
private var lastRequestTime = TimeSource.Monotonic.markNow()
private var isDownloadMode = false
var isDownloadMode = false
private set
fun cancelDecode(index: Int) {
decoder.cancel(index)
@@ -547,13 +545,20 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private fun doLaunchDownloadJob(index: Int, force: Boolean, orgImg: Boolean = false) {
val state = mPageStateArray[index]
if (!force && state == STATE_FINISHED) return
if (!force) {
if (state == STATE_FINISHED) return
if (index in mSpiderDen) return updatePageState(index, STATE_FINISHED)
}
val currentJob = mFetcherJobMap[index]
val skipHath = force && !orgImg && currentJob?.isActive == true
if (force) currentJob?.cancel(CancellationException(FORCE_RETRY))
if (currentJob?.isActive != true) {
mFetcherJobMap[index] = launch {
runCatching {
delayLock.withLock {
delay(mDownloadDelay - lastRequestTime.elapsedNow())
lastRequestTime = TimeSource.Monotonic.markNow()
}
mSemaphore.withPermit {
doInJob(index, force, orgImg, skipHath)
}
@@ -591,19 +596,14 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
?: getPTokenFromMultiPageViewer(index)
}
updatePageState(index, STATE_DOWNLOADING)
if (!force && index in mSpiderDen) {
return updatePageState(index, STATE_FINISHED)
}
if (force) {
pTokenLock.withLock {
val pToken = mSpiderInfo.pTokenMap[index]
if (pToken == TOKEN_FAILED) mSpiderInfo.pTokenMap.remove(index)
}
}
val previousPToken: String?
val pToken: String
pTokenLock.withLock {
if (force) {
if (mSpiderInfo.pTokenMap[index] == TOKEN_FAILED) {
mSpiderInfo.pTokenMap.remove(index)
}
}
pToken = getPToken(index)
?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE).also {
mSpiderInfo.pTokenMap[index] = TOKEN_FAILED
@@ -611,11 +611,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
previousPToken = getPToken(index - 1)
}
delayLock.withLock {
delay(mDownloadDelay - lastRequestTime.elapsedNow())
lastRequestTime = TimeSource.Monotonic.markNow()
}
var skipHathKey: String? = null
var originImageUrl: String? = null
var error: String? = null
@@ -651,15 +646,15 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
pToken,
localShowKey,
previousPToken,
).let {
check509(it.imageUrl)
imageUrl = it.imageUrl
skipHathKey = it.skipHathKey
originImageUrl = it.originImageUrl
}
}.onFailure {
)
}.getOrElse {
forceHtml = true
return@repeat
}.let {
check509(it.imageUrl)
imageUrl = it.imageUrl
skipHathKey = it.skipHathKey
originImageUrl = it.originImageUrl
}
}
@@ -738,31 +733,37 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
val currentJob = mDecodeJobMap[index]
if (currentJob?.isActive != true) {
mDecodeJobMap[index] = launch {
doInJob(index)
runCatching {
mSemaphore.withPermit {
mFetcherJobMap[index]?.join()
if (mPageStateArray[index] == STATE_FINISHED) {
doInJob(index)
}
}
}.onFailure {
if (mPageStateArray[index] == STATE_FINISHED) {
notifyGetImageFailure(index, DECODE_ERROR)
}
if (it is CancellationException) {
throw it
}
}
}
}
}
}
private suspend fun doInJob(index: Int) {
mFetcherJobMap[index]?.takeIf { it.isActive }?.join()
val src = mSpiderDen.getImageSource(index) ?: return
val image = Image.decode(src)
checkNotNull(image)
runCatching {
val src = mSpiderDen.getImageSource(index) ?: return
val image = mSemaphore.withPermit { Image.decode(src) }
checkNotNull(image)
runCatching {
currentCoroutineContext().ensureActive()
}.onFailure {
image.recycle()
throw it
}
notifyGetImageSuccess(index, image)
currentCoroutineContext().ensureActive()
}.onFailure {
notifyGetImageFailure(index, DECODE_ERROR)
if (it is CancellationException) {
throw it
}
image.recycle()
throw it
}
notifyGetImageSuccess(index, image)
}
}
}
@@ -40,7 +40,6 @@ import androidx.core.content.ContextCompat
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@@ -161,7 +160,7 @@ suspend fun DialogState.startDownload(
add(it.label)
}
}
val (selected, checked) = showSelectItemWithCheckBox(
val (selected, checked) = awaitSelectItemWithCheckBox(
items,
title = R.string.download,
checkBoxText = R.string.remember_download_label,
@@ -190,7 +189,7 @@ suspend fun DialogState.startDownload(
suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
val localFavorited = EhDB.containLocalFavorites(galleryInfo.gid)
if (EhCookieStore.hasSignedIn()) {
if (Settings.hasSignedIn.value) {
val isFavorited = galleryInfo.favoriteSlot != NOT_FAVORITED
val defaultFavSlot = Settings.defaultFavSlot
if (defaultFavSlot == -2) {
@@ -206,7 +205,7 @@ suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
add(localFav)
addAll(cloudFav)
}
val (slot, note) = showSelectItemWithIconAndTextField(
val (slot, note) = awaitSelectItemWithIconAndTextField(
items,
title = R.string.add_favorites_dialog_title,
hint = R.string.favorite_note,
@@ -311,10 +310,14 @@ suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
add(Icons.AutoMirrored.Default.DriveFileMove to R.string.download_move_dialog_title)
}
}
val selected = showSelectItemWithIcon(items, EhUtils.getSuitableTitle(info))
val selected = awaitSelectItemWithIcon(items, EhUtils.getSuitableTitle(info))
with(findActivity<MainActivity>()) {
when (selected) {
0 -> navToReader(info)
0 -> {
EhDB.putHistoryInfo(info)
navToReader(info)
}
1 -> withUIContext {
if (downloaded) {
confirmRemoveDownload(info)
@@ -406,7 +409,7 @@ suspend fun DialogState.showMoveDownloadLabel(info: GalleryInfo) {
add(it.label)
}
}
val selected = showSelectItem(labels, R.string.download_move_dialog_title)
val selected = awaitSelectItem(labels, R.string.download_move_dialog_title)
val downloadInfo = DownloadManager.getDownloadInfo(info.gid) ?: return
val label = if (selected == 0) null else labels[selected]
DownloadManager.changeLabel(listOf(downloadInfo), label)
@@ -420,19 +423,19 @@ suspend fun DialogState.showMoveDownloadLabelList(list: Collection<DownloadInfo>
add(it.label)
}
}
val selected = showSelectItem(labels, R.string.download_move_dialog_title)
val selected = awaitSelectItem(labels, R.string.download_move_dialog_title)
val label = if (selected == 0) null else labels[selected]
DownloadManager.changeLabel(list, label)
return label
}
suspend fun DialogState.showDatePicker(): String? {
suspend fun DialogState.awaitSelectDate(): String? {
val initial = LocalDate(2007, 3, 21)
val yesterday = Clock.System.todayIn(TimeZone.UTC).minus(1, DateTimeUnit.DAY)
val initialMillis = initial.toEpochMillis()
val yesterdayMillis = yesterday.toEpochMillis()
val dateRange = initialMillis..yesterdayMillis
val dateMillis = showDatePicker(
val dateMillis = awaitSelectDate(
title = R.string.go_to,
yearRange = initial.year..yesterday.year,
selectableDates = object : SelectableDates {
@@ -16,16 +16,26 @@
package com.hippo.ehviewer.ui
import android.os.Bundle
import android.view.View
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.ActivityNavigator
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.util.isAtLeastR
import eu.kanade.tachiyomi.util.view.setSecureScreen
abstract class EhActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// https://issuetracker.google.com/204791558
// Fix system bars insets still exist in fullscreen mode on API < 30
@Suppress("DEPRECATION")
if (!isAtLeastR) {
with(window.decorView) {
systemUiVisibility = systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_STABLE.inv()
}
}
}
override fun onResume() {
@@ -49,47 +49,59 @@ import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Whatshot
import androidx.compose.material3.DrawerDefaults
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerState2
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.ModalSideDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.SideDrawer
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.material3.rememberDrawerState2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.currentCompositeKeyHash
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.navigation.compose.rememberNavController
@@ -117,15 +129,14 @@ import com.hippo.ehviewer.ui.destinations.WhatshotScreenDestination
import com.hippo.ehviewer.ui.screen.asDst
import com.hippo.ehviewer.ui.screen.asDstWith
import com.hippo.ehviewer.ui.screen.navWithUrl
import com.hippo.ehviewer.ui.screen.navigate
import com.hippo.ehviewer.ui.settings.showNewVersion
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalTouchSlopProvider
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.updater.AppUpdater
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.calculateFraction
import com.hippo.ehviewer.util.displayString
import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.getUrlFromClipboard
@@ -134,8 +145,9 @@ import com.hippo.ehviewer.util.isAtLeastS
import com.hippo.unifile.asUniFile
import com.hippo.unifile.sha1
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.rememberNavHostEngine
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
import com.ramcosta.composedestinations.utils.currentDestinationAsState
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
@@ -148,7 +160,7 @@ import moe.tarsin.coroutines.runSuspendCatching
import splitties.systemservices.clipboardManager
import splitties.systemservices.connectivityManager
private val navItems = arrayOf(
private val navItems = arrayOf<Triple<DirectionDestinationSpec, Int, ImageVector>>(
Triple(HomePageScreenDestination, R.string.homepage, Icons.Default.Home),
Triple(SubscriptionScreenDestination, R.string.subscription, EhIcons.Default.Subscriptions),
Triple(WhatshotScreenDestination, R.string.whats_hot, Icons.Default.Whatshot),
@@ -203,10 +215,11 @@ class MainActivity : EhActivity() {
val configuration = LocalConfiguration.current
val dialogState = LocalDialogState.current
val navDrawerState = rememberDrawerState(DrawerValue.Closed)
val sideSheetState = rememberDrawerState(DrawerValue.Closed)
val sideSheetState = rememberDrawerState2(DrawerValue.Closed)
val snackbarState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val navController = rememberNavController()
val navigator = navController.rememberDestinationsNavigator()
fun closeDrawer(callback: () -> Unit = {}) = scope.launch {
navDrawerState.close()
callback()
@@ -225,7 +238,7 @@ class MainActivity : EhActivity() {
style = MaterialTheme.typography.titleMedium,
)
}
navController.navigate(DownloadScreenDestination)
navigator.navigate(DownloadScreenDestination)
}
}
@@ -249,7 +262,7 @@ class MainActivity : EhActivity() {
when (intent.action) {
Intent.ACTION_VIEW -> {
val url = intent.data?.toString()
if (url != null && !navController.navWithUrl(url)) {
if (url != null && !navigator.navWithUrl(url)) {
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
addTextToClipboard(new)
}
@@ -258,26 +271,28 @@ class MainActivity : EhActivity() {
val type = intent.type
if ("text/plain" == type) {
val keyword = intent.getStringExtra(Intent.EXTRA_TEXT)
if (keyword != null && !navController.navWithUrl(keyword)) {
navController.navigate(ListUrlBuilder(mKeyword = keyword).asDst())
if (keyword != null && !navigator.navWithUrl(keyword)) {
navigator.navigate(ListUrlBuilder(mKeyword = keyword).asDst())
}
} else if (type != null && type.startsWith("image/")) {
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
if (null != uri) {
withIOContext { uri.asUniFile().sha1() }?.let {
navController.navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
hash = it,
).asDst(),
)
}
val hash = withIOContext { uri.asUniFile().sha1() }
navigator.navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
hash = hash,
).asDst(),
)
}
}
}
DownloadService.ACTION_START_DOWNLOADSCENE -> {
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)
navController.navigate(DownloadsScreenDestination)
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)!!
if (args.getString(DownloadService.KEY_ACTION) == DownloadService.ACTION_CLEAR_DOWNLOAD_SERVICE) {
DownloadService.clear()
}
navigator.navigate(DownloadsScreenDestination)
}
}
}
@@ -308,7 +323,7 @@ class MainActivity : EhActivity() {
}
val snackMessage = stringResource(R.string.clipboard_gallery_url_snack_message)
val snackAction = stringResource(R.string.clipboard_gallery_url_snack_action)
LifecycleResumeEffect {
LifecycleResumeEffect(scope) {
val job = scope.launch {
delay(300)
val text = clipboardManager.getUrlFromClipboard(applicationContext)
@@ -317,11 +332,11 @@ class MainActivity : EhActivity() {
val result1 = GalleryDetailUrlParser.parse(text, false)
var launch: (() -> Unit)? = null
if (result1 != null) {
launch = { navController.navigate(result1.gid asDstWith result1.token) }
launch = { navigator.navigate(result1.gid asDstWith result1.token) }
}
val result2 = GalleryPageUrlParser.parse(text, false)
if (result2 != null) {
launch = { navController.navigate(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
launch = { navigator.navigate(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
}
launch?.let {
val ret = snackbarState.showSnackbar(snackMessage, snackAction, true)
@@ -333,17 +348,18 @@ class MainActivity : EhActivity() {
onPauseOrDispose { job.cancel() }
}
val currentDestination by navController.currentDestinationAsState()
val lockDrawerHandle = remember { mutableStateListOf<Int>() }
val drawerHandle = remember { mutableStateListOf<Int>() }
var snackbarFabPadding by remember { mutableStateOf(0.dp) }
val drawerLocked = lockDrawerHandle.isNotEmpty()
val viewConfiguration = LocalViewConfiguration.current
val drawerEnabled = drawerHandle.isNotEmpty()
val density = LocalDensity.current
val adaptiveInfo = currentWindowAdaptiveInfo()
CompositionLocalProvider(
LocalNavDrawerState provides navDrawerState,
LocalSideSheetState provides sideSheetState,
LocalDrawerLockHandle provides lockDrawerHandle,
LocalDrawerHandle provides drawerHandle,
LocalSnackBarHostState provides snackbarState,
LocalSnackBarFabPadding provides animateDpAsState(snackbarFabPadding, label = "SnackbarFabPadding"),
LocalWindowSizeClass provides adaptiveInfo.windowSizeClass,
) {
Scaffold(
snackbarHost = {
@@ -357,81 +373,85 @@ class MainActivity : EhActivity() {
)
},
) {
LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) {
ModalNavigationDrawer(
drawerContent = {
ModalDrawerSheet(
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 56).dp),
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Start),
var minOffset by remember {
mutableFloatStateOf(-with(density) { DrawerDefaults.MaximumDrawerWidth.toPx() })
}
ModalNavigationDrawer(
drawerContent = {
ModalDrawerSheet(
drawerState = navDrawerState,
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 56).dp)
.onSizeChanged { minOffset = -it.width.toFloat() },
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Start),
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.verticalScroll(scrollState)
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)),
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.verticalScroll(scrollState)
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)),
) {
Image(
painter = painterResource(id = R.drawable.sadpanda_low_poly),
contentDescription = null,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
contentScale = ContentScale.FillWidth,
Image(
painter = painterResource(id = R.drawable.sadpanda_low_poly),
contentDescription = null,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
contentScale = ContentScale.FillWidth,
)
navItems.forEach { (direction, stringId, icon) ->
NavigationDrawerItem(
label = {
Text(text = stringResource(id = stringId))
},
selected = currentDestination === direction,
onClick = {
navigator.navigate(direction)
closeDrawer()
},
modifier = Modifier.padding(horizontal = 12.dp),
icon = {
Icon(imageVector = icon, contentDescription = null)
},
)
navItems.forEach { (direction, stringId, icon) ->
NavigationDrawerItem(
label = {
Text(text = stringResource(id = stringId))
},
selected = currentDestination === direction,
onClick = {
navController.navigate(direction.route)
closeDrawer()
},
modifier = Modifier.padding(horizontal = 12.dp),
icon = {
Icon(imageVector = icon, contentDescription = null)
},
)
}
}
}
}
},
drawerState = navDrawerState,
gesturesEnabled = drawerEnabled && sideSheetState.isClosed || navDrawerState.isOpen,
) {
val sheet = sideSheet.firstOrNull()
val radius by remember {
snapshotFlow {
val step = calculateFraction(minOffset, 0f, navDrawerState.currentOffset)
with(density) { lerp(0, 10, step).dp.toPx() }
}
}.collectAsState(0f)
ModalSideDrawer(
drawerContent = {
if (sheet != null) {
ModalDrawerSheet(
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End),
) {
sheet(sideSheetState)
}
}
},
drawerState = navDrawerState,
gesturesEnabled = !drawerLocked || navDrawerState.isOpen,
) {
val sheet = sideSheet.firstOrNull()
SideDrawer(
drawerContent = {
if (sheet != null) {
ModalDrawerSheet(
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End),
) {
CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
sheet(sideSheetState)
}
}
}
},
drawerState = sideSheetState,
gesturesEnabled = sheet != null && !drawerLocked,
) {
val windowSizeClass = calculateWindowSizeClass(this)
CompositionLocalProvider(
LocalViewConfiguration provides viewConfiguration,
LocalWindowSizeClass provides windowSizeClass,
) {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = if (Settings.needSignIn) {
SignInScreenDestination
} else {
StartDestination
},
engine = rememberNavHostEngine(rootDefaultAnimations = rememberEhNavAnim()),
navController = navController,
)
modifier = Modifier.graphicsLayer {
if (radius != 0f) {
renderEffect = BlurEffect(radius, radius, TileMode.Clamp)
shape = RectangleShape
clip = true
}
}
},
drawerState = sideSheetState,
gesturesEnabled = sheet != null && drawerEnabled,
) {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
}
}
}
@@ -508,18 +528,17 @@ class MainActivity : EhActivity() {
}
}
val LocalNavDrawerState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalNavDrawerState not present!") }
val LocalNavDrawerState = compositionLocalOf<DrawerState> { error("CompositionLocal LocalNavDrawerState not present!") }
val LocalSideSheetState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalSideSheetState not present!") }
val LocalDrawerLockHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalDrawerLockHandle not present!") }
val LocalDrawerHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalDrawerHandle not present!") }
val LocalSnackBarHostState = compositionLocalOf<SnackbarHostState> { error("CompositionLocal LocalSnackBarHostState not present!") }
val LocalSnackBarFabPadding = compositionLocalOf<State<Dp>> { error("CompositionLocal LocalSnackBarFabPadding not present!") }
@Composable
fun LockDrawer(value: Boolean) {
val updated by rememberUpdatedState(value)
if (updated) {
fun DrawerHandle(enabled: Boolean) {
if (enabled) {
val current = currentCompositeKeyHash
val handle = LocalDrawerLockHandle.current
val handle = LocalDrawerHandle.current
DisposableEffect(current) {
handle.add(current)
onDispose {
@@ -128,7 +128,7 @@ fun Context.interceptSecurityOrReturn() {
val lockedDelayTime = lockedResumeTime - lockedLastLeaveTime
if (lockedDelayTime < Settings.securityDelay * 60) {
locked = false
} else if (Settings.security && isAuthenticationSupported() && locked) {
} else if (Settings.security.value && isAuthenticationSupported() && locked) {
startActivity(Intent(this, SecurityActivity::class.java))
}
}
@@ -2,17 +2,21 @@ package com.hippo.ehviewer.ui
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.ComposeView
import androidx.navigation.NavBackStackEntry
import com.hippo.ehviewer.ui.theme.EhTheme
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.ProvideVectorPainterCache
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle
import soup.compose.material.motion.animation.materialSharedAxisXIn
import soup.compose.material.motion.animation.materialSharedAxisXOut
import soup.compose.material.motion.animation.rememberSlideDistance
@@ -46,14 +50,14 @@ inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -
}
@Composable
fun rememberEhNavAnim(): RootNavGraphDefaultAnimations {
fun rememberEhNavAnim(): NavHostAnimatedDestinationStyle {
val slideDistance = rememberSlideDistance()
return remember {
RootNavGraphDefaultAnimations(
enterTransition = { materialSharedAxisXIn(true, slideDistance) },
exitTransition = { materialSharedAxisXOut(true, slideDistance) },
popEnterTransition = { materialSharedAxisXIn(false, slideDistance) },
popExitTransition = { materialSharedAxisXOut(false, slideDistance) },
)
return remember(slideDistance) {
object : NavHostAnimatedDestinationStyle() {
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { materialSharedAxisXIn(true, slideDistance) }
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { materialSharedAxisXOut(true, slideDistance) }
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { materialSharedAxisXIn(false, slideDistance) }
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { materialSharedAxisXOut(false, slideDistance) }
}
}
}
@@ -12,8 +12,7 @@ suspend fun postLogin() = coroutineScope {
launch {
runCatching {
EhEngine.getProfile().run {
Settings.displayName = displayName
Settings.avatar = avatar
Settings.displayName.value = displayName
}
}.onFailure {
logcat(it)
@@ -41,5 +40,6 @@ suspend fun postLogin() = coroutineScope {
}.onFailure {
Settings.gallerySite = EhUrl.SITE_E
}
Settings.hasSignedIn.value = true
Settings.needSignIn = false
}
@@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -40,6 +39,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.dimensionResource
@@ -54,32 +54,31 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowWidthSizeClass
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.destinations.WebViewSignInScreenDestination
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.autofill
import com.hippo.ehviewer.util.displayString
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.Job
@RootNavGraph(start = true)
@Destination
@Destination<RootGraph>(start = true)
@Composable
fun SignInScreen(navigator: DestinationsNavigator) {
LockDrawer(true)
val windowSizeClass = LocalWindowSizeClass.current
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
@@ -125,7 +124,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Text(
"""
${it.displayString()}
${stringResource(R.string.sign_in_failed_tip)}
${stringResource(R.string.sign_in_failed_tip, stringResource(R.string.sign_in_via_webview))}
""".trimIndent(),
)
},
@@ -144,7 +143,10 @@ fun SignInScreen(navigator: DestinationsNavigator) {
OutlinedTextField(
value = username,
onValueChange = { username = it },
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)),
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)).autofill(
autofillTypes = listOf(AutofillType.Username),
onFill = { username = it },
),
label = { Text(stringResource(R.string.username)) },
supportingText = { if (showUsernameError) Text(stringResource(R.string.error_username_cannot_empty)) },
trailingIcon = { if (showUsernameError) Icon(imageVector = Icons.Filled.Info, contentDescription = null) },
@@ -155,7 +157,10 @@ fun SignInScreen(navigator: DestinationsNavigator) {
OutlinedTextField(
value = password,
onValueChange = { password = it },
Modifier.width(dimensionResource(id = R.dimen.single_max_width)),
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)).autofill(
autofillTypes = listOf(AutofillType.Password),
onFill = { password = it },
),
label = { Text(stringResource(R.string.password)) },
visualTransformation = if (passwordHidden) PasswordVisualTransformation() else VisualTransformation.None,
supportingText = { if (showPasswordError) Text(stringResource(R.string.error_password_cannot_empty)) },
@@ -177,8 +182,8 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}
Box(contentAlignment = Alignment.Center) {
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> {
when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.COMPACT, WindowWidthSizeClass.MEDIUM -> {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).systemBarsPadding().padding(dimensionResource(id = R.dimen.keyline_margin)),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -197,9 +202,9 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Text(
text = stringResource(id = R.string.app_waring_2),
modifier = Modifier.widthIn(max = dimensionResource(id = R.dimen.single_max_width)).padding(top = 12.dp),
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.keyline_margin)))
Row(modifier = Modifier.padding(top = dimensionResource(R.dimen.keyline_margin))) {
FilledTonalButton(
onClick = { context.openBrowser(EhUrl.URL_REGISTER) },
@@ -214,10 +219,10 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Text(text = stringResource(id = R.string.sign_in))
}
}
Row(modifier = Modifier.padding(horizontal = 4.dp)) {
Row(modifier = Modifier.padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = { navigator.navigate(WebViewSignInScreenDestination) },
modifier = Modifier.padding(horizontal = 8.dp),
modifier = Modifier.weight(1f),
) {
Text(
text = buildAnnotatedString {
@@ -235,6 +240,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Settings.gallerySite = EhUrl.SITE_E
navigator.popNavigate(StartDestination)
},
modifier = Modifier.weight(1f),
) {
Text(
text = buildAnnotatedString {
@@ -249,7 +255,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}
}
}
WindowWidthSizeClass.Expanded -> {
WindowWidthSizeClass.EXPANDED -> {
Row(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).systemBarsPadding().padding(dimensionResource(id = R.dimen.keyline_margin)),
verticalAlignment = Alignment.CenterVertically,
@@ -272,7 +278,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Text(
text = stringResource(id = R.string.app_waring_2),
modifier = Modifier.widthIn(max = 360.dp),
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleLarge,
)
}
Column(
@@ -299,7 +305,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Row(horizontalArrangement = Arrangement.Center) {
TextButton(
onClick = { navigator.navigate(WebViewSignInScreenDestination) },
modifier = Modifier.padding(horizontal = 4.dp),
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
) {
Text(
text = buildAnnotatedString {
@@ -317,7 +323,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Settings.gallerySite = EhUrl.SITE_E
navigator.popNavigate(StartDestination)
},
modifier = Modifier.padding(horizontal = 4.dp),
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
) {
Text(
text = buildAnnotatedString {
@@ -13,21 +13,20 @@ import com.google.accompanist.web.rememberWebViewState
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.util.setDefaultSettings
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import io.ktor.http.Url
@Destination
@Destination<RootGraph>
@Composable
fun WebViewSignInScreen(navigator: DestinationsNavigator) {
LockDrawer(true)
val coroutineScope = rememberCoroutineScope()
val state = rememberWebViewState(url = EhUrl.URL_SIGN_IN)
val client = remember {
@@ -2,6 +2,8 @@ package com.hippo.ehviewer.ui.main
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
object AdvanceTable {
const val SH = 0x1
@@ -12,7 +14,9 @@ object AdvanceTable {
}
@Parcelize
@Serializable
data class AdvancedSearchOption(
@Transient
val advanceSearch: Int = 0,
val minRating: Int = 0,
val fromPage: Int = 0,
@@ -0,0 +1,112 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.parser.Archive
import com.hippo.ehviewer.client.parser.Funds
@Composable
fun ArchiveList(
funds: Funds,
items: List<Archive>,
onItemClick: (Archive) -> Unit,
modifier: Modifier = Modifier,
) {
val currentFunds = stringResource(R.string.current_funds)
val archiveFree = stringResource(R.string.archive_free)
val archiveOriginal = stringResource(R.string.archive_original)
val archiveResample = stringResource(R.string.archive_resample)
val fundsStyle = MaterialTheme.typography.labelLarge
val (hAtH, nonHAtH) = remember(items) { items.partition { it.isHAtH } }
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(text = currentFunds, textAlign = TextAlign.Center)
}
item {
FundsItem(
type = "GP",
amount = funds.gp,
modifier = Modifier.fillMaxWidth(),
textStyle = fundsStyle,
)
}
item {
FundsItem(
type = "C",
amount = funds.credit,
modifier = Modifier.fillMaxWidth(),
textStyle = fundsStyle,
)
}
items(nonHAtH) {
ArchiveItem(
name = if (it.res == "org") archiveOriginal else archiveResample,
cost = if (it.cost == "Free!") archiveFree else it.cost,
size = it.size,
onClick = { onItemClick(it) },
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(text = "H@H")
}
}
items(hAtH) {
ArchiveItem(
name = if (it.res == "org") archiveOriginal else it.name,
cost = if (it.cost == "Free") archiveFree else it.cost,
size = it.size,
onClick = { onItemClick(it) },
)
}
}
}
@Composable
private fun ArchiveItem(
name: String,
cost: String,
size: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val labelStyle = MaterialTheme.typography.labelMedium
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = cost,
style = labelStyle,
)
Button(onClick = onClick) {
Text(text = name)
}
Text(
text = size,
style = labelStyle,
)
}
}
@@ -2,10 +2,10 @@ package com.hippo.ehviewer.ui.main
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
@@ -18,19 +18,24 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.ConstraintSet
import androidx.constraintlayout.compose.Dimension
import arrow.core.Tuple9
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUtils
@@ -40,6 +45,51 @@ import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
import com.hippo.ehviewer.util.FileUtils
private val ids = Tuple9(1, 2, 3, 4, 5, 6, 7, 8, 9)
private val constraintSet = ConstraintSet {
val (
titleRef, uploaderRef, ratingRef, categoryRef, actionsRef, stateTextRef,
progressBarRef, progressTextRef, speedRef,
) = createRefsFor(1, 2, 3, 4, 5, 6, 7, 8, 9)
constrain(titleRef) {
top.linkTo(parent.top)
width = Dimension.matchParent
}
constrain(uploaderRef) {
start.linkTo(parent.start)
bottom.linkTo(actionsRef.top)
}
constrain(ratingRef) {
start.linkTo(parent.start)
top.linkTo(actionsRef.top, 1.dp)
}
constrain(categoryRef) {
start.linkTo(parent.start)
bottom.linkTo(parent.bottom)
}
constrain(progressBarRef) {
bottom.linkTo(actionsRef.top)
width = Dimension.matchParent
}
constrain(progressTextRef) {
bottom.linkTo(progressBarRef.top)
start.linkTo(progressBarRef.start)
}
constrain(speedRef) {
bottom.linkTo(progressBarRef.top)
end.linkTo(progressBarRef.end)
}
constrain(actionsRef) {
end.linkTo(parent.end, (-4).dp)
bottom.linkTo(parent.bottom, (-4).dp)
}
constrain(stateTextRef) {
end.linkTo(parent.end)
bottom.linkTo(actionsRef.top)
}
}
@Composable
fun DownloadCard(
onClick: () -> Unit,
@@ -48,143 +98,122 @@ fun DownloadCard(
onStart: () -> Unit,
onStop: () -> Unit,
info: DownloadInfo,
selectMode: Boolean,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
CrystalCard(
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
) {
Row {
Card(onClick = onThumbClick) {
) = CrystalCard(modifier = modifier, onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource) {
Row {
val thumb = remember {
movableContentOf<DownloadInfo> {
EhAsyncCropThumb(
key = info,
modifier = Modifier.aspectRatio(DEFAULT_ASPECT).fillMaxSize(),
key = it,
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
)
}
val stateFailed = stringResource(R.string.download_state_failed)
val stateFailed2 = stringResource(R.string.download_state_failed_2, info.legacy)
val downloadState by DownloadManager.collectDownloadState(info.gid)
val stateText = when (downloadState) {
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
DownloadInfo.STATE_DOWNLOAD -> null
DownloadInfo.STATE_FAILED -> if (info.legacy <= 0) stateFailed else stateFailed2
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_finish)
else -> null // Chill, will be removed soon
}
if (selectMode) {
Card {
thumb(info)
}
ConstraintLayout(modifier = Modifier.padding(8.dp, 4.dp).fillMaxSize()) {
val (
titleRef, uploaderRef, ratingRef, categoryRef, actionsRef, stateTextRef,
progressBarRef, progressTextRef, speedRef,
) = createRefs()
Text(
text = EhUtils.getSuitableTitle(info),
maxLines = 2,
modifier = Modifier.constrainAs(titleRef) {
top.linkTo(parent.top)
start.linkTo(parent.start)
}.fillMaxWidth(),
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
if (downloadState != DownloadInfo.STATE_DOWNLOAD) {
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
info.uploader?.let {
Text(
text = it,
modifier = Modifier.constrainAs(uploaderRef) {
start.linkTo(parent.start)
bottom.linkTo(ratingRef.top)
}.alpha(if (info.disowned) 0.5f else 1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
GalleryListCardRating(
rating = info.rating,
modifier = Modifier.constrainAs(ratingRef) {
start.linkTo(parent.start)
bottom.linkTo(categoryRef.top)
},
)
val categoryColor = EhUtils.getCategoryColor(info.category)
val categoryText = EhUtils.getCategory(info.category).uppercase()
} else {
Card(onClick = onThumbClick) {
thumb(info)
}
}
val stateFailed = stringResource(R.string.download_state_failed)
val stateFailed2 = stringResource(R.string.download_state_failed_2, info.legacy)
val downloadState by DownloadManager.collectDownloadState(info.gid)
val stateText = when (downloadState) {
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
DownloadInfo.STATE_DOWNLOAD -> null
DownloadInfo.STATE_FAILED -> if (info.legacy <= 0) stateFailed else stateFailed2
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_finish)
else -> null // Chill, will be removed soon
}
ConstraintLayout(
modifier = Modifier.padding(start = 8.dp, top = 2.dp, end = 4.dp, bottom = 4.dp).fillMaxSize(),
constraintSet = constraintSet,
) {
val (
titleRef, uploaderRef, ratingRef, categoryRef, actionsRef, stateTextRef,
progressBarRef, progressTextRef, speedRef,
) = ids
Text(
text = EhUtils.getSuitableTitle(info),
maxLines = 2,
modifier = Modifier.layoutId(titleRef),
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
if (downloadState != DownloadInfo.STATE_DOWNLOAD) {
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
info.uploader?.let {
Text(
text = categoryText,
modifier = Modifier.constrainAs(categoryRef) {
start.linkTo(parent.start)
bottom.linkTo(parent.bottom)
}.clip(ShapeDefaults.Small).background(categoryColor).padding(vertical = 2.dp, horizontal = 8.dp),
color = if (Settings.harmonizeCategoryColor) Color.Unspecified else EhUtils.categoryTextColor,
textAlign = TextAlign.Center,
text = it,
modifier = Modifier.layoutId(uploaderRef).alpha(if (info.disowned) 0.5f else 1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} else {
val (total, finished, speed) = DownloadManager.updatedDownloadInfo(info) {
Triple(total, finished, speed)
}
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
if (total <= 0 || finished < 0) {
LinearProgressIndicator(
modifier = Modifier.constrainAs(progressBarRef) {
bottom.linkTo(actionsRef.top)
}.fillMaxWidth(),
)
} else {
LinearProgressIndicator(
progress = { finished.toFloat() / total.toFloat() },
modifier = Modifier.constrainAs(progressBarRef) {
bottom.linkTo(actionsRef.top)
}.fillMaxWidth(),
)
Text(
text = "$finished/$total",
modifier = Modifier.constrainAs(progressTextRef) {
bottom.linkTo(progressBarRef.top)
start.linkTo(progressBarRef.start)
},
)
}
Text(
text = FileUtils.humanReadableByteCount(speed.coerceAtLeast(0), false) + "/S",
modifier = Modifier.constrainAs(speedRef) {
bottom.linkTo(progressBarRef.top)
end.linkTo(progressBarRef.end)
},
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.constrainAs(actionsRef) {
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
},
) {
if (downloadState == DownloadInfo.STATE_WAIT || downloadState == DownloadInfo.STATE_DOWNLOAD) {
IconButton(onClick = onStop) {
Icon(imageVector = Icons.Default.Pause, contentDescription = null)
}
} else {
IconButton(onClick = onStart) {
Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null)
}
}
}
if (stateText != null) {
GalleryListCardRating(
rating = info.rating,
modifier = Modifier.layoutId(ratingRef),
)
val categoryColor = EhUtils.getCategoryColor(info.category)
val categoryText = EhUtils.getCategory(info.category).uppercase()
Text(
text = stateText,
modifier = Modifier.constrainAs(stateTextRef) {
end.linkTo(parent.end, 16.dp)
bottom.linkTo(actionsRef.top)
},
style = MaterialTheme.typography.titleSmall,
text = categoryText,
modifier = Modifier.layoutId(categoryRef).clip(ShapeDefaults.Small)
.background(categoryColor).padding(vertical = 2.dp, horizontal = 8.dp),
color = if (Settings.harmonizeCategoryColor) Color.Unspecified else EhUtils.categoryTextColor,
textAlign = TextAlign.Center,
)
}
} else {
val (total, finished, speed) = DownloadManager.updatedDownloadInfo(info) {
Triple(total, finished, speed)
}
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
if (total <= 0 || finished < 0) {
LinearProgressIndicator(modifier = Modifier.layoutId(progressBarRef))
} else {
LinearProgressIndicator(
progress = { finished.toFloat() / total.toFloat() },
modifier = Modifier.layoutId(progressBarRef),
)
Text(
text = "$finished/$total",
modifier = Modifier.layoutId(progressTextRef),
)
}
Text(
text = FileUtils.humanReadableByteCount(speed.coerceAtLeast(0), false) + "/S",
modifier = Modifier.layoutId(speedRef),
)
}
}
val running = downloadState == DownloadInfo.STATE_WAIT || downloadState == DownloadInfo.STATE_DOWNLOAD
val icon = remember {
movableContentOf<Boolean> {
Icon(imageVector = if (it) Icons.Default.Pause else Icons.Default.PlayArrow, contentDescription = null)
}
}
if (selectMode) {
Box(modifier = Modifier.layoutId(actionsRef).minimumInteractiveComponentSize()) {
icon(running)
}
} else {
IconButton(onClick = if (running) onStop else onStart, modifier = Modifier.layoutId(actionsRef)) {
icon(running)
}
}
if (stateText != null) {
Text(
text = stateText,
modifier = Modifier.layoutId(stateTextRef),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
@@ -7,11 +7,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.Image
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.request.ImageRequest
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.ktbuilder.imageRequest
@@ -29,13 +28,15 @@ fun requestOf(model: GalleryInfo): ImageRequest {
fun EhAsyncThumb(
model: GalleryInfo,
modifier: Modifier = Modifier,
placeholder: Painter? = null,
onSuccess: ((Image) -> Unit)? = null,
contentScale: ContentScale = ContentScale.Fit,
) = AsyncImage(
model = requestOf(model),
contentDescription = null,
modifier = modifier,
placeholder = placeholder,
onSuccess = onSuccess?.let { callback ->
{ callback(it.result.image) }
},
contentScale = contentScale,
)
@@ -49,11 +50,9 @@ fun EhAsyncCropThumb(
model = requestOf(key),
contentDescription = null,
modifier = modifier,
onState = {
if (it is AsyncImagePainter.State.Success) {
if (it.result.image.shouldCrop) {
contentScale = ContentScale.Crop
}
onSuccess = {
if (it.result.image.shouldCrop) {
contentScale = ContentScale.Crop
}
},
contentScale = contentScale,
@@ -3,7 +3,6 @@ package com.hippo.ehviewer.ui.main
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
@@ -12,6 +11,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
@@ -23,7 +23,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@@ -31,27 +30,27 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import com.hippo.ehviewer.ui.tools.PredictiveBackEasing
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
import com.hippo.ehviewer.ui.tools.snackBarPadding
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.onEachLatest
import moe.tarsin.coroutines.runSuspendCatching
enum class FabLayoutValue {
Hidden,
Primary,
Expand,
}
@Stable
enum class FabLayoutValue { Hidden, Primary, Expand }
@Stable
@Composable
fun rememberFabLayoutState(initialValue: FabLayoutValue = FabLayoutValue.Primary): FabLayoutState {
return remember { FabLayoutState(initialValue) }
fun rememberFabLayoutState(initialValue: FabLayoutValue = FabLayoutValue.Primary) = remember {
FabLayoutState(initialValue)
}
@Stable
@@ -97,9 +96,11 @@ class FabLayoutState(
}
fun interface FabBuilder {
fun onClick(icon: ImageVector, that: suspend () -> Unit)
fun onClick(icon: ImageVector, autoClose: Boolean, that: suspend () -> Unit)
fun onClick(icon: ImageVector, that: suspend () -> Unit) = onClick(icon, true, that)
}
context(CoroutineScope)
@Composable
fun FabLayout(
hidden: Boolean,
@@ -132,7 +133,11 @@ fun FabLayout(
record { buildFab(builder) }
transform { onEachLatest { state.waitCollapse() } }
}
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
val interval = remember(density) { with(density) { FabInterval.roundToPx() } }
val padding = remember(density) { with(density) { FabPadding.roundToPx() } }
if (updatedExpanded && autoCancel) {
Spacer(
modifier = Modifier.fillMaxSize().pointerInput(Unit) {
@@ -140,77 +145,76 @@ fun FabLayout(
},
)
}
val appearState by state.appearProgress.asState()
val animatedProgress by state.expandProgress.asState()
PredictiveBackHandler(updatedExpanded) { flow ->
try {
state.mutatorMutex.mutate(MutatePriority.UserInput) {
flow.collect {
val eased = PredictiveBackEasing.transform(it.progress)
state.expandProgress.snapTo(1 - eased)
}
}
onExpandChanged(false)
} catch (e: CancellationException) {
launch { state.expand() }
}
}
Box(
modifier = Modifier.fillMaxSize().navigationBarsPadding().padding(16.dp).snackBarPadding(),
modifier = Modifier.fillMaxSize().navigationBarsPadding().snackBarPadding(),
contentAlignment = Alignment.BottomEnd,
) {
Box(
modifier = Modifier.graphicsLayer {
if (!state.appearProgress.isRunning && !updatedHidden) {
Box(
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = animatedProgress },
contentAlignment = Alignment.BottomEnd,
) {
with(secondaryFab) {
forEachIndexed { index, (imageVector, autoClose, onClick) ->
SmallFloatingActionButton(
onClick = {
launch(Dispatchers.Default) {
onClick()
if (autoClose) onExpandChanged(false)
}
},
modifier = Modifier.padding(20.dp).offset {
val distance = lerp(0, interval * (size - index) + padding, animatedProgress)
IntOffset(0, -distance)
},
) {
Icon(imageVector = imageVector, contentDescription = null)
}
}
}
}
}
val appearState by state.appearProgress.asState()
FloatingActionButton(
onClick = { onExpandChanged(!updatedExpanded) },
modifier = Modifier.padding(16.dp).graphicsLayer {
rotationZ = lerp(-90f, 0f, appearState)
scaleX = appearState
scaleY = appearState
},
contentAlignment = Alignment.Center,
) {
val animatedProgress by state.expandProgress.asState()
PredictiveBackHandler(updatedExpanded) { flow ->
try {
state.mutatorMutex.mutate(MutatePriority.UserInput) {
flow.collect {
val eased = EaseOut.transform(it.progress)
state.expandProgress.snapTo(1 - eased)
}
}
onExpandChanged(false)
} catch (e: CancellationException) {
coroutineScope.launch {
state.expand()
}
}
}
FloatingActionButton(onClick = { onExpandChanged(!updatedExpanded) }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = lerp(-135f, 0f, animatedProgress)
},
)
}
secondaryFab.run {
forEachIndexed { index, (imageVector, onClick) ->
SmallFloatingActionButton(
onClick = {
coroutineScope.launchIO {
runSuspendCatching {
onClick()
}
onExpandChanged(false)
}
},
modifier = Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
val distance = lerp(0, 150 * (size - index) + 50, animatedProgress)
placeable.placeRelative(0, -distance, -(size - index).toFloat())
}
}.graphicsLayer {
alpha = animatedProgress
},
) {
Icon(imageVector = imageVector, contentDescription = null)
}
}
}
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = lerp(-135f, 0f, animatedProgress)
},
)
}
}
}
private fun buildFab(builder: FabBuilder.() -> Unit) = buildList {
builder { icon, action ->
add(icon to action)
builder { icon, autoClose, action ->
add(Triple(icon, autoClose, action))
}
}
private val FabInterval = 56.dp // Small FAB height + 16 dp
private val FabPadding = 8.dp // (FAB height - small FAB height) / 2
const val FAB_ANIMATE_TIME = 300
@@ -0,0 +1,32 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Badge
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
@Composable
fun FundsItem(
type: String,
amount: String,
modifier: Modifier = Modifier,
textStyle: TextStyle = LocalTextStyle.current,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
Badge(containerColor = MaterialTheme.colorScheme.primaryContainer) {
Text(text = type)
}
Text(text = amount, style = textStyle)
}
}
@@ -5,12 +5,13 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
@@ -25,16 +26,14 @@ import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BrushPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
@@ -42,6 +41,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.ConstraintSet
import androidx.constraintlayout.compose.Dimension
import arrow.core.Tuple7
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUtils
@@ -60,15 +60,15 @@ private val constraintSet = ConstraintSet {
createRefsFor(1, 2, 3, 4, 5, 6, 7)
constrain(titleRef) {
top.linkTo(parent.top)
start.linkTo(parent.start)
width = Dimension.matchParent
}
constrain(uploaderRef) {
start.linkTo(parent.start)
bottom.linkTo(ratingRef.top)
bottom.linkTo(iconsRef.top)
}
constrain(ratingRef) {
start.linkTo(parent.start)
bottom.linkTo(categoryRef.top)
top.linkTo(iconsRef.top, 1.dp)
}
constrain(categoryRef) {
start.linkTo(parent.start)
@@ -76,7 +76,7 @@ private val constraintSet = ConstraintSet {
}
constrain(iconsRef) {
end.linkTo(parent.end)
bottom.linkTo(postedRef.top)
bottom.linkTo(categoryRef.top)
}
constrain(favRef) {
end.linkTo(parent.end)
@@ -84,7 +84,7 @@ private val constraintSet = ConstraintSet {
}
constrain(postedRef) {
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
linkTo(categoryRef.top, parent.bottom)
}
}
@@ -107,15 +107,18 @@ fun GalleryInfoListItem(
Card {
EhAsyncCropThumb(
key = info,
modifier = Modifier.aspectRatio(DEFAULT_ASPECT).fillMaxSize(),
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
)
}
ConstraintLayout(modifier = Modifier.padding(8.dp, 4.dp).fillMaxSize(), constraintSet = constraintSet) {
ConstraintLayout(
modifier = Modifier.padding(start = 8.dp, top = 2.dp, end = 4.dp, bottom = 4.dp).fillMaxSize(),
constraintSet = constraintSet,
) {
val (titleRef, uploaderRef, ratingRef, categoryRef, postedRef, favRef, iconsRef) = ids
Text(
text = EhUtils.getSuitableTitle(info),
maxLines = 2,
modifier = Modifier.layoutId(titleRef).fillMaxWidth(),
modifier = Modifier.layoutId(titleRef),
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
@@ -145,6 +148,8 @@ fun GalleryInfoListItem(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.layoutId(iconsRef),
) {
// Placeholder to reserve minimum height
Text(text = "")
val download by DownloadManager.collectContainDownloadInfo(info.gid)
if (download) {
Icon(
@@ -153,7 +158,9 @@ fun GalleryInfoListItem(
modifier = Modifier.size(16.dp),
)
}
Text(text = info.simpleLanguage.orEmpty())
info.simpleLanguage?.let {
Text(text = it)
}
if (info.pages != 0 && showPages) {
Text(text = "${info.pages}P")
}
@@ -163,11 +170,12 @@ fun GalleryInfoListItem(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.layoutId(favRef),
) {
// Placeholder to reserve minimum height
Text(text = "")
if (isInFavScene) {
Text(
text = info.favoriteNote.orEmpty(),
fontStyle = FontStyle.Italic,
)
info.favoriteNote?.let {
Text(text = it, fontStyle = FontStyle.Italic)
}
} else {
val showFav by FavouriteStatusRouter.collectAsState(info) { it != NOT_FAVORITED }
if (showFav) {
@@ -176,7 +184,9 @@ fun GalleryInfoListItem(
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Text(text = info.favoriteName.orEmpty())
info.favoriteName?.let {
Text(text = it)
}
}
}
}
@@ -195,7 +205,8 @@ fun GalleryInfoGridItem(
onLongClick: () -> Unit,
info: GalleryInfo,
modifier: Modifier = Modifier,
badgeText: String? = info.simpleLanguage,
showLanguage: Boolean = true,
showPages: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = ElevatedCard(
modifier = modifier,
@@ -204,19 +215,20 @@ fun GalleryInfoGridItem(
interactionSource = interactionSource,
) {
Box {
val placeholder = remember {
val aspect = if (info.thumbHeight != 0) {
(info.thumbWidth.toFloat() / info.thumbHeight).coerceIn(MIN_ASPECT, MAX_ASPECT)
var ratio by remember(info) {
val ratio = if (info.thumbHeight != 0) {
(info.thumbWidth.toFloat() / info.thumbHeight).coerceIn(MIN_RATIO, MAX_RATIO)
} else {
DEFAULT_ASPECT
DEFAULT_RATIO
}
BrushPainter(Brush.linearGradient(PlaceholderColors, end = Offset(aspect, 1f)))
mutableFloatStateOf(ratio)
}
EhAsyncThumb(
model = info,
modifier = Modifier.fillMaxWidth(),
placeholder = placeholder,
contentScale = ContentScale.Crop,
modifier = Modifier.aspectRatio(ratio),
onSuccess = {
ratio = (it.width.toFloat() / it.height).coerceIn(MIN_RATIO, MAX_RATIO)
},
)
val categoryColor = EhUtils.getCategoryColor(info.category)
Badge(
@@ -224,15 +236,20 @@ fun GalleryInfoGridItem(
containerColor = categoryColor,
contentColor = if (Settings.harmonizeCategoryColor) contentColorFor(categoryColor) else EhUtils.categoryTextColor,
) {
if (badgeText != null) {
Text(text = badgeText)
val shouldShowLanguage = showLanguage && info.simpleLanguage != null
if (showPages && info.pages > 0) {
Text(text = "${info.pages}")
if (shouldShowLanguage) {
Spacer(modifier = Modifier.width(4.dp))
}
}
if (shouldShowLanguage) {
Text(text = "${info.simpleLanguage}")
}
}
}
}
private val PlaceholderColors = listOf(Color.Transparent, Color.Transparent)
private const val MIN_ASPECT = 0.33F
private const val MAX_ASPECT = 1.5F
const val DEFAULT_ASPECT = 0.67F
private const val MIN_RATIO = 0.5F
private const val MAX_RATIO = 1.5F
const val DEFAULT_RATIO = 0.67F
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
@@ -25,19 +26,24 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
@@ -54,7 +60,10 @@ import com.hippo.ehviewer.ui.screen.collectDetailSizeAsState
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
import com.hippo.ehviewer.util.displayString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@Stable
operator fun PaddingValues.plus(r: PaddingValues) = object : PaddingValues {
@@ -65,6 +74,7 @@ operator fun PaddingValues.plus(r: PaddingValues) = object : PaddingValues {
override fun calculateTopPadding() = l.calculateTopPadding() + r.calculateTopPadding()
}
context(CoroutineScope)
@Composable
fun GalleryList(
modifier: Modifier = Modifier,
@@ -76,20 +86,41 @@ fun GalleryList(
detailItemContent: @Composable (LazyGridItemScope.(BaseGalleryInfo) -> Unit),
thumbListState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
thumbItemContent: @Composable (LazyStaggeredGridItemScope.(BaseGalleryInfo) -> Unit),
refreshState: PullToRefreshState,
searchBarOffsetY: () -> Int,
scrollToTopOnRefresh: Boolean = true,
onRefresh: () -> Unit,
onLoading: () -> Unit,
) {
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v)
val combinedModifier = contentModifier.nestedScroll(refreshState.nestedScrollConnection)
Box(modifier = modifier.fillMaxSize()) {
var isRefreshing by remember { mutableStateOf(false) }
val refreshState = rememberPullToRefreshState()
Box(
modifier = modifier.fillMaxSize().pullToRefresh(
isRefreshing = isRefreshing,
state = refreshState,
enabled = data.loadState.refresh is LoadState.NotLoading,
) {
isRefreshing = true
launch {
if (data.loadState.prepend.endOfPaginationReached) {
onRefresh()
} else {
data.retry()
}
snapshotFlow { data.loadState }.drop(1).first { state ->
state.prepend !is LoadState.Loading && state.refresh !is LoadState.Loading
}
isRefreshing = false
}
},
) {
if (listMode == 0) {
val columnWidth by collectDetailSizeAsState()
FastScrollLazyVerticalGrid(
columns = GridCells.Adaptive(columnWidth),
modifier = combinedModifier,
modifier = contentModifier,
state = detailListState,
contentPadding = contentPadding + PaddingValues(marginH, marginV),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.gallery_list_interval)),
@@ -118,7 +149,7 @@ fun GalleryList(
val thumbColumns by Settings.thumbColumns.collectAsState()
FastScrollLazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(thumbColumns),
modifier = combinedModifier,
modifier = contentModifier,
state = thumbListState,
verticalItemSpacing = gridInterval,
horizontalArrangement = Arrangement.spacedBy(gridInterval),
@@ -144,23 +175,8 @@ fun GalleryList(
}
}
if (refreshState.isRefreshing) {
LaunchedEffect(Unit) {
if (data.loadState.prepend.endOfPaginationReached) {
onRefresh()
} else {
data.retry()
}
snapshotFlow { data.loadState }.drop(1).collect { state ->
if (state.prepend !is LoadState.Loading && state.refresh !is LoadState.Loading) {
refreshState.endRefresh()
}
}
}
}
when (val state = data.loadState.refresh) {
is LoadState.Loading -> if (!refreshState.isRefreshing && scrollToTopOnRefresh) {
is LoadState.Loading -> if (!isRefreshing && scrollToTopOnRefresh) {
LaunchedEffect(Unit) {
onLoading()
}
@@ -195,6 +211,13 @@ fun GalleryList(
ErrorTip(modifier = Modifier.widthIn(max = 228.dp), text = stringResource(id = R.string.gallery_list_empty_hit))
}
}
PullToRefreshDefaults.Indicator(
state = refreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter).padding(top = contentPadding.calculateTopPadding())
.offset { IntOffset(0, searchBarOffsetY()) },
)
}
}
@@ -90,7 +90,7 @@ fun EhPreviewItem(
Box(contentAlignment = Alignment.Center) {
CrystalCard(
onClick = onClick,
modifier = Modifier.aspectRatio(DEFAULT_ASPECT),
modifier = Modifier.aspectRatio(DEFAULT_RATIO),
) {
if (galleryPreview != null) {
EhAsyncPreview(
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff. Show more