Compare commits

...
263 Commits
Author SHA1 Message Date
FooIbar 6191ad74d3 Prepare for release 1.12.0
Releases / build (push) Has been cancelled
2024-08-19 21:01:58 +08:00
FooIbar 5aae8f5649 Fix thumbnail resolution setting not working 2024-08-19 17:45:39 +08:00
FooIbar 6886465afb Refine reader
Fix navigation when switching from `WebtoonViewer` to `PagerViewer`.

Hide navigation overlay and reset slider to follow pager on click.
2024-08-19 17:45:26 +08:00
FooIbar 5a46fc4573 Don't recycle visible images 2024-08-19 13:49:36 +08:00
renovate[bot] a08b8e96e5 Lock file maintenance 2024-08-19 01:05:11 +00:00
FooIbar 5aeded7a24 Don't show load state indicator when no hits found 2024-08-18 17:40:48 +08:00
FooIbar 1883b82159 Fix pager unclickability when image is not loaded 2024-08-18 17:40:37 +08:00
FooIbar 302b57d4ac Fix normal preview in exhentai
Remove force e-hentai thumb option due to infeasible.
2024-08-17 00:52:44 +08:00
FooIbar c6b793923e Temporarily disable shared element transitions 2024-08-16 20:20:13 +08:00
FooIbar 058c280fd7 Avoid out-of-screen shared element transitions 2024-08-16 19:45:19 +08:00
renovate[bot] 0fa42d731b Update agp to v8.7.0-alpha07 2024-08-16 00:45:15 +00:00
FooIbar 9b54b57934 Refactor reader prefetch logic (#1523)
* Refactor reader prefetch logic

* Refine
2024-08-16 00:46:44 +08:00
FooIbar d3ab3ba61f [no ci] Update workflow 2024-08-15 18:29:41 +08:00
renovate[bot] 921f570151 Update dependency gradle to v8.10 2024-08-14 12:46:28 +00:00
FooIbar 03ab435401 Fix preload in reader (#1517)
* Fix preload in reader

* Reduce preload page count for ArchivePageLoader
2024-08-14 12:58:06 +08:00
renovate[bot] b73704ede1 Update dependency com.google.devtools.ksp to v2.0.20-RC2-1.0.24 2024-08-14 01:37:30 +00:00
renovate[bot] 97b2181ff7 Update agp to v8.7.0-alpha06 2024-08-13 19:11:16 +00:00
renovate[bot] ef5e3f7c51 Update Kotlin & KSP to v2.0.20-RC2 2024-08-13 13:41:34 +00:00
FooIbar 8a29d46b38 Revert^3 "Update Compose snapshot and migrate to BTF2 (#1474)"
This reverts commit 22b5a175f4.
2024-08-13 18:54:38 +08:00
FooIbar 75d8ff4b64 Fix ComicInfo.xml for galleries with temp tags 2024-08-13 17:46:01 +08:00
FooIbar 33258ea554 Fix read progress insert crash when exiting reader 2024-08-13 17:40:21 +08:00
renovate[bot] a723b4ecd3 Lock file maintenance 2024-08-12 00:15:22 +00:00
renovate[bot] 78a237fb0e Update agp to v8.7.0-alpha05 2024-08-09 02:18:54 +00:00
renovate[bot] 8fe60c0163 Update dependency dev.chrisbanes.compose:compose-bom to v2024.08.00-alpha01 2024-08-08 06:19:26 +00:00
renovate[bot] ffe47b2ff2 Update dependency androidx.lifecycle:lifecycle-runtime-compose to v2.9.0-alpha01 2024-08-08 03:14:46 +00:00
renovate[bot] e05fffb41c Update dependency androidx.biometric:biometric-ktx to v1.4.0-alpha02 2024-08-08 03:14:00 +00:00
renovate[bot] 1fb5de2527 Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha10 2024-08-08 00:40:20 +00:00
renovate[bot] 46790a1fbe Update dependency androidx.profileinstaller:profileinstaller to v1.4.0-alpha02 2024-08-08 00:39:56 +00:00
renovate[bot] af1316f06b Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.0-alpha14 2024-08-07 22:11:39 +00:00
renovate[bot] 24564cac8d Update dependency androidx.paging:paging-compose to v3.3.2 2024-08-07 22:11:14 +00:00
renovate[bot] c4868f137b Update benchmark to v1.3.0-rc01 2024-08-07 20:38:52 +00:00
renovate[bot] 3ccfa0dd5b Update androidx.room to v2.7.0-alpha06 2024-08-07 20:38:26 +00:00
FooIbar fded0fd08d Fix GitHub authorization and clean up code 2024-08-07 18:37:51 +08:00
renovate[bot] b1afe9bcd1 Update agp to v8.7.0-alpha04 2024-08-06 22:54:54 +00:00
FooIbar 22b5a175f4 Reapply "Update Compose snapshot and migrate to BTF2 (#1474)"
This reverts commit 94b138f254.
2024-08-06 23:01:20 +08:00
FooIbar ee9e0df12e [no ci] Fix release workflow 2024-08-06 22:03:30 +08:00
FooIbar dd525baf88 Use encoded token to avoid detection & revocation (#1492) 2024-08-06 21:33:46 +08:00
FooIbar 9180603ead Fix a division by zero error in ScrollbarAdapter 2024-08-06 21:30:12 +08:00
renovate[bot] 66fe19369e Lock file maintenance 2024-08-05 00:38:20 +00:00
renovate[bot] b9db025f57 Update gradle/actions action to v4 2024-08-04 00:54:28 +00:00
FooIbar 44f6eca3aa Fix main thread IO (really) and float comparison 2024-08-03 14:55:05 +08:00
FooIbar 1ff1caf421 Fix list item container color
Also compile debug build with SDK 35 as sources for SDK 35 have been
available.
2024-08-03 08:42:33 +08:00
github-actions[bot] ec1355c0e7 Update Chrome version for default User-Agent 2024-08-01 11:21:29 +08:00
FooIbar d23a453f71 Don't let navigation overlay consume click event
So it won't take two actions to dismiss overlay & app bar.
2024-08-01 07:25:47 +08:00
FooIbar 94b138f254 Revert "Update Compose snapshot and migrate to BTF2 (#1474)"
This reverts commit abcef9a86e.
2024-08-01 03:06:53 +08:00
FooIbar c281daa4ea Update NDK to r27 2024-08-01 01:42:33 +08:00
FooIbar 4f9eba8c63 Update Kotlin and remove workaround 2024-08-01 01:08:41 +08:00
FooIbar a2547f1e8d Update page range filter limit 2024-07-31 18:54:51 +08:00
FooIbar abcef9a86e Update Compose snapshot and migrate to BTF2 (#1474)
* Update Compose snapshot and remove workaround

* Sync SearchBarInputField with upstream

* Migrate to BTF2 text fields

* Fix search page range error text container color

* Update snapshot to fix a SieveCache crash

https://r.android.com/3199791
2024-07-31 17:40:09 +08:00
FooIbar 7d6a9b10e4 Fix crash when opening archives simultaneously 2024-07-30 16:27:16 +08:00
renovate[bot] 9322b80790 Update dependency com.google.devtools.ksp to v2.0.0-1.0.24 2024-07-30 00:51:18 +00:00
FooIbar e12897bd13 Fix layout issue with comments that contain images 2024-07-29 21:59:40 +08:00
FooIbar 28279cc671 Fix page transitions when reading with volume keys 2024-07-29 18:19:43 +08:00
FooIbar 2531fa289b Update dependency sh.calvin.reorderable:reorderable to v2.4.0-alpha01 2024-07-29 10:47:13 +08:00
renovate[bot] 9673163e8f Lock file maintenance 2024-07-29 00:55:33 +00:00
renovate[bot] 3f7091f880 Update dependency sh.calvin.reorderable:reorderable to v2.3.0 2024-07-28 20:01:11 +00:00
FooIbar 5df75fd534 Don't save baseline profile in sources (#1462)
* Don't save baseline profile in sources

* Fixup
2024-07-28 17:25:36 +00:00
FooIbarandGG Bond 62061e54ee Add shared element transitions (#1049)
* Add shared element transitions

* Fixup CodeStyle

* Add a overload for `Modifier.sharedBounds`

* Add TransitionsVisibilityScope to hold multiple transition

* advance AnimatedVisibilityScope to TransitionsVisibilityScope

* composing use advance

* Correctly remember

* Convert to use `Set`

* share SharedContentState

* Refine

* Cleanup

* Cleanup

* Update snapshot

* Temporarily disable navigation transition

* Fix build

* Make `TransitionsVisibilityScope` a value class

* Naming

* Use `implicit` instead

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-07-28 14:11:47 +00:00
github-actions[bot] 436c07fb6b Update baseline profile 2024-07-28 21:34:06 +08:00
FooIbar a16f873f7d Fix crash when switching reading mode 2024-07-28 21:15:09 +08:00
FooIbar 98fdcf0588 Fix double free on closing failed-to-open archives 2024-07-28 20:48:56 +08:00
renovate[bot] 539ad1af30 Fix read encrypted archive 2024-07-28 12:05:04 +00:00
FooIbar 4370606977 [Reader] Show progress indicator & fix background 2024-07-28 19:45:18 +08:00
FooIbar 1ecdc07655 Graduate Compose Reader to stable 2024-07-28 17:29:50 +08:00
FooIbar 01a1113a07 [New Reader] Avoid initial scroll on reader start 2024-07-28 17:14:07 +08:00
renovate[bot] fd6c2977b0 Update plugin composeCompilerReportGenerator to v1.4.0 2024-07-28 08:50:51 +00:00
FooIbar 02e36e7021 [New Reader] Implement open archive intent 2024-07-28 14:00:23 +08:00
FooIbar 7f8e4aa003 [New Reader] Hide menu on scroll 2024-07-28 14:00:23 +08:00
FooIbar 8ce16bc918 [New Reader] Implement navigation overlay (#1445)
* [New Reader] Implement navigation overlay

* Fix typo

* Refine

* Reuse navigation regions
2024-07-26 23:45:41 +08:00
renovate[bot] fbe35a0fa0 Update agp to v8.7.0-alpha03 2024-07-25 23:43:05 +00:00
renovate[bot] 931f1ec514 Update dependency dev.chrisbanes.compose:compose-bom to v2024.07.00-alpha02 2024-07-25 17:31:31 +00:00
renovate[bot] 7c4a59e9a4 Update compose.destinations to v2.1.0-beta11 2024-07-25 13:33:39 +00:00
FooIbar c1c6f25763 [New Reader] Refine landscape zoom (#1442) 2024-07-25 18:53:16 +08:00
renovate[bot] e7f515c70f Update dependency androidx.paging:paging-compose to v3.3.1 2024-07-24 19:48:04 +00:00
renovate[bot] 64fecbfa52 Update dependency androidx.lifecycle:lifecycle-process to v2.8.4 2024-07-24 17:18:31 +00:00
renovate[bot] c6c181a9fa Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha09 2024-07-24 22:05:22 +08:00
FooIbar 53a1bd9959 [New Reader] Implement landscape zoom 2024-07-24 20:36:43 +08:00
FooIbar 7b1f94cf5a [New Reader] Implement custom image scale types 2024-07-24 09:02:45 +08:00
renovate[bot] 4b8ecbf6a7 Update agp to v8.7.0-alpha02 2024-07-22 21:19:13 +00:00
renovate[bot] 0e0b0519e7 Lock file maintenance 2024-07-22 02:12:22 +00:00
FooIbar 7fb3b7c785 Tweak baseline profile generation schedule
[no ci]
2024-07-21 15:00:14 +08:00
renovate[bot]andFooIbar e18ebb1feb Update compose.destinations to v2.1.0-beta10 (#1435)
* Update compose.destinations to v2.1.0-beta10

* 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-07-21 06:30:57 +00:00
FooIbar f76dcfc97b [New Reader] Misc improvements 2024-07-21 08:30:32 +08:00
renovate[bot] c0c630bd9f Update dependency me.saket.telephoto:zoomable to v0.12.1 2024-07-20 09:00:29 +00:00
FooIbar 04df7da5dc Remove obsolete workaround 2024-07-17 17:38:01 +08:00
FooIbar d156638a79 Fix comment screen refresh indicator padding 2024-07-17 17:33:44 +08:00
FooIbar c78fe0b72c [New Reader] Implement touch navigation 2024-07-17 17:32:08 +08:00
renovate[bot] 799c37ea71 Update dependency sh.calvin.reorderable:reorderable to v2.2.0 2024-07-17 05:42:34 +00:00
renovate[bot] 9559ca571b Update agp 2024-07-15 19:40:24 +00:00
renovate[bot] 2f5c1e11c6 Lock file maintenance 2024-07-15 01:07:35 +00:00
renovate[bot] 734c73fa4d Update dependency me.saket.telephoto:zoomable to v0.12.0 2024-07-13 19:51:02 +00:00
renovate[bot] c09173d62c Update dependency com.google.devtools.ksp to v2.0.0-1.0.23 2024-07-11 21:35:55 +00:00
renovate[bot] 533f50ed08 Update dependency gradle to v8.9 2024-07-11 18:03:51 +00:00
Dude so hot b4422fe910 Drop MAP_UNINITIALIZED
This is not available on modern MMU devices
2024-07-11 21:31:34 +08:00
Dude so hot 22e58a034f Fix memleak while do archive 2024-07-11 21:12:31 +08:00
FooIbar 784856d341 Revert "Update agp to v8.6.0-beta01"
This reverts commit b982705b2f.
2024-07-11 08:45:00 +08:00
renovate[bot] f82832ca36 Update dependency dev.chrisbanes.compose:compose-bom to v2024.07.00-alpha01 2024-07-11 00:15:30 +00:00
renovate[bot] 6a70aa792e Update benchmark to v1.3.0-beta02 2024-07-10 19:10:50 +00:00
renovate[bot] a0016dbf85 Update androidx.room to v2.7.0-alpha05 2024-07-10 19:10:30 +00:00
renovate[bot] b982705b2f Update agp to v8.6.0-beta01 2024-07-10 17:01:40 +00:00
FooIbar 4c36398bed Prepare for next iteration 2024-07-10 22:13:25 +08:00
FooIbar 7e1b3abcf6 Prepare for release 1.11.7
Releases / build (push) Has been cancelled
2024-07-10 22:12:56 +08:00
FooIbar 97bb65aa35 Fix search bar title flickering 2024-07-10 22:11:46 +08:00
FooIbar 98c1a377ca Fix main thread disk IO 2024-07-10 20:25:52 +08:00
FooIbar 2d2ce3281e Fix Path -> Uri conversion for paths contain '%'
This reverts commit ab2e4a8e2d.
2024-07-10 19:44:13 +08:00
renovate[bot] a28d3ba840 Update dependency org.jsoup:jsoup to v1.18.1 2024-07-10 08:41:55 +00:00
FooIbar ab2e4a8e2d Fix Path -> Uri conversion
No need to do it for '?' as that's not allowed in filename.
2024-07-10 12:59:29 +08:00
FooIbar 67ddba87b5 Fix workflow config
[no ci]
2024-07-09 22:29:28 +08:00
FooIbar 8164847fcb Prepare for next iteration 2024-07-09 22:03:40 +08:00
FooIbar 85b538bbd3 Prepare for release 1.11.6
Baseline profile generation / baseline-profile (push) Has been cancelled
Releases / build (push) Has been cancelled
2024-07-09 22:03:04 +08:00
FooIbar d00d700a4b Improve Path/Uri conversion 2024-07-09 21:51:41 +08:00
github-actions[bot] ae66318843 Update baseline profile 2024-07-09 19:15:53 +08:00
FooIbar 1b9aa6a762 Retrieve favorite notes when modifying favorites 2024-07-09 18:54:57 +08:00
renovate[bot]andFooIbar 306ce83de4 Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha08 (#1397)
* Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha08

* 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-07-09 07:01:49 +00:00
FooIbar 89f9735ab9 Update Rust edition to 2021 2024-07-09 11:07:10 +08:00
FooIbar 843e1cc6a0 Fix MediaStore DATE_ADDED/DATE_MODIFIED
They are in seconds not milliseconds as per the documentation.
2024-07-09 10:55:34 +08:00
FooIbar 1a53d0d207 Fix download notification not showing 2024-07-09 10:18:22 +08:00
renovate[bot] 45c02d4fd6 Update Rust crate quick-xml to 0.36 2024-07-08 20:06:06 +00:00
renovate[bot] 862dee0845 Lock file maintenance 2024-07-08 01:53:46 +00:00
FooIbar 809bbfeba7 Refine clean redundancy dialog 2024-07-08 00:12:23 +08:00
FooIbar 959c740175 AndroidFileSystem POC (#1383)
* AndroidFileSystem POC

* Cache image file paths

* Refine
2024-07-07 07:13:49 +00:00
FooIbar a718510c89 Implicitly add language filter tag
This partially reverts commit 673c1837e9.
2024-07-06 15:02:55 +08:00
FooIbar f7c2fd8af3 Renew GitHub token 2024-07-05 21:12:46 +08:00
FooIbar c76440f167 Address deprecation 2024-07-05 20:57:47 +08:00
FooIbar c3bd959ee5 Update Compose snapshot (#1381)
* Update Compose snapshot

https://issuetracker.google.com/344633766

* Fix search bar text field padding
2024-07-04 13:34:27 +00:00
FooIbar 0a25643b87 Fix native crash on archive open failures 2024-07-04 20:24:37 +08:00
FooIbar c805ca8aff Revert "Reuse the root download location UniFile object"
Reason for revert: Caused OOM.
2024-07-03 22:34:18 +08:00
FooIbar 699a1ab801 Refactor DownloadService to use flows 2024-07-03 19:59:12 +08:00
FooIbar ee091aee0f Rescan download directory when restoring downloads (#1372) 2024-07-02 05:50:20 +00:00
FooIbar 673c1837e9 Fix language filter being applied to quick search 2024-07-02 13:44:01 +08:00
FooIbar 3998913c17 Fix existing download dirname not being used (#1371)
Tweak clean redundancy logic and add a confirm dialog.
2024-07-02 13:41:31 +08:00
renovate[bot] 1e83d7128e Update androidx.lifecycle to v2.8.3 2024-07-01 17:29:06 +00:00
FooIbar 355289a576 Avoid relying on implementation details (#1357)
* Avoid relying on implementation details

* Refine

* Fixup

* Extract common logic of RawFile & TreeDocumentFile

* Reuse the root download location UniFile object
2024-07-01 20:00:13 +08:00
FooIbar ba3a41f0ee Limit search bar text to single line 2024-07-01 17:17:38 +08:00
github-actions[bot] fc6d0bd6ef Update Chrome version for default User-Agent 2024-07-01 14:26:38 +08:00
FooIbar 15ef0ae362 Exclude scrollbar area from the system gesture 2024-07-01 14:26:15 +08:00
renovate[bot] a598f8562d Lock file maintenance 2024-07-01 01:28:33 +00:00
github-actions[bot] 2860fed5ab Update baseline profile 2024-06-30 11:07:14 +08:00
FooIbar f247544439 Migrate to CMP's implementation of scrollbar 2024-06-30 11:06:12 +08:00
renovate[bot] fdc3d94d5c Update Rust crate quick-xml to 0.35 2024-06-29 20:08:16 +00:00
renovate[bot] afc2ff7478 Update dependency io.github.petertrr:kotlin-multiplatform-diff to v0.7.0 2024-06-29 15:38:04 +00:00
renovate[bot] 83072d4eee Update aboutlibraries to v11.2.2 2024-06-29 12:13:33 +00:00
renovate[bot] 7e7be8d40a Update agp to v8.6.0-alpha08 2024-06-27 21:42:03 +00:00
FooIbar 90d7555333 [no ci] Fix benchmark build type configuration 2024-06-27 14:48:55 +08:00
github-actions[bot] ba268cfe6d Update baseline profile 2024-06-27 12:37:32 +08:00
renovate[bot]andFooIbar 0a7a9beb84 Update dependency androidx.test.uiautomator:uiautomator to v2.4.0-alpha01 (#1354)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-06-27 04:09:18 +00:00
renovate[bot] b18b89db29 Update dependency androidx.test.ext:junit to v1.2.1 2024-06-27 03:54:12 +00:00
FooIbar cb2d99bdc1 Add automated baseline profile generation (#1349)
* Add automated baseline profile generation

* Create benchmark build type

And remove the `enabledRules` argument as it will be injected by
baseline profile gradle plugin.
2024-06-27 11:46:02 +08:00
renovate[bot]andFooIbar 4dd6d68163 Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha07 (#1351)
* Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha07

* 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-06-27 03:44:45 +00:00
renovate[bot] 28ecc9d479 Update dependency dev.chrisbanes.compose:compose-bom to v2024.06.00-alpha01 2024-06-26 23:16:46 +00:00
renovate[bot] c917117db3 Update kotlinx.serialization to v1.7.1 2024-06-25 17:19:22 +00:00
FooIbar 5c0d65dd60 Fix crop borders not working 2024-06-25 15:17:21 +08:00
FooIbar dfc59aa643 Target API 35 as it has reached platform stability
Still use 34 SDK in debug build as sources for 35 are not available yet.
2024-06-25 15:17:09 +08:00
renovate[bot] 6ab77e541f Update dependency com.android.application to v8.6.0-alpha07 2024-06-25 10:01:48 +08:00
renovate[bot] 6224f8f4ab Update Rust crate quick-xml to 0.34 2024-06-24 21:20:26 +00:00
renovate[bot] 3d269c4220 Lock file maintenance 2024-06-24 00:31:53 +00:00
FooIbar 176a802e96 Don't use Unicode ellipsis character for filename
It will be replaced by three dots.
2024-06-23 09:04:18 +08:00
renovate[bot] 417a06e140 Update dependency io.github.fornewid:material-motion-compose-core to v2.0.1 2024-06-22 21:19:47 +00:00
FooIbar 5c4f034532 Update Compose snapshot and vendor Reorderable
As `animateItemPlacement` has been removed.
2024-06-22 12:57:13 +08:00
FooIbar 1d50855f52 Workaround for displaying extremely long images (#1327)
* Workaround for displaying extremely long images

* Make hardware bitmap threshold configurable
2024-06-22 12:56:46 +08:00
renovate[bot] 0280450797 Update Rust crate quick-xml to 0.33 2024-06-21 16:58:19 +00:00
FooIbar d0c907c7ff Remove unused jitpack repo 2024-06-21 12:17:49 +08:00
FooIbar 1302e17c01 Migrate to ViewPager2 (#1335)
* Migrate to ViewPager2

* Remove code for unimplemented automatic background
2024-06-21 12:07:27 +08:00
FooIbar 33f83b7325 Switch PhotoView to active fork 2024-06-20 15:05:02 +08:00
FooIbar d2c49f5022 Upload all R8 reports 2024-06-19 12:26:32 +08:00
FooIbar 084883a308 Decouple CronetEngine 2024-06-19 12:14:20 +08:00
FooIbar 67a73da720 Revert "Update dependency com.android.application to v8.6.0-alpha06"
This reverts commit f2447fd122.
2024-06-19 01:21:01 +08:00
renovate[bot] f2447fd122 Update dependency com.android.application to v8.6.0-alpha06 2024-06-18 16:33:56 +00:00
FooIbar 8ddcb003e0 Move lint check to the check job (#1330)
* K2 lint

* Move lint check to the check job

* Fix CI
2024-06-18 16:25:38 +00:00
FooIbar 0bff011f72 Address deprecation 2024-06-18 18:15:29 +08:00
FooIbar f41d1d7306 Skip patching if the patch is already applied 2024-06-18 00:12:33 +08:00
renovate[bot] a30aa23b67 Update xmlutil to v0.90.1 2024-06-17 23:39:02 +08:00
FooIbar 583d30eb5f Update nettle to v3.10 2024-06-17 18:01:31 +08:00
FooIbar c53b0986a9 Use link options to exclude libarchive symbols 2024-06-17 18:00:56 +08:00
renovate[bot] 85703c8c47 Lock file maintenance 2024-06-17 07:57:38 +00:00
renovate[bot] 025ba0bf5b Lock file maintenance 2024-06-17 01:19:24 +00:00
renovate[bot] ac2def22d2 Update Rust crate android_logger to 0.14 2024-06-16 09:59:31 +00:00
FooIbar 895cfc7bc6 Minor refactor 2024-06-16 17:48:50 +08:00
FooIbar 49eb9d5077 Fix gid qualifier not working with language filter 2024-06-16 17:48:50 +08:00
FooIbar 5a3c165ff1 Update ktlint to 1.3.0 2024-06-16 17:48:50 +08:00
FooIbar 3946428df6 Migrate to the std implementation of OnceCell 2024-06-16 17:46:40 +08:00
FooIbar 471d9784d6 Use different schedule to avoid rate limits 2024-06-15 13:41:21 +08:00
FooIbar 0ba0afc0d0 Downgrade Compose to the last known good build 2024-06-14 20:44:18 +08:00
FooIbar 5049b791fb Fix FAB recomposing on item selected 2024-06-14 20:43:46 +08:00
renovate[bot] 9a3715c516 Update androidx.lifecycle to v2.8.2 2024-06-12 23:23:08 +00:00
renovate[bot] 4ee276f636 Update androidx.room to v2.7.0-alpha04 2024-06-12 20:27:55 +00:00
renovate[bot] d528b2d5d0 Update dependency io.github.petertrr:kotlin-multiplatform-diff to v0.6.0 2024-06-12 01:40:34 +00:00
FooIbar b3ee69d193 Clean up EhCookieStore 2024-06-11 14:21:51 +08:00
FooIbar f882b78ff6 Update NDK to r27 RC 1 2024-06-11 14:20:18 +08:00
renovate[bot] 7e58101a3f Update Rust crate quick-xml to 0.32 2024-06-10 15:25:59 +00:00
FooIbar 5450eb9b2b Revert "Update Compose snapshot"
This reverts commit 13cf42437f.
2024-06-10 13:04:40 +08:00
renovate[bot] 69695e3c99 Lock file maintenance 2024-06-10 01:02:53 +00:00
FooIbar 5b15059c04 Switch back to C implementation of sha1
Now that libnettle is built with hardware acceleration instructions
2024-06-09 23:11:57 +08:00
FooIbar f50d69cd60 Build libnettle with assembly code (#1292)
* Build libnettle with assembler code

* Add .gitattributes for shell script

* Patch nettle for CPU feature detection on Android

* Don't build libnettle at configuration time

* Fix build

* Refine
2024-06-09 13:49:36 +08:00
renovate[bot] df6f783a69 Update dependency com.android.application to v8.6.0-alpha05 2024-06-08 10:58:55 +00:00
Dude so hot f0291d94f9 Tell function return types to workaround compiler bug 2024-06-07 17:11:04 +08:00
Dude so hot 593f24b86f Refactor awaitInputTextWithCheckBox 2024-06-07 17:11:04 +08:00
Dude so hot 5bce898f99 Move goto dialog invalidator to use ensure 2024-06-07 17:11:04 +08:00
Dude so hot 2b444c262d Move archive dialog invalidator to use ensure 2024-06-07 17:11:04 +08:00
Dude so hot e40a7c75d9 Move page range invalidator to use ensure 2024-06-07 17:11:04 +08:00
Dude so hot 4a7c057260 Expr-ize 2024-06-07 17:11:04 +08:00
Dude so hot 229a00cd53 Let Dialog.awaitInputText use Raise to improve semantics 2024-06-07 17:11:04 +08:00
Dude so hot d2d0d880f1 Let Dialog.awaitResult use Raise to improve semantics 2024-06-07 17:11:04 +08:00
Dude so hot c5e17c1d5b Retry when FabLayoutState sync interrupted
Double click primary fab on select mode should "collapse to recompose secondary fabs" then immediately expand
2024-06-07 15:58:37 +08:00
Dude so hot 33ff2ebe06 Import arrow resilience 2024-06-07 15:58:37 +08:00
Dude so hot 5c13e188de Secondary fab recompose force fablayout collapse
Fix bug where fastly click primary fab twice on select mode, select state is cleared but secondary fabs not recomposed
2024-06-07 15:58:37 +08:00
FooIbar 60b03dcd0e Fix integrity check for non-H@H resampled images
Their URLs contain hashes for both original and resampled images.
2024-06-07 15:13:40 +08:00
renovate[bot] 4604a25f58 Update dependency io.github.fornewid:material-motion-compose-core to v2 2024-06-07 01:26:56 +00:00
FooIbar 99029b8b04 Prepare for next iteration 2024-06-06 22:07:52 +08:00
FooIbar 70f46442c6 Prepare for release 1.11.6-RC2
Releases / build (push) Has been cancelled
2024-06-06 22:07:23 +08:00
FooIbar 13cf42437f Update Compose snapshot
https://issuetracker.google.com/343750859
2024-06-06 21:54:22 +08:00
renovate[bot] 1e29f6dcb4 Update kotlinx.serialization to v1.7.0 2024-06-06 13:52:43 +00:00
renovate[bot] f25981792f Update dependency com.google.devtools.ksp to v2.0.0-1.0.22 2024-06-06 00:09:30 +00:00
FooIbar 934c8be3e4 Anti-tamper support 2024-06-04 15:13:09 +08:00
renovate[bot] b9994eacbc Lock file maintenance 2024-06-03 01:53:41 +00:00
FooIbar 704ab0ee7e Prepare for next iteration 2024-06-02 23:38:33 +08:00
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
253 changed files with 5134 additions and 11239 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
*.sh eol=lf
+8 -16
View File
@@ -38,16 +38,16 @@ jobs:
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
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
- name: Lint Check
run: ./gradlew lintMarshmallowRelease
default:
runs-on: ubuntu-latest
steps:
@@ -60,9 +60,6 @@ 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 x86_64-linux-android && rustup component add rustfmt clippy
@@ -74,10 +71,9 @@ jobs:
save-if: "false"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
gradle-home-cache-cleanup: true
- name: CMake Cache
uses: actions/cache@v4
@@ -111,7 +107,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: default-mapping-${{ github.sha }}
path: app/build/outputs/mapping/defaultRelease/mapping.txt
path: app/build/outputs/mapping/defaultRelease
- name: Upload native debug symbols
uses: actions/upload-artifact@v4
@@ -131,9 +127,6 @@ 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 x86_64-linux-android && rustup component add rustfmt clippy
@@ -145,10 +138,9 @@ jobs:
save-if: "false"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
gradle-home-cache-cleanup: true
- name: CMake Cache
uses: actions/cache@v4
@@ -182,7 +174,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: marshmallow-mapping-${{ github.sha }}
path: app/build/outputs/mapping/marshmallowRelease/mapping.txt
path: app/build/outputs/mapping/marshmallowRelease
- name: Upload native debug symbols
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -2,7 +2,7 @@ name: 'Lock Threads'
on:
schedule:
- cron: '0 0 * * *'
- cron: '0 8 * * *'
workflow_dispatch:
permissions:
+20 -5
View File
@@ -18,8 +18,16 @@ 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: Free disk space
run: |
shopt -s extglob
rm -rf ${ANDROID_SDK_ROOT}/ndk/!(27*)
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
@@ -36,10 +44,17 @@ jobs:
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
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
arguments: assembleRelease
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
- name: Generate Baseline Profile
run: ./gradlew generateBaselineProfile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
- name: Build
run: ./gradlew assembleRelease
- name: Rename Apks
run: |
+85
View File
@@ -1,5 +1,90 @@
# 变更日志
## [1.12.0] - 2024-08-19
### 重大变更
* Compose 阅读器已稳定,移除了旧阅读器实现 [#847](https://github.com/FooIbar/EhViewer/issues/847)
* 由于 E 站缩略图服务器变更,移除了强制使用 e-hentai 缩略图服务器的选项
### 改进
* 更新高级搜索选项中页数过滤器的范围限制以遵循 E 站变更
### Bug 修复
* 评论页面刷新指示器边距不正确 [#1428](https://github.com/FooIbar/EhViewer/issues/1428)
* 关闭打开失败的压缩包时崩溃
* 无法打开带密码的压缩包 [#1454](https://github.com/FooIbar/EhViewer/issues/1454)
* 使用音量键翻页时无法关闭过渡动画 [#1467](https://github.com/FooIbar/EhViewer/issues/1467)
* 带有图片的评论显示错位 [#1468](https://github.com/FooIbar/EhViewer/issues/1468)
* 在包含相同项目的页面间切换时崩溃 [#1490](https://github.com/FooIbar/EhViewer/issues/1490)
* 无法检查更新 [#1512](https://github.com/FooIbar/EhViewer/issues/1512)
* 画廊详情未加载完成时退出阅读器导致崩溃 [#1507](https://github.com/FooIbar/EhViewer/issues/1507)
* 无法为带有临时标签的画廊生成 ComicInfo.xml [#1509](https://github.com/FooIbar/EhViewer/issues/1509)
* 已下载的画廊翻页时出现加载动画 [#1510](https://github.com/FooIbar/EhViewer/issues/1510)
* 画廊详情页面的缩略图无法加载 [#1529](https://github.com/FooIbar/EhViewer/issues/1529)
* 图片未加载完成时无法通过点击翻页
* 搜索结果为空时解析失败 [#1418](https://github.com/FooIbar/EhViewer/issues/1418)
* 缩略图分辨率设置不起作用
## [1.11.7] - 2024-07-10
### Bug 修复
* 无法下载标题包含 # 或 % 的画廊
* 下载页数较多的画廊时应用无响应
* 退出搜索栏时文本闪烁
## [1.11.6] - 2024-07-09 [YANKED]
### 改进
* 某些设备上无法加载长图时可尝试减小 设置-高级 中的硬件位图阈值 [#1321](https://github.com/FooIbar/EhViewer/issues/1321)
* 清理下载冗余时显示确认对话框 [#1369](https://github.com/FooIbar/EhViewer/issues/1369)
* 修改收藏时显示收藏备注 [#1392](https://github.com/FooIbar/EhViewer/issues/1392)
### Bug 修复
* 图片完整性校验误报 [#1286](https://github.com/FooIbar/EhViewer/issues/1286)
* 某些情况下应用崩溃 [#1290](https://github.com/FooIbar/EhViewer/issues/1290) [#1302](https://github.com/FooIbar/EhViewer/issues/1302)
* 某些情况下渲染问题 [#1284](https://github.com/FooIbar/EhViewer/issues/1284)
* 无法下载标题过长的画廊 [#1340](https://github.com/FooIbar/EhViewer/issues/1340)
* 某些界面拖动滚动条时黑屏 [#1329](https://github.com/FooIbar/EhViewer/issues/1329)
* 搜索栏多行文本遮挡其他内容 [#1365](https://github.com/FooIbar/EhViewer/issues/1365)
* CIFS Documents Provider 兼容问题 [#1356](https://github.com/FooIbar/EhViewer/issues/1356)
* 清理下载冗余未按预期运行 [#1369](https://github.com/FooIbar/EhViewer/issues/1369)
* 读取压缩包失败时崩溃
* 某些设备上崩溃 [#1276](https://github.com/FooIbar/EhViewer/issues/1276)
* 检查更新失效
## [1.11.6-RC2] - 2024-06-06
### 改进
* 下载图片时执行完整性校验以防止文件损坏/被篡改
## [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
### 重大变更
-2
View File
@@ -94,8 +94,6 @@ Here is the libraries
- [libarchive](https://www.libarchive.org/)
- [jsoup](https://jsoup.org/)
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
# License
Copyright 2014-2019 Hippo Seven
+66 -45
View File
@@ -1,6 +1,6 @@
import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
import java.time.Instant
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag
val isRelease: Boolean
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
@@ -15,11 +15,15 @@ plugins {
alias(libs.plugins.spotless)
alias(libs.plugins.aboutlibrariesPlugin)
alias(libs.plugins.composeCompilerReportGenerator)
alias(libs.plugins.baselineprofile)
}
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
android {
compileSdk = 34
ndkVersion = "27.0.11718014-beta1"
compileSdk = 35
buildToolsVersion = "35.0.0"
ndkVersion = "27.0.12077973"
androidResources.generateLocaleConfig = true
splits {
@@ -27,7 +31,7 @@ android {
isEnable = true
reset()
if (isRelease) {
include("arm64-v8a", "x86_64", "armeabi-v7a")
include(*supportedAbis)
isUniversalApk = true
} else {
include("arm64-v8a", "x86_64")
@@ -48,6 +52,10 @@ android {
commandLine = "git rev-parse --short=7 HEAD".split(' ')
}.standardOutput.asText.get().trim()
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:")
@@ -59,9 +67,9 @@ android {
defaultConfig {
applicationId = "moe.tarsin.ehviewer"
minSdk = 26
targetSdk = 34
versionCode = 180056
versionName = "1.11.5"
targetSdk = 35
versionCode = 180059
versionName = "1.12.0"
// versionNameSuffix = "-SNAPSHOT"
resourceConfigurations.addAll(
listOf(
@@ -81,9 +89,13 @@ android {
)
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 {
@@ -114,33 +126,10 @@ android {
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
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",
)
}
lint {
checkReleaseBuilds = false
disable += setOf("MissingTranslation", "MissingQuantity")
fatal += setOf("NewApi", "InlinedApi")
error += setOf("InlinedApi")
}
packaging {
@@ -157,29 +146,32 @@ android {
isShrinkResources = true
proguardFiles("proguard-rules.pro")
signingConfig = signConfig
buildConfigField("long", "BUILD_TIME", "${Instant.now().epochSecond}")
}
debug {
applicationIdSuffix = ".debug"
buildConfigField("long", "BUILD_TIME", "0")
lint {
abortOnError = false
}
}
create("benchmarkRelease") {
initWith(buildTypes.getByName("release"))
matchingFallbacks += listOf("release")
applicationIdSuffix = ".benchmark"
signingConfig = signingConfigs.getByName("debug")
isDebuggable = false
}
}
buildFeatures {
buildConfig = true
compose = true
viewBinding = true
}
namespace = "com.hippo.ehviewer"
}
composeCompiler {
enableNonSkippingGroupOptimization = true
enableStrongSkippingMode = true
featureFlags = setOf(ComposeFeatureFlag.OptimizeNonSkippingGroups)
}
androidComponents {
@@ -193,6 +185,10 @@ androidComponents {
}
}
baselineProfile {
mergeIntoMain = true
}
dependencies {
// https://developer.android.com/jetpack/androidx/releases/activity
implementation(libs.androidx.activity.compose)
@@ -221,15 +217,11 @@ dependencies {
// https://developer.android.com/jetpack/androidx/releases/paging
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.recyclerview)
// https://developer.android.com/jetpack/androidx/releases/room
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.work.runtime)
implementation(libs.photoview) // Dead Dependency
implementation(libs.directionalviewpager) // Dead Dependency
implementation(libs.material.motion.core)
implementation(libs.bundles.splitties)
@@ -244,12 +236,11 @@ dependencies {
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.accompanist.drawable.painter)
implementation(libs.insetter) // Dead Dependency
implementation(libs.reorderable)
implementation(platform(libs.arrow.stack))
implementation(libs.arrow.fx.coroutines)
implementation(libs.arrow.resilience)
// https://coil-kt.github.io/coil/changelog/
implementation(platform(libs.coil.bom))
@@ -269,12 +260,40 @@ dependencies {
implementation(libs.cronet.embedded)
implementation(libs.androidx.profileinstaller)
"baselineProfile"(project(":benchmark"))
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.ExperimentalMaterial3ExpressiveApi",
"-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.compose.animation.ExperimentalSharedTransitionApi",
"-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 {
@@ -287,13 +306,15 @@ aboutLibraries {
duplicationRule = GROUP
}
val ktlintVersion = libs.ktlint.get().version
spotless {
kotlin {
// https://github.com/diffplug/spotless/issues/111
target("src/**/*.kt")
ktlint()
ktlint(ktlintVersion)
}
kotlinGradle {
ktlint()
ktlint(ktlintVersion)
}
}
+33 -38
View File
@@ -70,6 +70,39 @@
<data android:host="exhentai.org" />
<data android:host="e-hentai.org" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:scheme="content" />
<data android:host="*" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/x-7z-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" />
<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" />
<data android:mimeType="application/x-xz-compressed-tar" />
</intent-filter>
<intent-filter android:label="@string/keyword_search">
<action android:name="android.intent.action.SEND" />
@@ -85,44 +118,6 @@
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity
android:name="eu.kanade.tachiyomi.ui.reader.ReaderActivity"
android:configChanges="screenSize"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<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/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-compressed-tar" />
<data android:mimeType="application/x-bzip-compressed-tar" />
<data android:mimeType="application/x-lzma-compressed-tar" />
<data android:mimeType="application/x-xz-compressed-tar" />
</intent-filter>
</activity>
<activity
android:name=".ui.SecurityActivity"
android:configChanges="screenSize"
-29
View File
@@ -1,29 +0,0 @@
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
@@ -1,14 +1,14 @@
From 2dcc8be937499022c37678eb6bf4bd026b6fcbb0 Mon Sep 17 00:00:00 2001
From aee8f7147ddb262fd9e5feee8d5b17094cf3470f 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
Subject: [PATCH] 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
index ec97e4c7..420f204c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -438,7 +438,7 @@ IF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
+34 -15
View File
@@ -1,5 +1,6 @@
cmake_minimum_required(VERSION 3.14)
project(ehviewer C)
include(ExternalProject)
include(FetchContent)
if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
@@ -18,31 +19,47 @@ endif (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
option(BUILD_TESTING OFF)
set(XZ_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch)
FetchContent_Declare(
liblzma
GIT_REPOSITORY https://github.com/tukaani-project/xz.git
GIT_TAG v5.6.1
PATCH_COMMAND git apply ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch || cd .
GIT_TAG v5.6.2
PATCH_COMMAND git apply --check -R ${XZ_PATCH} || git apply ${XZ_PATCH}
)
FetchContent_MakeAvailable(liblzma)
include_directories(${liblzma_SOURCE_DIR}/src/liblzma/api)
# Build GNUTLS libnettle
FetchContent_Declare(
if (CMAKE_HOST_SYSTEM_NAME STREQUAL Windows)
execute_process(
COMMAND wsl wslpath ${CMAKE_CURRENT_LIST_DIR}
OUTPUT_VARIABLE CURRENT_DIR
OUTPUT_STRIP_TRAILING_WHITESPACE
)
else ()
set(CURRENT_DIR ${CMAKE_CURRENT_LIST_DIR})
endif ()
set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}")
ExternalProject_Add(
nettle
URL https://ftp.gnu.org/gnu/nettle/nettle-3.9.1.tar.gz
URL_MD5 29fcd2dec6bf5b48e5e3ffb3cbc4779e
SOURCE_DIR nettle-src/nettle
PREFIX nettle
URL https://ftp.gnu.org/gnu/nettle/nettle-3.10.tar.gz
URL_MD5 c61453139d5fb44e9cdcc5b684b26e55
CONFIGURE_COMMAND bash ${CURRENT_DIR}/configure_nettle.sh ${NDK_VERSION} ${CMAKE_C_COMPILER_TARGET}
BUILD_COMMAND bash -c "make libnettle.a CFLAGS='${CMAKE_C_FLAGS}'"
INSTALL_COMMAND ""
BUILD_IN_SOURCE 1
BUILD_BYPRODUCTS nettle/src/nettle/libnettle.a
)
FetchContent_MakeAvailable(nettle)
set(NETTLE_DIR ${nettle_SOURCE_DIR})
add_subdirectory(nettle)
ExternalProject_Get_Property(nettle SOURCE_DIR)
include_directories(${SOURCE_DIR}/..)
add_library(libnettle STATIC IMPORTED)
set_property(TARGET libnettle PROPERTY IMPORTED_LOCATION ${SOURCE_DIR}/libnettle.a)
add_dependencies(libnettle nettle)
# Configure libnettle support for libarchive
include_directories(${CMAKE_CURRENT_BINARY_DIR}/nettle-src)
include_directories(.)
set(HAVE_LIBNETTLE 1)
set(HAVE_NETTLE_AES_H 1)
set(HAVE_NETTLE_HMAC_H 1)
@@ -58,6 +75,7 @@ SET(HAVE_LZMA_STREAM_ENCODER_MT 1)
SET(HAVE_LZMADEC_H 1)
SET(HAVE_LIBLZMADEC 1)
option(ENABLE_OPENSSL OFF)
option(ENABLE_TAR OFF)
option(ENABLE_CPIO OFF)
option(ENABLE_CAT OFF)
@@ -65,13 +83,14 @@ option(ENABLE_UNZIP OFF)
option(ENABLE_TEST OFF)
# Configure libarchive link's static lib
SET(LIBARCHIVE_CUSTOM_LIBS "nettle" "liblzma")
SET(LIBARCHIVE_CUSTOM_LIBS "libnettle" "liblzma")
set(LIBARCHIVE_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Insert-link-libs.patch)
FetchContent_Declare(
libarchive
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 .
PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH}
)
FetchContent_MakeAvailable(libarchive)
@@ -88,5 +107,5 @@ 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)
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics)
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c hash.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics -Wl,--exclude-libs,ALL)
+39 -32
View File
@@ -49,10 +49,10 @@ typedef struct {
} entry;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static archive_ctx **ctx_pool;
static archive_ctx **ctx_pool = NULL;
#define CTX_POOL_SIZE 20
static void *mempool = NULL;
static void *mempool = MAP_FAILED;
static size_t *mempoolofs = NULL;
static int page_size = 0;
@@ -62,11 +62,11 @@ static int page_size = 0;
#define MEMPOOL_SIZE (mempoolofs[entryCount - 1])
#define PROT_RW (PROT_WRITE | PROT_READ)
#define MAP_ANON_POOL (MAP_ANONYMOUS | MAP_NORESERVE | MAP_PRIVATE | MAP_UNINITIALIZED)
#define MAP_ANON_POOL (MAP_ANONYMOUS | MAP_NORESERVE | MAP_PRIVATE)
static bool need_encrypt = false;
static char *passwd = NULL;
static void *archiveAddr = NULL;
static void *archiveAddr = MAP_FAILED;
static size_t archiveSize = 0;
static entry *entries = NULL;
static size_t entryCount = 0;
@@ -117,8 +117,8 @@ static inline int compare_entries(const void *a, const void *b) {
return strnatcmp(fa, fb);
}
static long archive_map_entries_index(archive_ctx *ctx, bool sort) {
long count = 0;
static int archive_map_entries_index(archive_ctx *ctx, bool sort) {
int count = 0;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
const char *name = archive_entry_pathname2(ctx->entry);
if (archive_entry_is_file(ctx->entry) && filename_is_playable_file(name)) {
@@ -165,8 +165,8 @@ static void mempool_prefault_pages(void *addr, size_t size) {
}
}
static long archive_list_all_entries(archive_ctx *ctx) {
long count = 0;
static int archive_list_all_entries(archive_ctx *ctx) {
int count = 0;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK)
if (archive_entry_is_playable(ctx->entry))
count++;
@@ -296,11 +296,12 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
LOGE("Allocate archive ctx pool failed:ENOMEM");
return 0;
}
long r = archive_alloc_ctx(&ctx);
int r = archive_alloc_ctx(&ctx);
if (r) {
if (r == -ENOMEM)
LOGE("%s", "Mem alloc failed");
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
return 0;
} else {
entryCount = archive_list_all_entries(ctx);
LOGI("%s%zu%s", "Found ", entryCount, " image entries in archive");
@@ -347,10 +348,6 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
r = archive_map_entries_index(ctx, sort_entries);
if (!archive_prealloc_mempool()) {
r = 0;
for (int i = 0; i < entryCount; ++i) {
free((void *) entries[i].filename);
}
free(entries);
}
}
@@ -403,19 +400,32 @@ JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
EH_UNUSED(env);
EH_UNUSED(thiz);
for (int i = 0; i < CTX_POOL_SIZE; i++)
archive_release_ctx(ctx_pool[i]);
if (ctx_pool) {
for (int i = 0; i < CTX_POOL_SIZE; i++)
archive_release_ctx(ctx_pool[i]);
free(ctx_pool);
ctx_pool = NULL;
}
free(passwd);
free(ctx_pool);
passwd = NULL;
need_encrypt = false;
munmap(archiveAddr, archiveSize);
munmap(mempool, MEMPOOL_SIZE);
if (archiveAddr != MAP_FAILED) {
munmap(archiveAddr, archiveSize);
archiveAddr = MAP_FAILED;
}
if (mempool != MAP_FAILED) {
munmap(mempool, MEMPOOL_SIZE);
mempool = MAP_FAILED;
}
free(mempoolofs);
for (int i = 0; i < entryCount; ++i) {
free((void *) entries[i].filename);
mempoolofs = NULL;
if (entries) {
for (int i = 0; i < entryCount; ++i) {
free((void *) entries[i].filename);
}
free(entries);
entries = NULL;
}
free(entries);
}
JNIEXPORT jboolean JNICALL
@@ -432,12 +442,9 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
archive_ctx *ctx;
jboolean ret = true;
int len = (*env)->GetStringUTFLength(env, str);
passwd = realloc(passwd, len * sizeof(char));
if (!passwd) {
LOGE("Allocate passwd buffer failed");
return false;
}
passwd = realloc(passwd, len + 1);
(*env)->GetStringUTFRegion(env, str, 0, len, passwd);
passwd[len] = 0;
archive_alloc_ctx(&ctx);
void *tmpBuf = alloca(4096);
while (archive_read_next_header(ctx->arc, &entry) == ARCHIVE_OK) {
@@ -486,7 +493,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz
if (!ADDR_IN_FILE_MAPPING(addr)) mempool_release_pages(addr, size);
}
JNIEXPORT jboolean JNICALL
JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz, jintArray fd_batch, jobjectArray names, jint arc_fd, jint size) {
EH_UNUSED(clazz);
struct archive *arc = archive_write_new();
@@ -508,15 +515,15 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz,
archive_entry_copy_stat(entry, &st);
archive_entry_set_perm(entry, 0644);
archive_write_header(arc, entry);
int len = read(fd, buff, sizeof(buff));
while (len > 0) {
archive_write_data(arc, buff, len);
int len;
do {
len = read(fd, buff, sizeof(buff));
}
archive_write_data(arc, buff, len);
} while (len > 0);
archive_write_finish_entry(arc);
archive_entry_clear(entry);
}
archive_entry_free(entry);
archive_write_close(arc);
archive_write_free(arc);
return true;
}
+6
View File
@@ -0,0 +1,6 @@
TOOLCHAIN=$ANDROID_HOME/ndk/$1/toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
TARGET=$2
./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
AR=$TOOLCHAIN/llvm-ar \
CC="$TOOLCHAIN/clang -target $TARGET" \
RANLIB=$TOOLCHAIN/llvm-ranlib
+39
View File
@@ -0,0 +1,39 @@
#include <unistd.h>
#include <jni.h>
#include <nettle/sha1.h>
#include "ehviewer.h"
#define BUFFER_SIZE 8192
typedef uint8_t byte;
const char hex_digits[] = "0123456789abcdef";
JNIEXPORT jstring JNICALL
Java_com_hippo_ehviewer_jni_HashKt_sha1(JNIEnv *env, jclass clazz, jint fd) {
EH_UNUSED(clazz);
struct sha1_ctx ctx;
sha1_init(&ctx);
size_t bytes_read;
byte buffer[BUFFER_SIZE];
while ((bytes_read = read(fd, buffer, BUFFER_SIZE)) > 0) {
sha1_update(&ctx, bytes_read, buffer);
}
byte digest[SHA1_DIGEST_SIZE];
sha1_digest(&ctx, SHA1_DIGEST_SIZE, digest);
byte byte;
char hex_digest[2 * SHA1_DIGEST_SIZE + 1];
for (int i = 0; i < SHA1_DIGEST_SIZE; i++) {
byte = digest[i];
hex_digest[2 * i] = hex_digits[byte >> 4 & 0xF];
hex_digest[2 * i + 1] = hex_digits[byte & 0xF];
}
hex_digest[2 * SHA1_DIGEST_SIZE] = '\0';
return (*env)->NewStringUTF(env, hex_digest);
}
-238
View File
@@ -1,238 +0,0 @@
cmake_minimum_required(VERSION 3.4.1)
project(nettle C)
set(LIBNETTLE_DEFINITIONS
-DHAVE_CONFIG_H)
set(LIBNETTLE_SOURCES
${NETTLE_DIR}/aes-decrypt-internal.c
${NETTLE_DIR}/aes-decrypt.c
${NETTLE_DIR}/aes-decrypt-table.c
${NETTLE_DIR}/aes128-decrypt.c
${NETTLE_DIR}/aes192-decrypt.c
${NETTLE_DIR}/aes256-decrypt.c
${NETTLE_DIR}/aes-encrypt-internal.c
${NETTLE_DIR}/aes-encrypt.c
${NETTLE_DIR}/aes-encrypt-table.c
${NETTLE_DIR}/aes128-encrypt.c
${NETTLE_DIR}/aes192-encrypt.c
${NETTLE_DIR}/aes256-encrypt.c
${NETTLE_DIR}/aes-invert-internal.c
${NETTLE_DIR}/aes-set-key-internal.c
${NETTLE_DIR}/aes-set-encrypt-key.c
${NETTLE_DIR}/aes-set-decrypt-key.c
${NETTLE_DIR}/aes128-set-encrypt-key.c
${NETTLE_DIR}/aes128-set-decrypt-key.c
${NETTLE_DIR}/aes128-meta.c
${NETTLE_DIR}/aes192-set-encrypt-key.c
${NETTLE_DIR}/aes192-set-decrypt-key.c
${NETTLE_DIR}/aes192-meta.c
${NETTLE_DIR}/aes256-set-encrypt-key.c
${NETTLE_DIR}/aes256-set-decrypt-key.c
${NETTLE_DIR}/aes256-meta.c
${NETTLE_DIR}/nist-keywrap.c
${NETTLE_DIR}/arcfour.c
${NETTLE_DIR}/arctwo.c
${NETTLE_DIR}/arctwo-meta.c
${NETTLE_DIR}/blowfish.c
${NETTLE_DIR}/blowfish-bcrypt.c
${NETTLE_DIR}/base16-encode.c
${NETTLE_DIR}/base16-decode.c
${NETTLE_DIR}/base16-meta.c
${NETTLE_DIR}/base64-encode.c
${NETTLE_DIR}/base64-decode.c
${NETTLE_DIR}/base64-meta.c
${NETTLE_DIR}/base64url-encode.c
${NETTLE_DIR}/base64url-decode.c
${NETTLE_DIR}/base64url-meta.c
${NETTLE_DIR}/buffer.c
${NETTLE_DIR}/buffer-init.c
${NETTLE_DIR}/camellia-crypt-internal.c
${NETTLE_DIR}/camellia-table.c
${NETTLE_DIR}/camellia-absorb.c
${NETTLE_DIR}/camellia-invert-key.c
${NETTLE_DIR}/camellia128-set-encrypt-key.c
${NETTLE_DIR}/camellia128-crypt.c
${NETTLE_DIR}/camellia128-set-decrypt-key.c
${NETTLE_DIR}/camellia128-meta.c
${NETTLE_DIR}/camellia192-meta.c
${NETTLE_DIR}/camellia256-set-encrypt-key.c
${NETTLE_DIR}/camellia256-crypt.c
${NETTLE_DIR}/camellia256-set-decrypt-key.c
${NETTLE_DIR}/camellia256-meta.c
${NETTLE_DIR}/cast128.c
${NETTLE_DIR}/cast128-meta.c
${NETTLE_DIR}/cbc.c
${NETTLE_DIR}/cbc-aes128-encrypt.c
${NETTLE_DIR}/cbc-aes192-encrypt.c
${NETTLE_DIR}/cbc-aes256-encrypt.c
${NETTLE_DIR}/ccm.c
${NETTLE_DIR}/ccm-aes128.c
${NETTLE_DIR}/ccm-aes192.c
${NETTLE_DIR}/ccm-aes256.c
${NETTLE_DIR}/cfb.c
${NETTLE_DIR}/siv-cmac.c
${NETTLE_DIR}/siv-cmac-aes128.c
${NETTLE_DIR}/siv-cmac-aes256.c
${NETTLE_DIR}/cnd-memcpy.c
${NETTLE_DIR}/chacha-crypt.c
${NETTLE_DIR}/chacha-core-internal.c
${NETTLE_DIR}/chacha-poly1305.c
${NETTLE_DIR}/chacha-poly1305-meta.c
${NETTLE_DIR}/chacha-set-key.c
${NETTLE_DIR}/chacha-set-nonce.c
${NETTLE_DIR}/ctr.c
${NETTLE_DIR}/ctr16.c
${NETTLE_DIR}/des.c
${NETTLE_DIR}/des3.c
${NETTLE_DIR}/eax.c
${NETTLE_DIR}/eax-aes128.c
${NETTLE_DIR}/eax-aes128-meta.c
${NETTLE_DIR}/ghash-set-key.c
${NETTLE_DIR}/ghash-update.c
${NETTLE_DIR}/gcm.c
${NETTLE_DIR}/gcm-aes.c
${NETTLE_DIR}/gcm-aes128.c
${NETTLE_DIR}/gcm-aes128-meta.c
${NETTLE_DIR}/gcm-aes192.c
${NETTLE_DIR}/gcm-aes192-meta.c
${NETTLE_DIR}/gcm-aes256.c
${NETTLE_DIR}/gcm-aes256-meta.c
${NETTLE_DIR}/gcm-camellia128.c
${NETTLE_DIR}/gcm-camellia128-meta.c
${NETTLE_DIR}/gcm-camellia256.c
${NETTLE_DIR}/gcm-camellia256-meta.c
${NETTLE_DIR}/cmac.c
${NETTLE_DIR}/cmac64.c
${NETTLE_DIR}/cmac-aes128.c
${NETTLE_DIR}/cmac-aes256.c
${NETTLE_DIR}/cmac-des3.c
${NETTLE_DIR}/cmac-aes128-meta.c
${NETTLE_DIR}/cmac-aes256-meta.c
${NETTLE_DIR}/cmac-des3-meta.c
${NETTLE_DIR}/gost28147.c
${NETTLE_DIR}/gosthash94.c
${NETTLE_DIR}/gosthash94-meta.c
${NETTLE_DIR}/hmac.c
${NETTLE_DIR}/hmac-gosthash94.c
${NETTLE_DIR}/hmac-md5.c
${NETTLE_DIR}/hmac-ripemd160.c
${NETTLE_DIR}/hmac-sha1.c
${NETTLE_DIR}/hmac-sha224.c
${NETTLE_DIR}/hmac-sha256.c
${NETTLE_DIR}/hmac-sha384.c
${NETTLE_DIR}/hmac-sha512.c
${NETTLE_DIR}/hmac-streebog.c
${NETTLE_DIR}/hmac-sm3.c
${NETTLE_DIR}/hmac-md5-meta.c
${NETTLE_DIR}/hmac-ripemd160-meta.c
${NETTLE_DIR}/hmac-sha1-meta.c
${NETTLE_DIR}/hmac-sha224-meta.c
${NETTLE_DIR}/hmac-sha256-meta.c
${NETTLE_DIR}/hmac-sha384-meta.c
${NETTLE_DIR}/hmac-sha512-meta.c
${NETTLE_DIR}/hmac-streebog-meta.c
${NETTLE_DIR}/hmac-sm3-meta.c
${NETTLE_DIR}/knuth-lfib.c
${NETTLE_DIR}/hkdf.c
${NETTLE_DIR}/md2.c
${NETTLE_DIR}/md2-meta.c
${NETTLE_DIR}/md4.c
${NETTLE_DIR}/md4-meta.c
${NETTLE_DIR}/md5.c
${NETTLE_DIR}/md5-compress.c
${NETTLE_DIR}/md5-compat.c
${NETTLE_DIR}/md5-meta.c
${NETTLE_DIR}/memeql-sec.c
${NETTLE_DIR}/memxor.c
${NETTLE_DIR}/memxor3.c
${NETTLE_DIR}/nettle-lookup-hash.c
${NETTLE_DIR}/nettle-meta-aeads.c
${NETTLE_DIR}/nettle-meta-armors.c
${NETTLE_DIR}/nettle-meta-ciphers.c
${NETTLE_DIR}/nettle-meta-hashes.c
${NETTLE_DIR}/nettle-meta-macs.c
${NETTLE_DIR}/pbkdf2.c
${NETTLE_DIR}/pbkdf2-hmac-gosthash94.c
${NETTLE_DIR}/pbkdf2-hmac-sha1.c
${NETTLE_DIR}/pbkdf2-hmac-sha256.c
${NETTLE_DIR}/pbkdf2-hmac-sha384.c
${NETTLE_DIR}/pbkdf2-hmac-sha512.c
${NETTLE_DIR}/poly1305-aes.c
${NETTLE_DIR}/poly1305-internal.c
${NETTLE_DIR}/realloc.c
${NETTLE_DIR}/ripemd160.c
${NETTLE_DIR}/ripemd160-compress.c
${NETTLE_DIR}/ripemd160-meta.c
${NETTLE_DIR}/salsa20-core-internal.c
${NETTLE_DIR}/salsa20-crypt-internal.c
${NETTLE_DIR}/salsa20-crypt.c
${NETTLE_DIR}/salsa20r12-crypt.c
${NETTLE_DIR}/salsa20-set-key.c
${NETTLE_DIR}/salsa20-set-nonce.c
${NETTLE_DIR}/salsa20-128-set-key.c
${NETTLE_DIR}/salsa20-256-set-key.c
${NETTLE_DIR}/sha1.c
${NETTLE_DIR}/sha1-compress.c
${NETTLE_DIR}/sha1-meta.c
${NETTLE_DIR}/sha256.c
${NETTLE_DIR}/sha256-compress-n.c
${NETTLE_DIR}/sha224-meta.c
${NETTLE_DIR}/sha256-meta.c
${NETTLE_DIR}/sha512.c
${NETTLE_DIR}/sha512-compress.c
${NETTLE_DIR}/sha384-meta.c
${NETTLE_DIR}/sha512-meta.c
${NETTLE_DIR}/sha512-224-meta.c
${NETTLE_DIR}/sha512-256-meta.c
${NETTLE_DIR}/sha3.c
${NETTLE_DIR}/sha3-permute.c
${NETTLE_DIR}/sha3-224.c
${NETTLE_DIR}/sha3-224-meta.c
${NETTLE_DIR}/sha3-256.c
${NETTLE_DIR}/sha3-256-meta.c
${NETTLE_DIR}/sha3-384.c
${NETTLE_DIR}/sha3-384-meta.c
${NETTLE_DIR}/sha3-512.c
${NETTLE_DIR}/sha3-512-meta.c
${NETTLE_DIR}/shake256.c
${NETTLE_DIR}/sm3.c
${NETTLE_DIR}/sm3-meta.c
${NETTLE_DIR}/serpent-set-key.c
${NETTLE_DIR}/serpent-encrypt.c
${NETTLE_DIR}/serpent-decrypt.c
${NETTLE_DIR}/serpent-meta.c
${NETTLE_DIR}/streebog.c
${NETTLE_DIR}/streebog-meta.c
${NETTLE_DIR}/twofish.c
${NETTLE_DIR}/twofish-meta.c
${NETTLE_DIR}/umac-nh.c
${NETTLE_DIR}/umac-nh-n.c
${NETTLE_DIR}/umac-l2.c
${NETTLE_DIR}/umac-l3.c
${NETTLE_DIR}/umac-poly64.c
${NETTLE_DIR}/umac-poly128.c
${NETTLE_DIR}/umac-set-key.c
${NETTLE_DIR}/umac32.c
${NETTLE_DIR}/umac64.c
${NETTLE_DIR}/umac96.c
${NETTLE_DIR}/umac128.c
${NETTLE_DIR}/version.c
${NETTLE_DIR}/write-be32.c
${NETTLE_DIR}/write-le32.c
${NETTLE_DIR}/write-le64.c
${NETTLE_DIR}/yarrow256.c
${NETTLE_DIR}/yarrow_key_event.c
${NETTLE_DIR}/xts.c
${NETTLE_DIR}/xts-aes128.c
${NETTLE_DIR}/xts-aes256.c
)
set(LIBNETTLE_INCLUDES
${CMAKE_CURRENT_SOURCE_DIR}
${NETTLE_DIR}
)
add_library(nettle STATIC ${LIBNETTLE_SOURCES})
target_include_directories(nettle PUBLIC ${LIBNETTLE_INCLUDES})
target_compile_definitions(nettle PRIVATE ${LIBNETTLE_DEFINITIONS})
-280
View File
@@ -1,280 +0,0 @@
/* config.h. Generated from config.h.in by configure. */
/* config.h.in. Generated from configure.ac by autoheader. */
/* Define if building universal (internal helper macro) */
/* #undef AC_APPLE_UNIVERSAL_BUILD */
/* Define to one of `_getb67', `GETB67', `getb67' for Cray-2 and Cray-YMP
systems. This function is required for `alloca.c' support on those systems.
*/
/* #undef CRAY_STACKSEG_END */
/* Define to 1 if using `alloca.c'. */
/* #undef C_ALLOCA */
/* Define to 1 if you have `alloca', as a function or macro. */
#define HAVE_ALLOCA 1
/* Define to 1 if you have <alloca.h> and it should be used (not on Ultrix).
*/
#define HAVE_ALLOCA_H 1
/* Define if __builtin_bswap64 is available */
#define HAVE_BUILTIN_BSWAP64 1
/* Define if clock_gettime is available */
#define HAVE_CLOCK_GETTIME 1
/* Define to 1 if you have the <dlfcn.h> header file. */
#define HAVE_DLFCN_H 1
/* Define if fcntl file locking is available */
#define HAVE_FCNTL_LOCKING 1
/* Define if the compiler understands __attribute__ */
#define HAVE_GCC_ATTRIBUTE 1
/* Define to 1 if you have the `getline' function. */
#define HAVE_GETLINE 1
/* Define to 1 if you have the <inttypes.h> header file. */
#define HAVE_INTTYPES_H 1
/* Define to 1 if you have dlopen (with -ldl). */
#define HAVE_LIBDL 1
/* Define to 1 if you have the `gmp' library (-lgmp). */
/* #undef HAVE_LIBGMP */
/* Define if compiler and linker supports __attribute__ ifunc */
#define HAVE_LINK_IFUNC 1
/* Define to 1 if you have the <malloc.h> header file. */
#define HAVE_MALLOC_H 1
/* Define to 1 if you have the <memory.h> header file. */
#define HAVE_MEMORY_H 1
/* Define to 1 each of the following for which a native (ie. CPU specific)
implementation of the corresponding routine exists. */
/* #undef HAVE_NATIVE_memxor3 */
/* #undef HAVE_NATIVE_aes_decrypt */
/* #undef HAVE_NATIVE_aes_encrypt */
/* #undef HAVE_NATIVE_aes128_decrypt */
/* #undef HAVE_NATIVE_aes128_encrypt */
/* #undef HAVE_NATIVE_aes128_invert_key */
/* #undef HAVE_NATIVE_aes128_set_decrypt_key */
/* #undef HAVE_NATIVE_aes128_set_encrypt_key */
/* #undef HAVE_NATIVE_aes192_decrypt */
/* #undef HAVE_NATIVE_aes192_encrypt */
/* #undef HAVE_NATIVE_aes192_invert_key */
/* #undef HAVE_NATIVE_aes192_set_decrypt_key */
/* #undef HAVE_NATIVE_aes192_set_encrypt_key */
/* #undef HAVE_NATIVE_aes256_decrypt */
/* #undef HAVE_NATIVE_aes256_encrypt */
/* #undef HAVE_NATIVE_aes256_invert_key */
/* #undef HAVE_NATIVE_aes256_set_decrypt_key */
/* #undef HAVE_NATIVE_aes256_set_encrypt_key */
/* #undef HAVE_NATIVE_cbc_aes128_encrypt */
/* #undef HAVE_NATIVE_cbc_aes192_encrypt */
/* #undef HAVE_NATIVE_cbc_aes256_encrypt */
/* #undef HAVE_NATIVE_chacha_core */
/* #undef HAVE_NATIVE_chacha_2core */
#define HAVE_NATIVE_chacha_3core 1
/* #undef HAVE_NATIVE_chacha_4core */
/* #undef HAVE_NATIVE_fat_chacha_2core */
/* #undef HAVE_NATIVE_fat_chacha_3core */
/* #undef HAVE_NATIVE_fat_chacha_4core */
/* #undef HAVE_NATIVE_ecc_curve25519_modp */
/* #undef HAVE_NATIVE_ecc_curve448_modp */
/* #undef HAVE_NATIVE_ecc_secp192r1_modp */
/* #undef HAVE_NATIVE_ecc_secp192r1_redc */
/* #undef HAVE_NATIVE_ecc_secp224r1_modp */
/* #undef HAVE_NATIVE_ecc_secp224r1_redc */
/* #undef HAVE_NATIVE_ecc_secp256r1_modp */
/* #undef HAVE_NATIVE_ecc_secp256r1_redc */
/* #undef HAVE_NATIVE_ecc_secp384r1_modp */
/* #undef HAVE_NATIVE_ecc_secp384r1_redc */
/* #undef HAVE_NATIVE_ecc_secp521r1_modp */
/* #undef HAVE_NATIVE_ecc_secp521r1_redc */
/* #undef HAVE_NATIVE_poly1305_set_key */
/* #undef HAVE_NATIVE_poly1305_block */
/* #undef HAVE_NATIVE_poly1305_digest */
/* #undef HAVE_NATIVE_poly1305_blocks */
/* #undef HAVE_NATIVE_fat_poly1305_blocks */
/* #undef HAVE_NATIVE_ghash_set_key */
/* #undef HAVE_NATIVE_ghash_update */
/* #undef HAVE_NATIVE_salsa20_core */
#define HAVE_NATIVE_salsa20_2core 1
/* #undef HAVE_NATIVE_fat_salsa20_2core */
/* #undef HAVE_NATIVE_sha1_compress */
/* #undef HAVE_NATIVE_sha256_compress_n */
/* #undef HAVE_NATIVE_sha512_compress */
/* #undef HAVE_NATIVE_sha3_permute */
/* #undef HAVE_NATIVE_umac_nh */
/* #undef HAVE_NATIVE_umac_nh_n */
/* Define to 1 if you have the <openssl/ecdsa.h> header file. */
/* #undef HAVE_OPENSSL_ECDSA_H */
/* Define to 1 if you have the <openssl/evp.h> header file. */
/* #undef HAVE_OPENSSL_EVP_H */
/* Define to 1 if you have the `secure_getenv' function. */
#define HAVE_SECURE_GETENV 1
/* Define to 1 if you have the <stdint.h> header file. */
#define HAVE_STDINT_H 1
/* Define to 1 if you have the <stdlib.h> header file. */
#define HAVE_STDLIB_H 1
/* Define to 1 if you have the `strerror' function. */
#define HAVE_STRERROR 1
/* Define to 1 if you have the <strings.h> header file. */
#define HAVE_STRINGS_H 1
/* Define to 1 if you have the <string.h> header file. */
#define HAVE_STRING_H 1
/* Define to 1 if you have the <sys/stat.h> header file. */
#define HAVE_SYS_STAT_H 1
/* Define to 1 if you have the <sys/types.h> header file. */
#define HAVE_SYS_TYPES_H 1
/* Define to 1 if you have the <unistd.h> header file. */
#define HAVE_UNISTD_H 1
/* Define to 1 if you have the <valgrind/memcheck.h> header file. */
/* #undef HAVE_VALGRIND_MEMCHECK_H */
/* Define to the address where bug reports for this package should be sent. */
#define PACKAGE_BUGREPORT "nettle-bugs@lists.lysator.liu.se"
/* Define to the full name of this package. */
#define PACKAGE_NAME "nettle"
/* Define to the full name and version of this package. */
#define PACKAGE_STRING "nettle 3.9.1"
/* Define to the one symbol short name of this package. */
#define PACKAGE_TARNAME "nettle"
/* Define to the home page for this package. */
#define PACKAGE_URL ""
/* Define to the version of this package. */
#define PACKAGE_VERSION "3.9.1"
/* The size of `long', as computed by sizeof. */
#define SIZEOF_LONG __SIZEOF_LONG__
/* The size of `size_t', as computed by sizeof. */
#define SIZEOF_SIZE_T __SIZEOF_SIZE_T__
/* If using the C implementation of alloca, define if you know the
direction of stack growth for your system; otherwise it will be
automatically deduced at runtime.
STACK_DIRECTION > 0 => grows toward higher addresses
STACK_DIRECTION < 0 => grows toward lower addresses
STACK_DIRECTION = 0 => direction of growth unknown */
/* #undef STACK_DIRECTION */
/* Define to 1 if you have the ANSI C header files. */
#define STDC_HEADERS 1
/* Define to 1 if you can safely include both <sys/time.h> and <time.h>. */
#define TIME_WITH_SYS_TIME 1
/* Defined if public key features are enabled */
/* #undef WITH_HOGWEED */
/* Define if you have openssl's libcrypto (used for benchmarking) */
/* #undef WITH_OPENSSL */
/* Define WORDS_BIGENDIAN to 1 if your processor stores words with the most
significant byte first (like Motorola and SPARC, unlike Intel). */
#if defined AC_APPLE_UNIVERSAL_BUILD
# if defined __BIG_ENDIAN__
# define WORDS_BIGENDIAN 1
# endif
#else
# ifndef WORDS_BIGENDIAN
/* # undef WORDS_BIGENDIAN */
# endif
#endif
/* Define to empty if `const' does not conform to ANSI C. */
/* #undef const */
/* Define to `int' if <sys/types.h> doesn't define. */
/* #undef gid_t */
/* Define to `__inline__' or `__inline' if that's what the C compiler
calls it, or to nothing if 'inline' is not supported under any name. */
#ifndef __cplusplus
/* #undef inline */
#endif
/* Define to `unsigned int' if <sys/types.h> does not define. */
/* #undef size_t */
/* Define to `int' if <sys/types.h> doesn't define. */
/* #undef uid_t */
/* AIX requires this to be the first thing in the file. */
#ifndef __GNUC__
# if HAVE_ALLOCA_H
# include <alloca.h>
# else
# ifdef _AIX
#pragma alloca
# else
# ifndef alloca /* predefined by HP cc +Olibcalls */
char *alloca ();
# endif
# endif
/* Needed for alloca on windows */
# if HAVE_MALLOC_H
# include <malloc.h>
# endif
# endif
#else /* defined __GNUC__ */
# if HAVE_ALLOCA_H
# include <alloca.h>
# else
/* Needed for alloca on windows, also with gcc */
# if HAVE_MALLOC_H
# include <malloc.h>
# endif
# endif
#endif
#if HAVE_STRERROR
#define STRERROR strerror
#else
#define STRERROR(x) (sys_errlist[x])
#endif
#if __GNUC__ && HAVE_GCC_ATTRIBUTE
# define NORETURN __attribute__ ((__noreturn__))
# define PRINTF_STYLE(f, a) __attribute__ ((__format__ (__printf__, f, a)))
# define UNUSED __attribute__ ((__unused__))
#else
# define NORETURN
# define PRINTF_STYLE(f, a)
# define UNUSED
#endif
#if defined(__x86_64__) || defined(__arch64__)
# define HAVE_NATIVE_64_BIT 1
#else
/* Needs include of <limits.h> before use. */
# define HAVE_NATIVE_64_BIT (SIZEOF_LONG * CHAR_BIT >= 64)
#endif
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-64
View File
@@ -1,64 +0,0 @@
/* version.h
Information about library version.
Copyright (C) 2015 Red Hat, Inc.
Copyright (C) 2015 Niels Möller
This file is part of GNU Nettle.
GNU Nettle is free software: you can redistribute it and/or
modify it under the terms of either:
* the GNU Lesser General Public License as published by the Free
Software Foundation; either version 3 of the License, or (at your
option) any later version.
or
* the GNU General Public License as published by the Free
Software Foundation; either version 2 of the License, or (at your
option) any later version.
or both in parallel, as here.
GNU Nettle is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received copies of the GNU General Public License and
the GNU Lesser General Public License along with this program. If
not, see http://www.gnu.org/licenses/.
*/
#ifndef NETTLE_VERSION_H_INCLUDED
#define NETTLE_VERSION_H_INCLUDED
#ifdef __cplusplus
extern "C" {
#endif
/* Individual version numbers in decimal */
#define NETTLE_VERSION_MAJOR 3
#define NETTLE_VERSION_MINOR 9
#define NETTLE_USE_MINI_GMP 0
/* We need a preprocessor constant for GMP_NUMB_BITS, simply using
sizeof(mp_limb_t) * CHAR_BIT is not good enough. */
#if NETTLE_USE_MINI_GMP
# define GMP_NUMB_BITS n/a
#endif
int
nettle_version_major (void);
int
nettle_version_minor (void);
#ifdef __cplusplus
}
#endif
#endif /* NETTLE_VERSION_H_INCLUDED */
@@ -16,8 +16,14 @@
package androidx.compose.foundation.gestures
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.FloatDecayAnimationSpec
import androidx.compose.animation.core.generateDecayAnimationSpec
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.snapFlingBehavior
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
@@ -27,15 +33,23 @@ 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.ObserverModifierNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.observeReads
import androidx.compose.ui.node.requireLayoutDirection
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlin.math.sign
import kotlinx.coroutines.launch
@ExperimentalFoundationApi
@@ -45,31 +59,31 @@ fun <T> Modifier.anchoredHorizontalDraggable(
enableDragFromEndToStart: Boolean = true,
enabled: Boolean = true,
startDragImmediately: Boolean = state.isAnimationRunning,
flingBehavior: FlingBehavior? = null,
): Modifier = this then AnchoredHorizontalDraggableElement(
state = state,
canDrag = { (x, y) ->
if (x > 0) {
enableDragFromStartToEnd
} else {
enableDragFromEndToStart
} && x.absoluteValue > y.absoluteValue * 2f
val enableDrag = if (x > 0) enableDragFromStartToEnd else enableDragFromEndToStart
enableDrag && x.absoluteValue > y.absoluteValue * 2f
},
enabled = enabled,
startDragImmediately = startDragImmediately,
flingBehavior = flingBehavior,
)
@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,
private val flingBehavior: FlingBehavior?,
) : ModifierNodeElement<AnchoredHorizontalDraggableNode<T>>() {
override fun create() = AnchoredHorizontalDraggableNode(
state,
canDrag,
enabled,
startDragImmediately,
flingBehavior,
)
override fun update(node: AnchoredHorizontalDraggableNode<T>) {
@@ -78,6 +92,7 @@ private class AnchoredHorizontalDraggableElement<T>(
canDrag,
enabled,
startDragImmediately,
flingBehavior,
)
}
@@ -86,6 +101,7 @@ private class AnchoredHorizontalDraggableElement<T>(
result = 31 * result + canDrag.hashCode()
result = 31 * result + enabled.hashCode()
result = 31 * result + startDragImmediately.hashCode()
result = 31 * result + flingBehavior.hashCode()
return result
}
@@ -98,6 +114,7 @@ private class AnchoredHorizontalDraggableElement<T>(
if (canDrag != other.canDrag) return false
if (enabled != other.enabled) return false
if (startDragImmediately != other.startDragImmediately) return false
if (flingBehavior != other.flingBehavior) return false
return true
}
@@ -108,26 +125,56 @@ private class AnchoredHorizontalDraggableElement<T>(
properties["canDrag"] = canDrag
properties["enabled"] = enabled
properties["startDragImmediately"] = startDragImmediately
properties["flingBehavior"] = flingBehavior
}
}
@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 var flingBehavior: FlingBehavior?,
) : HorizontalDragGestureNode(enabled = enabled),
ObserverModifierNode {
lateinit var resolvedFlingBehavior: FlingBehavior
lateinit var density: Density
private val isReverseDirection: Boolean
get() = requireLayoutDirection() == LayoutDirection.Rtl
override fun canDrag(change: PointerInputChange): Boolean {
return canDrag(change.positionChange().reverseIfNeeded())
override fun onAttach() {
updateFlingBehavior(flingBehavior)
}
override fun onObservedReadsChanged() {
val newDensity = currentValueOf(LocalDensity)
if (density != newDensity) {
density = newDensity
updateFlingBehavior(flingBehavior)
}
}
private fun updateFlingBehavior(newFlingBehavior: FlingBehavior?) {
// Fall back to default fling behavior if the new fling behavior is null
this.resolvedFlingBehavior =
if (newFlingBehavior == null) {
// Only register for LocalDensity snapshot updates if we are creating a decay
observeReads { density = currentValueOf(LocalDensity) }
anchoredDraggableFlingBehavior(
snapAnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec,
positionalThreshold = AnchoredDraggableDefaults.PositionalThreshold,
density = density,
state = state,
)
} else {
newFlingBehavior
}
}
override fun canDrag(change: PointerInputChange): Boolean = canDrag(change.positionChange().reverseIfNeeded())
override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) {
state.anchoredDrag {
forEachDelta { dragDelta ->
@@ -141,10 +188,29 @@ private class AnchoredHorizontalDraggableNode<T>(
override fun onDragStopped(velocity: Velocity) {
if (!isAttached) return
coroutineScope.launch {
state.settle(velocity.reverseIfNeeded().x)
fling(velocity.reverseIfNeeded().x)
}
}
private suspend fun fling(velocity: Float): Float {
var leftoverVelocity = velocity
state.anchoredDrag {
val scrollScope =
object : ScrollScope {
override fun scrollBy(pixels: Float): Float {
val newOffset = state.newOffsetForDelta(pixels)
val consumed = newOffset - state.offset
dragTo(newOffset)
return consumed
}
}
with(resolvedFlingBehavior) {
leftoverVelocity = scrollScope.performFling(velocity)
}
}
return leftoverVelocity
}
override fun startDragImmediately(): Boolean = startDragImmediately
fun update(
@@ -152,11 +218,15 @@ private class AnchoredHorizontalDraggableNode<T>(
canDrag: (Offset) -> Boolean,
enabled: Boolean,
startDragImmediately: Boolean,
flingBehavior: FlingBehavior?,
) {
this.flingBehavior = flingBehavior
var resetPointerInputHandling = false
if (this.state != state) {
this.state = state
updateFlingBehavior(flingBehavior)
resetPointerInputHandling = true
}
@@ -175,7 +245,118 @@ private class AnchoredHorizontalDraggableNode<T>(
internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) =
((if (offset.isNaN()) 0f else offset) + delta)
.coerceIn(anchors.minAnchor(), anchors.maxAnchor())
.coerceIn(anchors.minPosition(), anchors.maxPosition())
/**
* Compute the target anchor based on the [currentOffset], [velocity] and [positionalThreshold] and
* [velocityThreshold].
*
* @return The suggested target anchor
*/
@ExperimentalFoundationApi
private fun <T> DraggableAnchors<T>.computeTarget(
currentOffset: Float,
currentValue: T,
velocity: Float,
positionalThreshold: (totalDistance: Float) -> Float,
velocityThreshold: () -> Float,
): T {
val currentAnchors = this
val currentAnchorPosition = currentAnchors.positionOf(currentValue)
val velocityThresholdPx = velocityThreshold()
return if (currentAnchorPosition == currentOffset || currentAnchorPosition.isNaN()) {
currentValue
} else {
if (abs(velocity) >= abs(velocityThresholdPx)) {
currentAnchors.closestAnchor(currentOffset, sign(velocity) > 0)!!
} else {
val neighborAnchor =
currentAnchors.closestAnchor(
currentOffset,
currentOffset - currentAnchorPosition > 0,
)!!
val neighborAnchorPosition = currentAnchors.positionOf(neighborAnchor)
val distance = abs(currentAnchorPosition - neighborAnchorPosition)
val relativeThreshold = abs(positionalThreshold(distance))
val relativePosition = abs(currentAnchorPosition - currentOffset)
if (relativePosition <= relativeThreshold) currentValue else neighborAnchor
}
}
}
/**
* Construct a [FlingBehavior] for use with [Modifier.anchoredDraggable].
*
* @param state The [AnchoredDraggableState] that will be used for the fling animation
* @param positionalThreshold A positional threshold that needs to be crossed in order to reach the
* next anchor when flinging, in pixels. This can be a derived from the distance that the lambda
* is invoked with.
* @param snapAnimationSpec The animation spec that will be used to snap to a new state.
*/
@ExperimentalFoundationApi
internal fun <T> anchoredDraggableFlingBehavior(
state: AnchoredDraggableState<T>,
density: Density,
positionalThreshold: (totalDistance: Float) -> Float,
snapAnimationSpec: AnimationSpec<Float>,
): TargetedFlingBehavior =
snapFlingBehavior(
decayAnimationSpec = NoOpDecayAnimationSpec,
snapAnimationSpec = snapAnimationSpec,
snapLayoutInfoProvider =
AnchoredDraggableLayoutInfoProvider(
state = state,
positionalThreshold = positionalThreshold,
velocityThreshold = { with(density) { 125.dp.toPx() } },
),
)
@Suppress("FunctionName")
@OptIn(ExperimentalFoundationApi::class)
private fun <T> AnchoredDraggableLayoutInfoProvider(
state: AnchoredDraggableState<T>,
positionalThreshold: (totalDistance: Float) -> Float,
velocityThreshold: () -> Float,
): SnapLayoutInfoProvider =
object : SnapLayoutInfoProvider {
// We never decay in AnchoredDraggable's fling
override fun calculateApproachOffset(velocity: Float, decayOffset: Float) = 0f
override fun calculateSnapOffset(velocity: Float): Float {
val currentOffset = state.requireOffset()
val target =
state.anchors.computeTarget(
currentOffset = currentOffset,
currentValue = state.currentValue,
velocity = velocity,
positionalThreshold = positionalThreshold,
velocityThreshold = velocityThreshold,
)
return state.anchors.positionOf(target) - currentOffset
}
}
private val NoOpDecayAnimationSpec: DecayAnimationSpec<Float> =
object : FloatDecayAnimationSpec {
override val absVelocityThreshold = 0f
override fun getValueFromNanos(
playTimeNanos: Long,
initialValue: Float,
initialVelocity: Float,
) = 0f
override fun getDurationNanos(initialValue: Float, initialVelocity: Float) = 0L
override fun getVelocityFromNanos(
playTimeNanos: Long,
initialValue: Float,
initialVelocity: Float,
) = 0f
override fun getTargetValue(initialValue: Float, initialVelocity: Float) = 0f
}.generateDecayAnimationSpec()
/**
* This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout
@@ -240,7 +421,8 @@ private class DraggableAnchorsNode<T>(
var state: AnchoredDraggableState<T>,
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
var orientation: Orientation,
) : Modifier.Node(), LayoutModifierNode {
) : Modifier.Node(),
LayoutModifierNode {
private var didLookahead: Boolean = false
override fun onDetach() {
@@ -46,7 +46,9 @@ import kotlinx.coroutines.launch
*/
internal abstract class HorizontalDragGestureNode(
enabled: Boolean,
) : DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode {
) : DelegatingNode(),
PointerInputModifierNode,
CompositionLocalConsumerModifierNode {
protected var enabled = enabled
private set
@@ -132,65 +134,63 @@ internal abstract class HorizontalDragGestureNode(
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()
private fun initializePointerInputNode(): SuspendingPointerInputModifierNode = 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)
}
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))
startListeningForEvents()
}
val xSign = sign(startEvent.position.x)
val ySign = sign(startEvent.position.y)
val adjustedStart = startEvent.position -
Offset(initialDelta.x * xSign, initialDelta.y * ySign)
val onDragEnd: () -> Unit = {
val maximumVelocity = currentValueOf(LocalViewConfiguration)
.maximumFlingVelocity
val velocity = velocityTracker.calculateVelocity(
Velocity(maximumVelocity, maximumVelocity),
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,
)
velocityTracker.resetTracking()
channel?.trySend(DragStopped(velocity))
}
val onDragCancel: () -> Unit = {
} catch (cancellation: CancellationException) {
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
}
if (!isActive) throw cancellation
}
}
}
@@ -0,0 +1,64 @@
package androidx.compose.foundation.scrollbar
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
internal class LazyStaggeredGridScrollbarAdapter(
private val scrollState: LazyStaggeredGridState,
) : LazyLineContentAdapter() {
override val viewportSize: Double
get() = with(scrollState.layoutInfo) {
if (isVertical) viewportSize.height else viewportSize.width
}.toDouble()
private val isVertical by lazy {
scrollState.layoutInfo.orientation == Orientation.Vertical
}
private fun LazyStaggeredGridItemInfo.mainAxisSize() = with(size) {
if (isVertical) height else width
}
private fun LazyStaggeredGridItemInfo.mainAxisOffset() = with(offset) {
if (isVertical) y else x
}
override fun firstVisibleLine(): VisibleLine? {
val first = scrollState.layoutInfo.visibleItemsInfo.firstOrNull()
return first?.run { VisibleLine(index, mainAxisOffset()) }
}
override fun totalLineCount() = scrollState.layoutInfo.totalItemsCount
override fun contentPadding() = with(scrollState.layoutInfo) {
beforeContentPadding + afterContentPadding
}
override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {
scrollState.scrollToItem(lineIndex, scrollOffset)
}
override suspend fun scrollBy(value: Float) {
scrollState.scrollBy(value)
}
override fun averageVisibleLineSize() = with(scrollState.layoutInfo.visibleItemsInfo) {
val first = firstOrNull() ?: return@with 0.0
val last = last()
(last.mainAxisOffset() + last.mainAxisSize() - first.mainAxisOffset()).toDouble() / size
}
override val lineSpacing get() = 0
}
@Composable
fun rememberScrollbarAdapter(
scrollState: LazyStaggeredGridState,
): ScrollbarAdapter = remember(scrollState) {
LazyStaggeredGridScrollbarAdapter(scrollState)
}
@@ -0,0 +1,455 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation.scrollbar
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.TweenSpec
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocal
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/**
* [CompositionLocal] used to pass [ScrollbarStyle] down the tree.
* This value is typically set in some "Theme" composable function
* (DesktopTheme, MaterialTheme)
*/
val LocalScrollbarStyle = staticCompositionLocalOf { defaultScrollbarStyle() }
/**
* Defines visual style of scrollbars (thickness, shapes, colors, etc).
* Can be passed as a parameter of scrollbar through [LocalScrollbarStyle]
*/
@Immutable
data class ScrollbarStyle(
val minimalHeight: Dp,
val thickness: Dp,
val shape: Shape,
val hoverDurationMillis: Int,
val unhoverColor: Color,
val hoverColor: Color,
)
/**
* Simple default [ScrollbarStyle] without applying MaterialTheme.
*/
fun defaultScrollbarStyle() = ScrollbarStyle(
minimalHeight = 16.dp,
thickness = 8.dp,
shape = RoundedCornerShape(4.dp),
hoverDurationMillis = 300,
unhoverColor = Color.Black.copy(alpha = 0.12f),
hoverColor = Color.Black.copy(alpha = 0.50f),
)
/**
* Vertical scrollbar that can be attached to some scrollable
* component (ScrollableColumn, LazyColumn) and share common state with it.
*
* Can be placed independently.
*
* Example:
* val state = rememberScrollState(0)
*
* Box(Modifier.fillMaxSize()) {
* Box(modifier = Modifier.verticalScroll(state)) {
* ...
* }
*
* VerticalScrollbar(
* adapter = rememberScrollbarAdapter(state)
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
* )
* }
*
* @param adapter [ScrollbarAdapter] that will be used to
* communicate with scrollable component
* @param modifier the modifier to apply to this layout
* @param reverseLayout reverse the direction of scrolling and layout, when `true`
* and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar
* will be at the bottom of the container.
* It is usually used in pair with `LazyColumn(reverseLayout = true)`
* @param style [ScrollbarStyle] to define visual style of scrollbar
* @param interactionSource [MutableInteractionSource] that will be used to dispatch
* [DragInteraction.Start] when this Scrollbar is being dragged.
*/
@Composable
fun VerticalScrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean = false,
style: ScrollbarStyle = LocalScrollbarStyle.current,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = Scrollbar(
adapter,
modifier,
reverseLayout,
style,
interactionSource,
isVertical = true,
)
/**
* Horizontal scrollbar that can be attached to some scrollable
* component (Modifier.verticalScroll(), LazyRow) and share common state with it.
*
* Can be placed independently.
*
* Example:
* val state = rememberScrollState(0)
*
* Box(Modifier.fillMaxSize()) {
* Box(modifier = Modifier.verticalScroll(state)) {
* ...
* }
*
* HorizontalScrollbar(
* adapter = rememberScrollbarAdapter(state)
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxWidth(),
* )
* }
*
* @param adapter [ScrollbarAdapter] that will be used to
* communicate with scrollable component
* @param modifier the modifier to apply to this layout
* @param reverseLayout reverse the direction of scrolling and layout, when `true`
* and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar
* will be at the end of the container.
* It is usually used in pair with `LazyRow(reverseLayout = true)`
* @param style [ScrollbarStyle] to define visual style of scrollbar
* @param interactionSource [MutableInteractionSource] that will be used to dispatch
* [DragInteraction.Start] when this Scrollbar is being dragged.
*/
@Composable
fun HorizontalScrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean = false,
style: ScrollbarStyle = LocalScrollbarStyle.current,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = Scrollbar(
adapter,
modifier,
if (LocalLayoutDirection.current == LayoutDirection.Rtl) !reverseLayout else reverseLayout,
style,
interactionSource,
isVertical = false,
)
@Composable
private fun Scrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean,
style: ScrollbarStyle,
interactionSource: MutableInteractionSource,
isVertical: Boolean,
) = with(LocalDensity.current) {
val dragInteraction = remember<MutableState<DragInteraction.Start?>> { mutableStateOf(null) }
DisposableEffect(key1 = interactionSource) {
onDispose {
dragInteraction.value?.let { interaction ->
interactionSource.tryEmit(DragInteraction.Cancel(interaction))
dragInteraction.value = null
}
}
}
var containerSize by remember { mutableIntStateOf(0) }
val isHovered by interactionSource.collectIsHoveredAsState()
val isHighlighted by remember {
derivedStateOf {
isHovered || dragInteraction.value is DragInteraction.Start
}
}
val minimalHeight = style.minimalHeight.toPx()
val coroutineScope = rememberCoroutineScope()
val sliderAdapter = remember(
adapter,
containerSize,
minimalHeight,
reverseLayout,
isVertical,
coroutineScope,
) {
SliderAdapter(
adapter,
containerSize,
minimalHeight,
reverseLayout = reverseLayout,
isVertical = isVertical,
coroutineScope = coroutineScope,
)
}
val scrollThickness = style.thickness.roundToPx()
val measurePolicy = if (isVertical) {
remember(sliderAdapter, scrollThickness) {
verticalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness)
}
} else {
remember(sliderAdapter, scrollThickness) {
horizontalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness)
}
}
val color by animateColorAsState(
if (isHighlighted) style.hoverColor else style.unhoverColor,
animationSpec = TweenSpec(durationMillis = style.hoverDurationMillis),
label = "Scrollbar color",
)
val isVisible = sliderAdapter.thumbSize < containerSize
Layout(
{
Box(
Modifier
.background(if (isVisible) color else Color.Transparent, style.shape)
.scrollbarDrag(
interactionSource = interactionSource,
draggedInteraction = dragInteraction,
sliderAdapter = sliderAdapter,
),
)
},
modifier.hoverable(interactionSource = interactionSource),
measurePolicy,
)
}
/**
* Create and [remember] [ScrollbarAdapter] for
* scrollable container with the given instance [ScrollState].
*/
@Composable
fun rememberScrollbarAdapter(
scrollState: ScrollState,
): ScrollbarAdapter = remember(scrollState) {
ScrollbarAdapter(scrollState)
}
/**
* Create and [remember] [ScrollbarAdapter] for
* lazy scrollable container with the given instance [LazyListState].
*/
@Composable
fun rememberScrollbarAdapter(
scrollState: LazyListState,
): ScrollbarAdapter = remember(scrollState) {
ScrollbarAdapter(scrollState)
}
/**
* Create and [remember] [ScrollbarAdapter] for lazy grid with
* the given instance of [LazyGridState].
*/
@Composable
fun rememberScrollbarAdapter(
scrollState: LazyGridState,
): ScrollbarAdapter = remember(scrollState) {
ScrollbarAdapter(scrollState)
}
/**
* ScrollbarAdapter for Modifier.verticalScroll and Modifier.horizontalScroll
*
* [scrollState] is instance of [ScrollState] which is used by scrollable component
*
* Example:
* val state = rememberScrollState(0)
*
* Box(Modifier.fillMaxSize()) {
* Box(modifier = Modifier.verticalScroll(state)) {
* ...
* }
*
* VerticalScrollbar(
* adapter = rememberScrollbarAdapter(state)
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
* )
* }
*/
fun ScrollbarAdapter(
scrollState: ScrollState,
): ScrollbarAdapter = ScrollableScrollbarAdapter(scrollState)
/**
* ScrollbarAdapter for lazy lists.
*
* [scrollState] is instance of [LazyListState] which is used by scrollable component
*
* Example:
* Box(Modifier.fillMaxSize()) {
* val state = rememberLazyListState()
*
* LazyColumn(state = state) {
* ...
* }
*
* VerticalScrollbar(
* adapter = rememberScrollbarAdapter(state)
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
* )
* }
*/
fun ScrollbarAdapter(
scrollState: LazyListState,
): ScrollbarAdapter = LazyListScrollbarAdapter(scrollState)
/**
* ScrollbarAdapter for lazy grids.
*
* [scrollState] is instance of [LazyGridState] which is used by scrollable component
*
* Example:
* Box(Modifier.fillMaxSize()) {
* val state = rememberLazyGridState()
*
* LazyVerticalGrid(columns = ..., state = state) {
* ...
* }
*
* VerticalScrollbar(
* adapter = rememberScrollbarAdapter(state)
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
* )
* }
*/
fun ScrollbarAdapter(
scrollState: LazyGridState,
): ScrollbarAdapter = LazyGridScrollbarAdapter(scrollState)
private val SliderAdapter.thumbPixelRange: IntRange
get() {
val start = position.roundToInt()
val endExclusive = start + thumbSize.roundToInt()
return (start until endExclusive)
}
private val IntRange.size get() = last + 1 - first
private fun verticalMeasurePolicy(
sliderAdapter: SliderAdapter,
setContainerSize: (Int) -> Unit,
scrollThickness: Int,
) = MeasurePolicy { measurables, constraints ->
setContainerSize(constraints.maxHeight)
val pixelRange = sliderAdapter.thumbPixelRange
val placeable = measurables.first().measure(
Constraints.fixed(
constraints.constrainWidth(scrollThickness),
pixelRange.size,
),
)
layout(placeable.width, constraints.maxHeight) {
placeable.place(0, pixelRange.first)
}
}
private fun horizontalMeasurePolicy(
sliderAdapter: SliderAdapter,
setContainerSize: (Int) -> Unit,
scrollThickness: Int,
) = MeasurePolicy { measurables, constraints ->
setContainerSize(constraints.maxWidth)
val pixelRange = sliderAdapter.thumbPixelRange
val placeable = measurables.first().measure(
Constraints.fixed(
pixelRange.size,
constraints.constrainHeight(scrollThickness),
),
)
layout(constraints.maxWidth, placeable.height) {
placeable.place(pixelRange.first, 0)
}
}
private fun Modifier.scrollbarDrag(
interactionSource: MutableInteractionSource,
draggedInteraction: MutableState<DragInteraction.Start?>,
sliderAdapter: SliderAdapter,
): Modifier = composed {
val currentInteractionSource by rememberUpdatedState(interactionSource)
val currentDraggedInteraction by rememberUpdatedState(draggedInteraction)
val currentSliderAdapter by rememberUpdatedState(sliderAdapter)
pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val interaction = DragInteraction.Start()
currentInteractionSource.tryEmit(interaction)
currentDraggedInteraction.value = interaction
currentSliderAdapter.onDragStarted()
val isSuccess = drag(down.id) { change ->
currentSliderAdapter.onDragDelta(change.positionChange())
change.consume()
}
val finishInteraction = if (isSuccess) {
DragInteraction.Stop(interaction)
} else {
DragInteraction.Cancel(interaction)
}
currentInteractionSource.tryEmit(finishInteraction)
currentDraggedInteraction.value = null
}
}
}
@@ -0,0 +1,472 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation.scrollbar
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.ui.geometry.Offset
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Defines how to scroll the scrollable component and how to display a scrollbar for it.
*
* The values of this interface are typically in pixels, but do not have to be.
* It's possible to create an adapter with any scroll range of `Double` values.
*/
interface ScrollbarAdapter {
// We use `Double` values here in order to allow scrolling both very large (think LazyList with
// millions of items) and very small (think something whose natural coordinates are less than 1)
// content.
/**
* Scroll offset of the content inside the scrollable component.
*
* For example, a value of `100` could mean the content is scrolled by 100 pixels from the
* start.
*/
val scrollOffset: Double
/**
* The size of the scrollable content, on the scrollable axis.
*/
val contentSize: Double
/**
* The size of the viewport, on the scrollable axis.
*/
val viewportSize: Double
/**
* Instantly jump to [scrollOffset].
*
* @param scrollOffset target offset to jump to, value will be coerced to the valid
* scroll range.
*/
suspend fun scrollTo(scrollOffset: Double)
}
/**
* The maximum scroll offset of the scrollable content.
*/
val ScrollbarAdapter.maxScrollOffset: Double
get() = (contentSize - viewportSize).coerceAtLeast(0.0)
internal class ScrollableScrollbarAdapter(
private val scrollState: ScrollState,
) : ScrollbarAdapter {
override val scrollOffset: Double get() = scrollState.value.toDouble()
override suspend fun scrollTo(scrollOffset: Double) {
scrollState.scrollTo(scrollOffset.roundToInt())
}
override val contentSize: Double
// This isn't strictly correct, as the actual content can be smaller
// than the viewport when scrollState.maxValue is 0, but the scrollbar
// doesn't really care as long as contentSize <= viewportSize; it's
// just not showing itself
get() = scrollState.maxValue + viewportSize
override val viewportSize: Double
get() = scrollState.viewportSize.toDouble()
}
/**
* Base class for [LazyListScrollbarAdapter] and [LazyGridScrollbarAdapter],
* and in the future maybe other lazy widgets that lay out their content in lines.
*/
internal abstract class LazyLineContentAdapter : ScrollbarAdapter {
// Implement the adapter in terms of "lines", which means either rows,
// (for a vertically scrollable widget) or columns (for a horizontally
// scrollable one).
// For LazyList this translates directly to items; for LazyGrid, it
// translates to rows/columns of items.
class VisibleLine(
val index: Int,
val offset: Int,
)
/**
* Return the first visible line, if any.
*/
protected abstract fun firstVisibleLine(): VisibleLine?
/**
* Return the total number of lines.
*/
protected abstract fun totalLineCount(): Int
/**
* The sum of content padding (before+after) on the scrollable axis.
*/
protected abstract fun contentPadding(): Int
/**
* Scroll immediately to the given line, and offset it by [scrollOffset] pixels.
*/
protected abstract suspend fun snapToLine(lineIndex: Int, scrollOffset: Int)
/**
* Scroll from the current position by the given amount of pixels.
*/
protected abstract suspend fun scrollBy(value: Float)
/**
* Return the average size (on the scrollable axis) of the visible lines.
*/
protected abstract fun averageVisibleLineSize(): Double
/**
* The spacing between lines.
*/
protected abstract val lineSpacing: Int
private val averageVisibleLineSize by derivedStateOf {
if (totalLineCount() == 0) {
0.0
} else {
averageVisibleLineSize()
}
}
private val averageVisibleLineSizeWithSpacing get() = averageVisibleLineSize + lineSpacing
override val scrollOffset: Double
get() {
val firstVisibleLine = firstVisibleLine()
return if (firstVisibleLine == null) {
0.0
} else {
firstVisibleLine.index * averageVisibleLineSizeWithSpacing - firstVisibleLine.offset
}
}
override val contentSize: Double
get() {
val totalLineCount = totalLineCount()
return averageVisibleLineSize * totalLineCount +
lineSpacing * (totalLineCount - 1).coerceAtLeast(0) +
contentPadding()
}
override suspend fun scrollTo(scrollOffset: Double) {
val distance = scrollOffset - this@LazyLineContentAdapter.scrollOffset
// if we scroll less than viewport we need to use scrollBy function to avoid
// undesirable scroll jumps (when an item size is different)
//
// if we scroll more than viewport we should immediately jump to this position
// without recreating all items between the current and the new position
if (abs(distance) <= viewportSize) {
scrollBy(distance.toFloat())
} else {
snapTo(scrollOffset)
}
}
private suspend fun snapTo(scrollOffset: Double) {
val scrollOffsetCoerced = scrollOffset.coerceIn(0.0, maxScrollOffset)
val index = (scrollOffsetCoerced / averageVisibleLineSizeWithSpacing)
.toInt()
.coerceAtLeast(0)
.coerceAtMost(totalLineCount() - 1)
val offset = (scrollOffsetCoerced - index * averageVisibleLineSizeWithSpacing)
.toInt()
.coerceAtLeast(0)
snapToLine(lineIndex = index, scrollOffset = offset)
}
}
internal class LazyListScrollbarAdapter(
private val scrollState: LazyListState,
) : LazyLineContentAdapter() {
override val viewportSize: Double
get() = with(scrollState.layoutInfo) {
if (orientation == Orientation.Vertical) {
viewportSize.height
} else {
viewportSize.width
}
}.toDouble()
/**
* A heuristic that tries to ignore the "currently stickied" header because it breaks the other
* computations in this adapter:
* - The currently stickied header always appears in the list of visible items, with its
* regular index. This makes [firstVisibleLine] always return its index, even if the list has
* been scrolled far beyond it.
* - [averageVisibleLineSize] calculates the average size in O(1) by assuming that items don't
* overlap, and the stickied item breaks this assumption.
*
* Attempts to return the index into `visibleItemsInfo` of the first non-currently-stickied (it
* could be sticky, but not stickied to the top of the list right now) item, if there is one.
*
* Note that this heuristic breaks down if the sticky header covers the entire list, so that
* it's the only visible item for some portion of the scroll range. But there's currently no
* known better way to solve it, and it's a relatively unusual case.
*/
private fun firstFloatingVisibleItemIndex() = with(scrollState.layoutInfo.visibleItemsInfo) {
when (size) {
0 -> null
1 -> 0
else -> {
val first = this[0]
val second = this[1]
// If either the indices or the offsets aren't continuous, then the first item is
// sticky, so we return 1
if ((first.index < second.index - 1) ||
(first.offset + first.size + lineSpacing > second.offset)
) {
1
} else {
0
}
}
}
}
override fun firstVisibleLine(): VisibleLine? {
val firstFloatingVisibleIndex = firstFloatingVisibleItemIndex() ?: return null
val firstFloatingItem = scrollState.layoutInfo.visibleItemsInfo[firstFloatingVisibleIndex]
return VisibleLine(
index = firstFloatingItem.index,
offset = firstFloatingItem.offset,
)
}
override fun totalLineCount() = scrollState.layoutInfo.totalItemsCount
override fun contentPadding() = with(scrollState.layoutInfo) {
beforeContentPadding + afterContentPadding
}
override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {
scrollState.scrollToItem(lineIndex, scrollOffset)
}
override suspend fun scrollBy(value: Float) {
scrollState.scrollBy(value)
}
override fun averageVisibleLineSize() = with(scrollState.layoutInfo.visibleItemsInfo) {
val firstFloatingIndex = firstFloatingVisibleItemIndex() ?: return@with 0.0
val first = this[firstFloatingIndex]
val last = last()
val count = size - firstFloatingIndex
(last.offset + last.size - first.offset - (count - 1) * lineSpacing).toDouble() / count
}
override val lineSpacing get() = scrollState.layoutInfo.mainAxisItemSpacing
}
internal class LazyGridScrollbarAdapter(
private val scrollState: LazyGridState,
) : LazyLineContentAdapter() {
override val viewportSize: Double
get() = with(scrollState.layoutInfo) {
if (isVertical) {
viewportSize.height
} else {
viewportSize.width
}
}.toDouble()
private val isVertical by lazy {
scrollState.layoutInfo.orientation == Orientation.Vertical
}
private val unknownLine = with(LazyGridItemInfo) {
if (isVertical) UnknownRow else UnknownColumn
}
private fun LazyGridItemInfo.line() = if (isVertical) row else column
private fun LazyGridItemInfo.mainAxisSize() = with(size) {
if (isVertical) height else width
}
private fun LazyGridItemInfo.mainAxisOffset() = with(offset) {
if (isVertical) y else x
}
private fun slotsPerLine(): Int = scrollState.layoutInfo.visibleItemsInfo
.groupingBy { it.line() }.eachCount().maxOfOrNull { it.value } ?: 1
private fun lineOfIndex(index: Int) = index / slotsPerLine()
private fun indexOfFirstInLine(line: Int) = line * slotsPerLine()
override fun firstVisibleLine(): VisibleLine? = scrollState.layoutInfo.visibleItemsInfo
.firstOrNull { it.line() != unknownLine } // Skip exiting items
?.let { firstVisibleItem ->
VisibleLine(
index = firstVisibleItem.line(),
offset = firstVisibleItem.mainAxisOffset(),
)
}
override fun totalLineCount(): Int {
val itemCount = scrollState.layoutInfo.totalItemsCount
return if (itemCount == 0) {
0
} else {
lineOfIndex(itemCount - 1) + 1
}
}
override fun contentPadding() = with(scrollState.layoutInfo) {
beforeContentPadding + afterContentPadding
}
override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {
scrollState.scrollToItem(
index = indexOfFirstInLine(lineIndex),
scrollOffset = scrollOffset,
)
}
override suspend fun scrollBy(value: Float) {
scrollState.scrollBy(value)
}
override fun averageVisibleLineSize(): Double {
val visibleItemsInfo = // Non-exiting visible items
scrollState.layoutInfo.visibleItemsInfo.filter { it.line() != unknownLine }
if (visibleItemsInfo.isEmpty()) {
return 0.0
}
// Compute the size of the last line
val last = visibleItemsInfo.last()
val lastLine = last.line()
val lastLineSize = visibleItemsInfo
.asReversed()
.asSequence()
.takeWhile { it.line() == lastLine }
.maxOf { it.mainAxisSize() }
val first = visibleItemsInfo.first()
val lineCount = lastLine - first.line() + 1
val lineSpacingSum = (lineCount - 1) * lineSpacing
return (
last.mainAxisOffset() + lastLineSize - first.mainAxisOffset() - lineSpacingSum
).toDouble() / lineCount
}
override val lineSpacing get() = scrollState.layoutInfo.mainAxisItemSpacing
}
internal class SliderAdapter(
private val adapter: ScrollbarAdapter,
private val trackSize: Int,
private val minHeight: Float,
private val reverseLayout: Boolean,
private val isVertical: Boolean,
private val coroutineScope: CoroutineScope,
) {
private val contentSize get() = adapter.contentSize
private val visiblePart: Double
get() {
val contentSize = contentSize
return if (contentSize == 0.0) {
1.0
} else {
(adapter.viewportSize / contentSize).coerceAtMost(1.0)
}
}
val thumbSize
get() = (trackSize * visiblePart).coerceAtLeast(minHeight.toDouble())
private val scrollScale: Double
get() {
val extraScrollbarSpace = trackSize - thumbSize
val extraContentSpace = adapter.maxScrollOffset // == contentSize - viewportSize
return if (extraContentSpace == 0.0) 1.0 else extraScrollbarSpace / extraContentSpace
}
private val rawPosition: Double
get() = scrollScale * adapter.scrollOffset
val position: Double
get() = if (reverseLayout) trackSize - thumbSize - rawPosition else rawPosition
// How much of the current drag was ignored because we've reached the end of the scrollbar area
private var unscrolledDragDistance = 0.0
/** Called when the thumb dragging starts */
fun onDragStarted() {
unscrolledDragDistance = 0.0
}
private suspend fun setPosition(value: Double) {
val rawPosition = if (reverseLayout) {
trackSize - thumbSize - value
} else {
value
}
adapter.scrollTo(rawPosition / scrollScale)
}
private val dragMutex = Mutex()
/** Called on every movement while dragging the thumb */
fun onDragDelta(offset: Offset) {
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
// Mutex is used to ensure that all earlier drag deltas were applied
// before calculating a new raw position
dragMutex.withLock {
val dragDelta = if (isVertical) offset.y else offset.x
val maxScrollPosition = adapter.maxScrollOffset * scrollScale
val currentPosition = position
val targetPosition =
(currentPosition + dragDelta + unscrolledDragDistance).coerceIn(
0.0,
maxScrollPosition,
)
val sliderDelta = targetPosition - currentPosition
// Have to add to position for smooth content scroll if the items are of different size
val newPos = position + sliderDelta
setPosition(newPos)
unscrolledDragDistance += dragDelta - sliderDelta
}
}
}
}
@@ -76,13 +76,11 @@ fun SearchBarInputField(
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 = {},
)
},
@@ -102,5 +100,4 @@ fun SearchBarInputField(
val SearchBarIconOffsetX = 4.dp
val SearchBarMinWidth = 360.dp
val SearchBarMaxWidth = 720.dp
val SearchBarHorizontalPadding = 16.dp
const val AnimationDelayMillis = 100
@@ -1,13 +1,13 @@
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
@file:Suppress("ktlint:standard:property-naming")
package androidx.compose.material3
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
@@ -53,8 +53,6 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
private const val DrawerPositionalThreshold = 0.5f
private val DrawerVelocityThreshold = 400.dp
@Suppress("NotCloseable")
@Stable
@@ -65,11 +63,7 @@ class DrawerState2(
val anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
snapAnimationSpec = AnimationSpec,
decayAnimationSpec = exponentialDecay(7.5f),
confirmValueChange = confirmStateChange,
positionalThreshold = { distance -> distance * DrawerPositionalThreshold },
velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } },
)
/**
@@ -230,10 +224,8 @@ class DrawerState2(
fun rememberDrawerState2(
initialValue: DrawerValue,
confirmStateChange: (DrawerValue) -> Boolean = { true },
): DrawerState2 {
return rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) {
DrawerState2(initialValue, confirmStateChange)
}
): DrawerState2 = rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) {
DrawerState2(initialValue, confirmStateChange)
}
@Composable
@@ -266,6 +258,10 @@ fun ModalSideDrawer(
enableDragFromStartToEnd = drawerState.isOpen,
enableDragFromEndToStart = drawerState.isClosed,
enabled = gesturesEnabled,
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
state = drawerState.anchoredDraggableState,
animationSpec = AnimationSpec,
),
),
) {
val radius by remember {
@@ -18,7 +18,7 @@ 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.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
@@ -38,21 +38,16 @@ 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.
@@ -61,16 +56,11 @@ 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
@@ -150,7 +140,6 @@ class SwipeToDismissBoxState(
*/
fun Saver(
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean,
positionalThreshold: (totalDistance: Float) -> Float,
density: Density,
) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
save = { it.currentValue },
@@ -159,7 +148,6 @@ class SwipeToDismissBoxState(
it,
density,
confirmValueChange,
positionalThreshold,
)
},
)
@@ -171,7 +159,6 @@ class 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.
@@ -180,18 +167,15 @@ class SwipeToDismissBoxState(
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)
SwipeToDismissBoxState(initialValue, density, confirmValueChange)
}
}
@@ -224,6 +208,11 @@ fun SwipeToDismissBox(
enableDragFromStartToEnd = enableDismissFromStartToEnd,
enableDragFromEndToStart = enableDismissFromEndToStart,
enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled,
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
state = state.anchoredDraggableState,
positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold,
animationSpec = SnapAnimationSpec,
),
),
propagateMinConstraints = true,
) {
@@ -251,5 +240,3 @@ fun SwipeToDismissBox(
)
}
}
private val DismissVelocityThreshold = 125.dp
@@ -27,10 +27,10 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil3.EventListener
import coil3.SingletonImageLoader
import coil3.asCoilImage
import coil3.asImage
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.ktor.KtorNetworkFetcherFactory
import coil3.network.ktor2.KtorNetworkFetcherFactory
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.crossfade
@@ -43,13 +43,14 @@ import com.hippo.ehviewer.coil.CropBorderInterceptor
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
import com.hippo.ehviewer.coil.HardwareBitmapInterceptor
import com.hippo.ehviewer.coil.MergeInterceptor
import com.hippo.ehviewer.cronet.cronetHttpClient
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.ktor.Cronet
import com.hippo.ehviewer.legacy.cleanObsoleteCache
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.lockObserver
@@ -58,6 +59,7 @@ 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.isAtLeastO
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastS
import eu.kanade.tachiyomi.util.lang.launchIO
@@ -77,7 +79,9 @@ import splitties.init.appCtx
private val lifecycle = ProcessLifecycleOwner.get().lifecycle
private val lifecycleScope = lifecycle.coroutineScope
class EhApplication : Application(), SingletonImageLoader.Factory {
class EhApplication :
Application(),
SingletonImageLoader.Factory {
override fun onCreate() {
// Initialize Settings on first access
lifecycleScope.launchIO {
@@ -168,8 +172,10 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
add(KtorNetworkFetcherFactory { ktorClient })
add(MergeInterceptor)
add(DownloadThumbInterceptor)
if (isAtLeastO) {
add(HardwareBitmapInterceptor)
}
add(CropBorderInterceptor)
add(HardwareBitmapInterceptor)
if (isAtLeastP) {
add(AnimatedImageDecoder.Factory(false))
} else {
@@ -179,7 +185,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
diskCache { imageCache }
crossfade(300)
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
if (drawable != null) error(drawable.asCoilImage(true))
if (drawable != null) error(drawable.asImage(true))
if (BuildConfig.DEBUG) {
logger(DebugLogger())
} else {
@@ -195,7 +201,10 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
companion object {
val ktorClient by lazy {
HttpClient(CronetEngine) {
HttpClient(Cronet) {
engine {
client = cronetHttpClient
}
install(HttpCookies) {
storage = EhCookieStore
}
@@ -203,7 +212,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
}
val noRedirectKtorClient by lazy {
HttpClient(CronetEngine) {
HttpClient(ktorClient.engine) {
followRedirects = false
install(HttpCookies) {
storage = EhCookieStore
@@ -33,9 +33,9 @@ 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 okio.Path
import okio.Path.Companion.toOkioPath
import splitties.arch.room.roomDb
object EhDB {
@@ -273,10 +273,10 @@ object EhDB {
db.filterDao().update(filter)
}
fun exportDB(context: Context, file: UniFile) {
fun exportDB(context: Context, file: Path) {
db.query("PRAGMA wal_checkpoint(FULL)", null).use { it.moveToNext() }
val dbFile = context.getDatabasePath(DB_NAME)
dbFile.asUniFile() sendTo file
dbFile.toOkioPath() sendTo file
}
suspend fun importDB(context: Context, uri: Uri) {
@@ -10,6 +10,7 @@ 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.EhCookieStore
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.download.DownloadsFilterMode
import com.hippo.ehviewer.download.SortMode
@@ -99,6 +100,7 @@ fun <T> PrefDelegate<T>.asMutableState(): MutableState<T> {
object Settings : DataStorePreferences(null) {
private const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations"
@Suppress("ktlint:standard:backing-property-naming")
private val _favFlow = MutableSharedFlow<Unit>()
val favChangesFlow = _favFlow.debounce(1000)
var favCat by stringArrayPref("fav_cat", 10, "Favorites").emitTo(_favFlow)
@@ -112,6 +114,7 @@ object Settings : DataStorePreferences(null) {
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)
@@ -120,7 +123,7 @@ object Settings : DataStorePreferences(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", null)
val recentDownloadLabel = stringOrNullPref("recent_download_label", "")
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", null)
@@ -138,7 +141,7 @@ object Settings : DataStorePreferences(null) {
var readCacheSize by intPref("read_cache_size_2", 640)
var launchPage by intPref("launch_page_2", 0)
var commentThreshold by intPref("comment_threshold", -101)
var forceEhThumb by boolPref("force_eh_thumb", false)
var hardwareBitmapThreshold by intPref("hardware_bitmap_threshold", 16384)
var showComments by boolPref("show_gallery_comments", true)
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
var hideHvEvents by boolPref("hide_hv_events", false)
@@ -169,11 +172,9 @@ object Settings : DataStorePreferences(null) {
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 lastUpdateTime by longPref("last_update_time", BuildConfig.BUILD_TIME)
// Tachiyomi Reader
var newReader by boolPref("new_compose_reader", false)
var lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
// Reader
val cropBorder = boolPref("crop_borders", false)
val colorFilter = boolPref("pref_color_filter_key", false)
val colorFilterValue = intPref("color_filter_value", 0)
@@ -205,6 +206,8 @@ object Settings : DataStorePreferences(null) {
val imageScaleType = intPref("pref_image_scale_type_key", 1)
val landscapeZoom = boolPref("landscape_zoom", false)
val zoomStart = intPref("pref_zoom_start_key", 1)
val showNavigationOverlayNewUser = boolPref("reader_navigation_overlay_new_user", true)
val showNavigationOverlayOnStart = boolPref("reader_navigation_overlay_on_start", false)
init {
if ("CN" == Locale.getDefault().country) {
@@ -223,7 +226,7 @@ object Settings : DataStorePreferences(null) {
private fun intArrayPref(key: String, count: Int) = object : Delegate<IntArray> {
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
@Suppress("ktlint:standard:property-naming")
@Suppress("ktlint:standard:backing-property-naming")
private var _value = (0 until count).map { intPref("${key}_$it", 0) }.toTypedArray()
override fun getValue(thisRef: Any?, prop: KProperty<*>?): IntArray = _value.map { it.value }.toIntArray()
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: IntArray) {
@@ -235,7 +238,7 @@ object Settings : DataStorePreferences(null) {
private fun stringArrayPref(key: String, count: Int, defMetaValue: String) = object : Delegate<Array<String>> {
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
@Suppress("ktlint:standard:property-naming")
@Suppress("ktlint:standard:backing-property-naming")
private var _value = (0 until count).map { stringPref("${key}_$it", "$defMetaValue $it") }.toTypedArray()
override fun getValue(thisRef: Any?, prop: KProperty<*>?): Array<String> = _value.map { it.value }.toTypedArray()
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: Array<String>) {
@@ -2,7 +2,6 @@ package com.hippo.ehviewer
import android.app.UiModeManager
import androidx.appcompat.app.AppCompatDelegate
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
@@ -54,7 +53,7 @@ fun updateWhenRequestNewsChanges() {
}
fun updateWhenGallerySiteChanges() {
if (!Settings.needSignIn && EhCookieStore.hasSignedIn()) {
if (Settings.hasSignedIn.value) {
collectScope.launchIO {
runCatching {
EhEngine.getUConfig()
@@ -15,36 +15,35 @@
*/
package com.hippo.ehviewer.client
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
// Normal Preview: https://*.hath.network/cm/[timed token]/[gid]-[index].jpg
// ExHentai Large Preview: https://s.exhentai.org/t/***
// ExHentai Normal Preview: https://s.exhentai.org/m/***
// EHentai Normal Preview: https://ehgt.org/m/***
// EHentai Large Preview: https://ehgt.org/***
// E-Hentai Large Preview: https://ehgt.org/***
private const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/"
private const val URL_PREFIX_LARGE_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/t/"
private const val NORMAL_PREVIEW_PREFIX = "$"
private val NormalPreviewKeyRegex = Regex("/(\\d+-\\d+)\\.jpg$")
fun getImageKey(gid: Long, index: Int): String {
return "image:$gid:$index"
}
fun getImageKey(gid: Long, index: Int): String = "image:$gid:$index"
fun getThumbKey(url: String): String {
return url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_LARGE_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
}
fun getThumbKey(url: String): String = url.removePrefix(thumbPrefix)
val GalleryPreview.url
get() = if (exThumb) {
if (this is NormalGalleryPreview) URL_PREFIX_THUMB_EX else URL_PREFIX_LARGE_THUMB_EX
val String.isNormalPreviewKey
get() = startsWith(NORMAL_PREVIEW_PREFIX)
val GalleryPreview.imageKey
get() = if (this is NormalGalleryPreview) {
NormalPreviewKeyRegex.find(url)?.run { NORMAL_PREVIEW_PREFIX + groupValues[1] }
} else {
URL_PREFIX_THUMB_E
} + imageKey
getThumbKey(url)
}
val GalleryInfo.thumbUrl
get() = (if (exThumb) URL_PREFIX_LARGE_THUMB_EX else URL_PREFIX_THUMB_E) + thumbKey!!
get() = thumbPrefix + EhUtils.handleThumbUrlResolution(thumbKey!!)
val exThumb get() = EhUtils.isExHentai && !Settings.forceEhThumb
private val thumbPrefix
get() = if (EhUtils.isExHentai) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
@@ -1,24 +1,19 @@
package com.hippo.ehviewer.client
import android.webkit.CookieManager
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastJoinToString
import io.ktor.client.plugins.cookies.CookiesStorage
import io.ktor.http.Cookie
import io.ktor.http.Url
import io.ktor.http.parseClientCookiesHeader
import io.ktor.http.renderCookieHeader
import io.ktor.http.renderSetCookieHeader
object EhCookieStore : CookiesStorage {
private val manager = CookieManager.getInstance()
fun signOut() = manager.removeAllCookies(null)
fun contains(url: String, name: String) = load(Url(url)).fastAny { it.name == name }
fun removeAllCookies() = manager.removeAllCookies(null)
fun hasSignedIn(): Boolean {
val url = EhUrl.HOST_E
return contains(url, KEY_IPB_MEMBER_ID) && contains(url, KEY_IPB_PASS_HASH)
}
fun hasSignedIn(): Boolean = getCookies(EhUrl.HOST_E)?.run {
containsKey(KEY_IPB_MEMBER_ID) && containsKey(KEY_IPB_PASS_HASH)
} ?: false
const val KEY_IPB_MEMBER_ID = "ipb_member_id"
const val KEY_IPB_PASS_HASH = "ipb_pass_hash"
@@ -53,11 +48,7 @@ object EhCookieStore : CookiesStorage {
fun flush() = manager.flush()
fun getCookieHeader(url: String): String {
return load(Url(url)).fastJoinToString("; ") {
renderCookieHeader(it)
}
}
fun getCookieHeader(url: String): String? = manager.getCookie(url)
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
override suspend fun addCookie(requestUrl: Url, cookie: Cookie) {
@@ -29,6 +29,7 @@ import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.NoHitsFoundException
import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.parser.ArchiveParser
@@ -55,11 +56,12 @@ import com.hippo.ehviewer.dailycheck.showEventNotification
import com.hippo.ehviewer.dailycheck.today
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.bodyAsUtf8Text
import com.hippo.ehviewer.util.ensureSuccess
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.statement.HttpStatement
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpStatusCode
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import java.io.File
@@ -86,7 +88,7 @@ private const val REQUEST_INTERVAL = 5000L
fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
val dir = AppConfig.externalParseErrorDir ?: return
val file = File(dir, ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".txt")
val file = File(dir, ReadableTime.getFilenamableTime() + ".txt")
file.sink().buffer().use { sink ->
with(sink) {
writeUtf8(e.message + "\n")
@@ -96,7 +98,7 @@ fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
}
}
fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
fun rethrowExactly(status: HttpStatusCode, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
// Don't translate coroutine cancellation
if (e is CancellationException) throw e
@@ -124,14 +126,12 @@ fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): N
}
// Check bad response code
if (code >= 400) {
throw StatusCodeException(code)
}
status.ensureSuccess()
if (e is ParseException || e is SerializationException) {
body.onLeft { if ("<" !in it) throw EhException(it) }
when (val message = e.cause?.message) {
"No hits found!" -> throw EhException(R.string.gallery_list_empty_hit)
"No hits found!" -> throw NoHitsFoundException()
"No watched tags!" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
"Not logged in!" -> throw NotLoggedInException()
is String -> if (message.startsWith("Your IP address")) throw EhException(message)
@@ -151,7 +151,7 @@ suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend
runSuspendCatching {
block(body)
}.onFailure {
rethrowExactly(response.status.value, body.left(), it)
rethrowExactly(response.status, body.left(), it)
}.getOrThrow()
}
@@ -163,7 +163,7 @@ suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: s
block(buffer)
}.onFailure {
buffer.rewind()
rethrowExactly(response.status.value, buffer.right(), it)
rethrowExactly(response.status, buffer.right(), it)
}.getOrThrow()
}
}
@@ -297,6 +297,10 @@ object EhEngine {
}.executeSafely { }
}
suspend fun getFavoriteNote(gid: Long, token: String) =
ehRequest(EhUrl.getAddFavorites(gid, token), EhUrl.getGalleryDetailUrl(gid, token))
.fetchUsingAsText(FavoritesParser::parseNote)
suspend fun downloadArchive(gid: Long, token: String, or: String, res: String, isHAtH: Boolean): String? {
val url = EhUrl.getDownloadArchive(gid, token, or)
val referer = EhUrl.getGalleryDetailUrl(gid, token)
@@ -20,6 +20,7 @@ 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 com.hippo.ehviewer.util.ensureSuccess
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.forms.FormDataContent
@@ -45,7 +46,11 @@ import okio.BufferedSource
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely { it.bodyAsUtf8Text().parseAs<T>() }
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely {
it.status.ensureSuccess()
it.bodyAsUtf8Text().parseAs<T>()
}
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
@@ -24,15 +24,13 @@ import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.copyTo
import com.hippo.unifile.asUniFile
import com.hippo.unifile.sha1
import com.hippo.ehviewer.util.ensureSuccess
import com.hippo.ehviewer.util.sha1
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.isSuccess
import java.io.File
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.CoroutineScope
@@ -45,6 +43,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import moe.tarsin.coroutines.runSuspendCatching
import okio.BufferedSource
import okio.Path.Companion.toOkioPath
import okio.buffer
import okio.source
import splitties.init.appCtx
@@ -63,9 +62,7 @@ object EhTagDatabase : CoroutineScope {
var initialized by mutableStateOf(false)
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? {
return tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
}
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? = tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
private fun internalSuggestFlow(
tags: Map<String, String>,
@@ -131,33 +128,22 @@ object EhTagDatabase : CoroutineScope {
private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean =
removeSpace().contains(other.removeSpace(), ignoreCase)
private fun getMetadata(context: Context): Array<String>? {
return context.resources.getStringArray(R.array.tag_translation_metadata)
.takeIf { it.size == 4 }
}
private fun getMetadata(context: Context): Array<String>? = context.resources.getStringArray(R.array.tag_translation_metadata)
.takeIf { it.size == 4 }
fun isTranslatable(context: Context): Boolean {
return context.resources.getBoolean(R.bool.tag_translatable)
}
fun isTranslatable(context: Context): Boolean = context.resources.getBoolean(R.bool.tag_translatable)
private fun getFileContent(file: File): String? {
return runCatching {
file.source().buffer().use { it.readString(StandardCharsets.UTF_8) }
}.getOrNull()
}
private fun getFileContent(file: File): String? = runCatching {
file.source().buffer().use { it.readString(StandardCharsets.UTF_8) }
}.getOrNull()
private fun checkData(sha1: String?, data: File): Boolean {
return sha1 != null && sha1 == data.asUniFile().sha1()
}
private fun checkData(sha1: String?, data: File): Boolean = sha1 != null && sha1 == data.toOkioPath().sha1()
private suspend fun save(client: HttpClient, url: String, file: File) {
runCatching {
client.prepareGet(url).executeSafely {
if (it.status.isSuccess()) {
it.bodyAsChannel().copyTo(file)
} else {
throw StatusCodeException(it.status.value)
}
it.status.ensureSuccess()
it.bodyAsChannel().copyTo(file)
}
}.onFailure {
file.delete()
@@ -60,14 +60,10 @@ object EhUrl {
}
val apiUrl: String
get() = if (Settings.forceEhThumb) {
API_E
} else {
when (Settings.gallerySite) {
SITE_E -> API_E
SITE_EX -> API_EX
else -> API_E
}
get() = when (Settings.gallerySite) {
SITE_E -> API_E
SITE_EX -> API_EX
else -> API_E
}
val referer: String
@@ -88,21 +88,18 @@ object EhUtils {
fun getCategory(type: String?): Int {
for (entry in CATEGORY_STRINGS) {
for (str in entry.first)
for (str in entry.first) {
if (str.equals(type, ignoreCase = true)) {
return entry.second
}
}
}
return UNKNOWN
}
fun getCategory(type: Int): String {
return CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
}
fun getCategory(type: Int): String = CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
private fun differenceDegrees(a: Float, b: Float): Float {
return 180.0f - abs(abs(a - b) - 180.0f)
}
private fun differenceDegrees(a: Float, b: Float): Float = 180.0f - abs(abs(a - b) - 180.0f)
private fun sanitizeDegreesDouble(degrees: Float): Float {
val deg = degrees % 360.0f
@@ -152,19 +149,18 @@ object EhUtils {
val categoryTextColor = Color(0xffe6e0e9)
fun signOut() {
EhCookieStore.signOut()
EhCookieStore.removeAllCookies()
Settings.displayName.value = null
Settings.hasSignedIn.value = false
Settings.gallerySite = EhUrl.SITE_E
Settings.needSignIn = true
}
fun getSuitableTitle(gi: GalleryInfo): String {
return if (Settings.showJpnTitle) {
if (gi.titleJpn.isNullOrEmpty()) gi.title else gi.titleJpn
} else {
if (gi.title.isNullOrEmpty()) gi.titleJpn else gi.title
}.orEmpty()
}
fun getSuitableTitle(gi: GalleryInfo): String = if (Settings.showJpnTitle) {
if (gi.titleJpn.isNullOrEmpty()) gi.title else gi.titleJpn
} else {
if (gi.title.isNullOrEmpty()) gi.titleJpn else gi.title
}.orEmpty()
fun extractTitle(fullTitle: String?): String? {
var title: String = fullTitle ?: return null
@@ -176,10 +172,7 @@ object EhUtils {
return title.substringBeforeLast('|').trim().ifEmpty { null }
}
fun handleThumbUrlResolution(url: String?): String? {
if (null == url) {
return null
}
fun handleThumbUrlResolution(url: String): String {
val resolution = when (Settings.thumbResolution) {
0 -> return url
1 -> "250"
@@ -88,7 +88,8 @@ data class BaseGalleryInfo(
@Ignore
override var favoriteNote: String? = null,
) : GalleryInfo, Parcelable {
) : GalleryInfo,
Parcelable {
constructor() : this(0, "")
}
@@ -55,7 +55,8 @@ data class GalleryDetail(
var comments: GalleryCommentList,
val previewPages: Int,
val previewList: List<GalleryPreview>,
) : GalleryInfo by galleryInfo, Parcelable {
) : GalleryInfo by galleryInfo,
Parcelable {
fun fillInfo() {
val index = LANGUAGES.indexOf(language)
if (index != -1) simpleLanguage = S_LANGS[index]
@@ -134,14 +134,10 @@ interface GalleryInfo {
}
}
fun GalleryInfo.findBaseInfo(): BaseGalleryInfo {
return when (this) {
is BaseGalleryInfo -> this
is GalleryDetail -> galleryInfo
else -> unreachable()
}
fun GalleryInfo.findBaseInfo(): BaseGalleryInfo = when (this) {
is BaseGalleryInfo -> this
is GalleryDetail -> galleryInfo
else -> unreachable()
}
fun GalleryInfo.asGalleryDetail(): GalleryDetail? {
return this as? GalleryDetail
}
fun GalleryInfo.asGalleryDetail(): GalleryDetail? = this as? GalleryDetail
@@ -19,19 +19,19 @@ import android.os.Parcelable
import kotlinx.parcelize.Parcelize
interface GalleryPreview : Parcelable {
val imageKey: String
val url: String
val position: Int
}
@Parcelize
data class LargeGalleryPreview(
override val imageKey: String,
override val url: String,
override val position: Int,
) : GalleryPreview
@Parcelize
data class NormalGalleryPreview(
override val imageKey: String,
override val url: String,
override val position: Int,
val offsetX: Int,
val clipWidth: Int,
@@ -22,4 +22,5 @@ import kotlinx.parcelize.Parcelize
data class GalleryTagGroup(
val groupName: String,
private val tagList: MutableList<String> = mutableListOf(),
) : Parcelable, MutableList<String> by tagList
) : Parcelable,
MutableList<String> by tagList
@@ -42,6 +42,7 @@ data class ListUrlBuilder(
var category: Int = EhUtils.NONE,
private var mKeyword: String? = null,
var hash: String? = null,
var language: Int = -1,
var advanceSearch: Int = -1,
var minRating: Int = -1,
var pageFrom: Int = -1,
@@ -83,18 +84,16 @@ data class ListUrlBuilder(
mNext = q.name.substringAfterLast('@', "").ifEmpty { null }
}
fun toQuickSearch(name: String): QuickSearch {
return QuickSearch(
name = name,
mode = mode,
category = category,
keyword = mKeyword,
advanceSearch = advanceSearch,
minRating = minRating,
pageFrom = pageFrom,
pageTo = pageTo,
)
}
fun toQuickSearch(name: String): QuickSearch = QuickSearch(
name = name,
mode = mode,
category = category,
keyword = mKeyword,
advanceSearch = advanceSearch,
minRating = minRating,
pageFrom = pageFrom,
pageTo = pageTo,
)
fun equalsQuickSearch(q: QuickSearch?): Boolean {
if (null == q) {
@@ -259,84 +258,89 @@ data class ListUrlBuilder(
}
}
fun build(): String {
return when (mode) {
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
if (category > 0) {
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
fun build(): String = 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())
}
val query = mKeyword?.let { keyword ->
if (language == -1 || "gid:" in keyword || "l:" in keyword || "language:" in keyword) {
keyword
} else {
val tag = GalleryInfo.S_LANG_TAGS[language]
if (keyword.isNotEmpty()) {
"$tag $keyword"
} else {
tag
}
}
val query = mKeyword?.let {
val index = Settings.languageFilter.value
GalleryInfo.S_LANG_TAGS.getOrNull(index)?.let { lang ->
"$it $lang"
} ?: it
}
addQueryParameterIfNotBlank("f_search", query)
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
// Advance search
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
addQueryParameter("advsearch", "1")
if (advanceSearch and AdvanceTable.SH != 0) {
addQueryParameter("f_sh", "on")
}
addQueryParameterIfNotBlank("f_search", query)
if (advanceSearch and AdvanceTable.STO != 0) {
addQueryParameter("f_sto", "on")
}
if (advanceSearch and AdvanceTable.SFL != 0) {
addQueryParameter("f_sfl", "on")
}
if (advanceSearch and AdvanceTable.SFU != 0) {
addQueryParameter("f_sfu", "on")
}
if (advanceSearch and AdvanceTable.SFT != 0) {
addQueryParameter("f_sft", "on")
}
// Set min star
if (minRating > 0) {
addQueryParameter("f_sr", "on")
addQueryParameter("f_srdd", "$minRating")
}
// Pages
if (pageFrom > 0 || pageTo > 0) {
addQueryParameter("f_sp", "on")
addQueryParameterIfNotBlank(
"f_spf",
pageFrom.takeIf { it > 0 }?.toString(),
)
addQueryParameterIfNotBlank(
"f_spt",
pageTo.takeIf { it > 0 }?.toString(),
)
}
}
}.buildString()
MODE_UPLOADER, MODE_TAG -> {
val path = if (mode == MODE_UPLOADER) "uploader" else "tag"
ehUrl(listOf(path, requireNotNull(mKeyword))) {
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
// Advance search
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
addQueryParameter("advsearch", "1")
if (advanceSearch and AdvanceTable.SH != 0) {
addQueryParameter("f_sh", "on")
}
if (advanceSearch and AdvanceTable.STO != 0) {
addQueryParameter("f_sto", "on")
}
if (advanceSearch and AdvanceTable.SFL != 0) {
addQueryParameter("f_sfl", "on")
}
if (advanceSearch and AdvanceTable.SFU != 0) {
addQueryParameter("f_sfu", "on")
}
if (advanceSearch and AdvanceTable.SFT != 0) {
addQueryParameter("f_sft", "on")
}
// Set min star
if (minRating > 0) {
addQueryParameter("f_sr", "on")
addQueryParameter("f_srdd", "$minRating")
}
// Pages
if (pageFrom > 0 || pageTo > 0) {
addQueryParameter("f_sp", "on")
addQueryParameterIfNotBlank(
"f_spf",
pageFrom.takeIf { it > 0 }?.toString(),
)
addQueryParameterIfNotBlank(
"f_spt",
pageTo.takeIf { it > 0 }?.toString(),
)
}
}
}.buildString()
MODE_UPLOADER, MODE_TAG -> {
val path = if (mode == MODE_UPLOADER) "uploader" else "tag"
ehUrl(listOf(path, requireNotNull(mKeyword))) {
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
}.buildString()
}
MODE_WHATS_HOT -> EhUrl.popularUrl
MODE_IMAGE_SEARCH -> ehUrl {
addQueryParameter("f_shash", requireNotNull(hash))
}.buildString()
MODE_TOPLIST -> {
ehUrl("toplist.php", EhUrl.DOMAIN_E) {
addQueryParameter("tl", requireNotNull(mKeyword))
addQueryParameterIfNotBlank("p", mJumpTo)
}.buildString()
}
else -> throw IllegalStateException("Unexpected value: $mode")
}
MODE_WHATS_HOT -> EhUrl.popularUrl
MODE_IMAGE_SEARCH -> ehUrl {
addQueryParameter("f_shash", requireNotNull(hash))
}.buildString()
MODE_TOPLIST -> {
ehUrl("toplist.php", EhUrl.DOMAIN_E) {
addQueryParameter("tl", requireNotNull(mKeyword))
addQueryParameterIfNotBlank("p", mJumpTo)
}.buildString()
}
else -> throw IllegalStateException("Unexpected value: $mode")
}
@IntDef(
@@ -0,0 +1,5 @@
package com.hippo.ehviewer.client.exception
import com.hippo.ehviewer.R
class NoHitsFoundException : EhException(R.string.gallery_list_empty_hit)
@@ -20,7 +20,5 @@ package com.hippo.ehviewer.client.parser
import org.jsoup.Jsoup
object EventPaneParser {
fun parse(body: String): String? {
return Jsoup.parse(body).getElementById("eventpane")?.html()
}
fun parse(body: String): String? = Jsoup.parse(body).getElementById("eventpane")?.html()
}
@@ -1,6 +1,7 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.util.unescapeXml
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import moe.tarsin.coroutines.runSuspendCatching
@@ -17,13 +18,16 @@ data class FavParserResult(
}
object FavoritesParser {
fun parse(body: ByteBuffer): FavParserResult {
return runSuspendCatching {
unmarshalParsingAs<FavParserResult>(body, ::parseFav)
}.getOrElse {
throw ParseException("Parse favorites error", it)
}
private val NoteRegex = Regex("<textarea[^>]*>([^<]*)</textarea>")
fun parse(body: ByteBuffer): FavParserResult = runSuspendCatching {
unmarshalParsingAs<FavParserResult>(body, ::parseFav)
}.getOrElse {
throw ParseException("Parse favorites error", it)
}
fun parseNote(body: String) = NoteRegex.find(body)?.run { groupValues[1].unescapeXml() }
?: throw ParseException("Can't parse favorite note")
}
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit()): Int
@@ -21,15 +21,13 @@ import com.hippo.ehviewer.util.ExceptionUtils
import org.jsoup.Jsoup
object ForumsParser {
fun parse(body: String): String {
return runCatching {
val d = Jsoup.parse(body, EhUrl.URL_FORUMS)
val userlinks = d.getElementById("userlinks")
val child = userlinks!!.child(0).child(0).child(0)
child.attr("href")
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw NotLoggedInException()
}
fun parse(body: String): String = runCatching {
val d = Jsoup.parse(body, EhUrl.URL_FORUMS)
val userlinks = d.getElementById("userlinks")
val child = userlinks!!.child(0).child(0).child(0)
child.attr("href")
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw NotLoggedInException()
}
}
@@ -16,7 +16,6 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.EhUtils.getCategory
import com.hippo.ehviewer.client.EhUtils.handleThumbUrlResolution
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.getThumbKey
import com.hippo.ehviewer.client.parseAs
@@ -32,7 +31,7 @@ object GalleryApiParser {
title = item.title.unescapeXml()
titleJpn = item.titleJpn.unescapeXml()
category = getCategory(item.category)
thumbKey = getThumbKey(handleThumbUrlResolution(item.thumb)!!)
thumbKey = getThumbKey(item.thumb)
uploader = item.uploader?.unescapeXml()
posted = ParserUtils.formatDate(item.posted * 1000)
rating = item.rating
@@ -21,7 +21,6 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhFilter
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.EhUtils.getCategory
import com.hippo.ehviewer.client.EhUtils.handleThumbUrlResolution
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.GalleryCommentList
@@ -140,11 +139,7 @@ object GalleryDetailParser {
val gm = d.getElementsByClass("gm")[0]
// Thumb url
gm.getElementById("gd1")?.child(0)?.attr("style")?.trim()?.let {
gd.thumbKey = getThumbKey(
PATTERN_COVER.find(it)?.run {
handleThumbUrlResolution(groupValues[3])
}!!,
)
gd.thumbKey = getThumbKey(PATTERN_COVER.find(it)!!.groupValues[3])
}
gd.title = gm.getElementById("gn")?.text()?.trim()
@@ -283,56 +278,50 @@ object GalleryDetailParser {
}
}
private fun parseTagGroup(element: Element): GalleryTagGroup? {
return try {
var nameSpace = element.child(0).text()
// Remove last ':'
nameSpace = nameSpace.substring(0, nameSpace.length - 1)
val group = GalleryTagGroup(nameSpace)
val tags = element.child(1).children()
tags.forEach {
var tag = it.text()
// Sometimes parody tag is followed with '|' and english translate, just remove them
val index = tag.indexOf('|')
if (index >= 0) {
tag = tag.substring(0, index).trim()
}
if (it.className() == "gtw") {
tag = "_$tag" // weak tag
}
group.add(tag)
private fun parseTagGroup(element: Element): GalleryTagGroup? = try {
var nameSpace = element.child(0).text()
// Remove last ':'
nameSpace = nameSpace.substring(0, nameSpace.length - 1)
val group = GalleryTagGroup(nameSpace)
val tags = element.child(1).children()
tags.forEach {
var tag = it.text()
// Sometimes parody tag is followed with '|' and english translate, just remove them
val index = tag.indexOf('|')
if (index >= 0) {
tag = tag.substring(0, index).trim()
}
if (group.size > 0) group else null
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
null
if (it.className() == "gtw") {
tag = "_$tag" // weak tag
}
group.add(tag)
}
if (group.size > 0) group else null
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
null
}
/**
* Parse tag groups with html parser
*/
private fun parseTagGroups(document: Document): List<GalleryTagGroup> {
return try {
val taglist = document.getElementById("taglist")!!
val tagGroups = taglist.child(0).child(0).children()
parseTagGroups(tagGroups)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
emptyList()
}
private fun parseTagGroups(document: Document): List<GalleryTagGroup> = try {
val taglist = document.getElementById("taglist")!!
val tagGroups = taglist.child(0).child(0).children()
parseTagGroups(tagGroups)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
emptyList()
}
private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> {
return try {
trs.mapNotNull { parseTagGroup(it) }
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
emptyList()
}
private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> = try {
trs.mapNotNull { parseTagGroup(it) }
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
emptyList()
}
private suspend fun parseComment(element: Element): GalleryComment? {
@@ -449,30 +438,24 @@ object GalleryDetailParser {
/**
* Parse preview pages with regular expressions
*/
fun parsePreviewPages(body: String): Int {
return PATTERN_PREVIEW_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
?: throw ParseException("Parse preview page count error")
}
fun parsePreviewPages(body: String): Int = PATTERN_PREVIEW_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
?: throw ParseException("Parse preview page count error")
/**
* Parse pages with regular expressions
*/
fun parsePages(body: String): Int {
return PATTERN_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
?: throw ParseException("Parse pages error")
}
fun parsePages(body: String): Int = PATTERN_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
?: throw ParseException("Parse pages error")
fun parsePreviewList(body: String): Pair<List<GalleryPreview>, List<String>> {
return runCatching { parseNormalPreview(body) }.getOrElse { parseLargePreview(body) }
}
fun parsePreviewList(body: String): Pair<List<GalleryPreview>, List<String>> = runCatching { parseNormalPreview(body) }.getOrElse { parseLargePreview(body) }
private fun parseLargePreview(body: String): Pair<List<GalleryPreview>, List<String>> {
check(PATTERN_LARGE_PREVIEW.containsMatchIn(body))
return PATTERN_LARGE_PREVIEW.findAll(body).unzip {
val position = it.groupValues[2].toInt() - 1
val imageKey = getThumbKey(it.groupValues[3].trim())
val url = it.groupValues[3].trim()
val pageUrl = it.groupValues[1].trim()
LargeGalleryPreview(imageKey, position) to pageUrl
LargeGalleryPreview(url, position) to pageUrl
}.run {
first.toList() to second.toList()
}
@@ -482,12 +465,12 @@ object GalleryDetailParser {
check(PATTERN_NORMAL_PREVIEW.containsMatchIn(body))
return PATTERN_NORMAL_PREVIEW.findAll(body).unzip {
val position = it.groupValues[6].toInt() - 1
val imageKey = getThumbKey(it.groupValues[3].trim())
val url = it.groupValues[3].trim()
val xOffset = it.groupValues[4].toIntOrNull() ?: 0
val width = it.groupValues[1].toInt()
val height = it.groupValues[2].toInt()
val pageUrl = it.groupValues[5].trim()
NormalGalleryPreview(imageKey, position, xOffset, width, height) to pageUrl
NormalGalleryPreview(url, position, xOffset, width, height) to pageUrl
}.run {
first.toList() to second.toList()
}
@@ -22,14 +22,12 @@ import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup
object GalleryNotAvailableParser {
fun parse(body: String): String? {
return runCatching {
val e = Jsoup.parse(body).getElementsByClass("d").first()
e!!.child(0).html().replace("<br>", "\n")
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
logcat(it)
null
}
fun parse(body: String): String? = runCatching {
val e = Jsoup.parse(body).getElementsByClass("d").first()
e!!.child(0).html().replace("<br>", "\n")
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
logcat(it)
null
}
}
@@ -32,13 +32,11 @@ object GalleryTokenApiParser {
* ]
* }
*/
fun parse(body: String): String {
return runCatching {
body.parseAs<Result>().tokenList[0].token
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw EhException(body.parseAs<Error>().error)
}
fun parse(body: String): String = runCatching {
body.parseAs<Result>().tokenList[0].token
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw EhException(body.parseAs<Error>().error)
}
@Serializable
@@ -37,23 +37,13 @@ object ParserUtils {
minute()
}
fun formatDate(time: Long): String {
return formatter.format(time.toLocalDateTime())
}
fun formatDate(time: Long): String = formatter.format(time.toLocalDateTime())
fun parseDate(str: String): Long {
return formatter.parse(str).toEpochMillis()
}
fun parseDate(str: String): Long = formatter.parse(str).toEpochMillis()
fun trim(str: String?): String {
return str?.unescapeXml()?.trim() ?: ""
}
fun trim(str: String?): String = str?.unescapeXml()?.trim() ?: ""
fun parseInt(str: String?, defValue: Int): Int {
return trim(str).replace(",", "").toIntOrDefault(defValue)
}
fun parseInt(str: String?, defValue: Int): Int = trim(str).replace(",", "").toIntOrDefault(defValue)
fun parseLong(str: String?, defValue: Long): Long {
return trim(str).replace(",", "").toLongOrDefault(defValue)
}
fun parseLong(str: String?, defValue: Long): Long = trim(str).replace(",", "").toLongOrDefault(defValue)
}
@@ -22,31 +22,29 @@ import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup
object ProfileParser {
fun parse(body: String): Result {
return runCatching {
val d = Jsoup.parse(body)
val profilename = d.getElementById("profilename")
val displayName = profilename!!.child(0).text()
val avatar = runCatching {
val avatar =
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
if (avatar.isEmpty()) {
null
} else if (!avatar.startsWith("http")) {
EhUrl.URL_FORUMS + avatar
} else {
avatar
}
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
logcat { "No avatar" }
fun parse(body: String): Result = runCatching {
val d = Jsoup.parse(body)
val profilename = d.getElementById("profilename")
val displayName = profilename!!.child(0).text()
val avatar = runCatching {
val avatar =
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
if (avatar.isEmpty()) {
null
} else if (!avatar.startsWith("http")) {
EhUrl.URL_FORUMS + avatar
} else {
avatar
}
Result(displayName, avatar)
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw ParseException("Parse forums error")
logcat { "No avatar" }
null
}
Result(displayName, avatar)
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw ParseException("Parse forums error")
}
class Result(val displayName: String?, val avatar: String?)
@@ -25,11 +25,9 @@ object SignInParser {
"|<span class=\"postcolor\">(.+?)</span>",
)
fun parse(body: String): String {
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")
}
fun parse(body: String): String = 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")
}
@@ -21,7 +21,5 @@ import com.hippo.ehviewer.client.parseAs
object VoteTagParser {
// {"error":"The tag \"neko\" is not allowed. Use character:neko or artist:neko"}
fun parse(body: String): String? {
return runCatching { body.parseAs<Error>().error }.getOrNull()
}
fun parse(body: String): String? = runCatching { body.parseAs<Error>().error }.getOrNull()
}
@@ -1,20 +1,16 @@
package com.hippo.ehviewer.coil
import android.graphics.Bitmap
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Extras
import coil3.Image
import coil3.asCoilImage
import coil3.getExtra
import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
import com.hippo.ehviewer.image.detectBorder
import com.hippo.ehviewer.util.isAtLeastO
private const val CROP_THRESHOLD = 0.75f
private const val RATIO_THRESHOLD = 2
@@ -30,59 +26,33 @@ val ImageRequest.maybeCropBorder: Boolean
class BitmapImageWithRect(
val rect: IntRect,
val image: Image,
val image: BitmapImage,
) : Image by image
object CropBorderInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult {
val req = chain.request
if (req.maybeCropBorder) {
val hw = req.allowHardware && isAtLeastO
val result = if (hw) {
val nonHw = req.newBuilder().allowHardware(false).build()
chain.withRequest(nonHw).proceed()
} else {
chain.proceed()
}
if (result is SuccessResult) {
val image = result.image
if (image is BitmapImage) {
val bitmap = image.bitmap
val ratio = if (image.height > image.width) {
image.height / image.width
} else {
image.width / image.height
val result = chain.proceed()
if (chain.request.maybeCropBorder && result is SuccessResult) {
val image = result.image
if (image is BitmapImage) {
val bitmap = image.bitmap
val ratio = if (image.height > image.width) {
image.height / image.width
} else {
image.width / image.height
}
if (ratio < RATIO_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
val rect = IntRect(array[0], array[1], array[2], array[3])
if (rect.width > minWidth && rect.height > minHeight) {
val new = BitmapImageWithRect(rect, image)
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
}
val rect = if (ratio < RATIO_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
IntRect(array[0], array[1], array[2], array[3]).takeIf {
it.width > minWidth && it.height > minHeight
}
} else {
null
}
val maybeHwImage = if (hw) {
// Upload to Graphical Buffer to accelerate render
// May fail if the image is too long
bitmap.copy(Bitmap.Config.HARDWARE, false)?.run {
bitmap.recycle()
asCoilImage()
} ?: image
} else {
image
}
val new = if (rect != null) {
BitmapImageWithRect(rect, maybeHwImage)
} else {
maybeHwImage
}
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
}
}
return result
}
return chain.proceed()
return result
}
}
@@ -7,28 +7,20 @@ import com.hippo.ehviewer.EhApplication.Companion.imageCache
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.spider.DownloadInfoMagics.decodeMagicRequestOrUrl
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.asUniFile
import com.hippo.files.isFile
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.lang.withIOContext
object DownloadThumbInterceptor : Interceptor {
// TODO: Remove this after a few releases
private const val LEGACY_THUMB_FILE = ".thumb"
private const val THUMB_FILE = "thumb.jpg"
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val magicOrUrl = chain.request.data as? String
if (magicOrUrl != null) {
val (url, location) = decodeMagicRequestOrUrl(magicOrUrl)
if (location != null) {
val thumb = withIOContext {
val legacyThumb = downloadLocation / location / LEGACY_THUMB_FILE
if (legacyThumb.isFile) {
legacyThumb.apply { renameTo(THUMB_FILE) }
} else {
downloadLocation / location / THUMB_FILE
}
}
val thumb = withIOContext { downloadLocation / location / THUMB_FILE }
if (withIOContext { thumb.isFile }) {
val new = chain.request.newBuilder().data(thumb.uri.toString()).build()
val new = chain.request.newBuilder().data(thumb.toUri()).build()
val result = chain.withRequest(new).proceed()
if (result is SuccessResult) return result
}
@@ -38,11 +30,9 @@ object DownloadThumbInterceptor : Interceptor {
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
}
val key = requireNotNull(chain.request.memoryCacheKey)
imageCache.read(key) {
data sendTo thumb
}
}
}
@@ -2,7 +2,7 @@ package com.hippo.ehviewer.coil
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import coil3.asCoilImage
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.network.NetworkHeaders
@@ -16,7 +16,7 @@ import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
import com.hippo.ehviewer.client.url
import com.hippo.ehviewer.client.imageKey
import com.hippo.ehviewer.spider.DownloadInfoMagics.encodeMagicRequestOrUrl
import io.ktor.http.HttpHeaders
@@ -42,7 +42,7 @@ fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
httpHeaders(header)
}
private val stubImage = ColorDrawable(Color.BLACK).asCoilImage(true)
private val stubImage = ColorDrawable(Color.BLACK).asImage(true)
private val stubResult = DecodeResult(stubImage, false)
private val stubFactory = Decoder { stubResult }
@@ -1,24 +1,57 @@
package com.hippo.ehviewer.coil
import android.graphics.Bitmap
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Extras
import coil3.asImage
import coil3.getExtra
import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
/**
* Allocating hardware bitmaps may fail on extremely long images.
*
* Retry with [allowHardware] disabled.
*/
private val hardwareThresholdKey = Extras.Key(default = 16384)
fun ImageRequest.Builder.hardwareThreshold(size: Int) = apply {
extras[hardwareThresholdKey] = size
}
val ImageRequest.hardwareThreshold: Int
get() = getExtra(hardwareThresholdKey)
@RequiresApi(Build.VERSION_CODES.O)
object HardwareBitmapInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult {
val result = chain.proceed()
return if (chain.request.allowHardware && result is ErrorResult && result.throwable is OutOfMemoryError) {
val req = chain.request.newBuilder().allowHardware(false).build()
chain.withRequest(req).proceed()
} else {
result
val request = result.request
if (!request.allowHardware && result is SuccessResult) {
var rect: IntRect? = null
val image = result.image
val bitmap = when (image) {
is BitmapImageWithRect -> image.image.also { rect = image.rect }
is BitmapImage -> image
else -> return result
}.bitmap
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
// This is not ideal but I haven't figured out how to probe the threshold.
// All we know is that it's less than the maximum texture size.
if (maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
bitmap.recycle()
val newImage = hwBitmap.asImage()
return result.copy(
image = rect?.let { BitmapImageWithRect(it, newImage as BitmapImage) } ?: newImage,
request = request,
dataSource = result.dataSource,
)
}
}
}
return result
}
}
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.coil
import coil3.intercept.Interceptor
import coil3.request.ImageResult
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.isNormalPreviewKey
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.CancellableContinuation
@@ -41,7 +42,7 @@ object MergeInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val req = chain.request
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed() }
val key = req.memoryCacheKey?.takeIf { it.isNormalPreviewKey || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorCoroutineContext) { chain.proceed() }
suspendCancellableCoroutine { continuation ->
synchronized(pendingContinuationMapLock) {
@@ -62,7 +63,7 @@ object MergeInterceptor : Interceptor {
}
try {
return withContext(req.interceptorDispatcher) { chain.proceed() }.apply {
return withContext(req.interceptorCoroutineContext) { chain.proceed() }.apply {
// Wake all pending continuations shared with the same memory key since we have written it to memory cache
notifyScope.launch {
synchronized(pendingContinuationMapLock) {
@@ -1,11 +0,0 @@
package com.hippo.ehviewer.cronet
import io.ktor.utils.io.pool.DirectByteBufferPool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
val pool = DirectByteBufferPool(32)
// Limit thread to 1 since we are async & non-blocking
val cronetDispatcher = Dispatchers.Default.limitedParallelism(1)
val cronetHttpClientExecutor = cronetDispatcher.asExecutor()
@@ -1,7 +1,7 @@
package com.hippo.ehviewer.cronet
import com.hippo.ehviewer.Settings
import okio.Path.Companion.toOkioPath
import java.io.File
import org.chromium.net.CronetEngine
import splitties.init.appCtx
@@ -10,8 +10,8 @@ val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
setUserAgent(Settings.userAgent)
// Cache Quic hint only since the real cache mechanism should on Ktor layer
val cache = (appCtx.cacheDir.toOkioPath() / "http_cache").toFile().apply { mkdirs() }
setStoragePath(cache.absolutePath)
val cache = File(appCtx.cacheDir, "http_cache").apply { mkdirs() }
setStoragePath(cache.path)
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096)
addQuicHint("e-hentai.org", 443, 443)
@@ -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
@@ -38,8 +37,7 @@ import splitties.init.appCtx
private const val CHANNEL_ID = "DailyCheckNotification"
class DailyCheckWork(context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) {
class DailyCheckWork(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result = withIOContext {
checkDawn().onFailure {
return@withIOContext Result.retry()
@@ -83,7 +81,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)
@@ -72,7 +72,8 @@ data class DownloadInfo(
@Embedded
val downloadInfo: DownloadEntity = DownloadEntity(galleryInfo.gid),
) : GalleryInfo by galleryInfo, AbstractDownloadInfo by downloadInfo {
) : GalleryInfo by galleryInfo,
AbstractDownloadInfo by downloadInfo {
companion object {
const val STATE_INVALID = -1
@@ -38,17 +38,18 @@ import com.hippo.ehviewer.spider.COMIC_INFO_FILE
import com.hippo.ehviewer.spider.SpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
import com.hippo.ehviewer.spider.putToDownloadDir
import com.hippo.ehviewer.spider.downloadDirname
import com.hippo.ehviewer.spider.readComicInfo
import com.hippo.ehviewer.spider.readCompatFromUniFile
import com.hippo.ehviewer.spider.readCompatFromPath
import com.hippo.ehviewer.spider.toSimpleTags
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.insertWith
import com.hippo.ehviewer.util.mapNotNull
import com.hippo.ehviewer.util.runAssertingNotMainThread
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import com.hippo.unifile.asUniFileOrNull
import com.hippo.files.delete
import com.hippo.files.find
import com.hippo.files.toOkioPath
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -61,6 +62,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import logcat.LogPriority
import okio.Path
import okio.Path.Companion.toOkioPath
import okio.Path.Companion.toPath
import splitties.preferences.edit
object DownloadManager : OnSpiderListener, CoroutineScope {
@@ -172,7 +176,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
} else {
// It is new download info
info = DownloadInfo(galleryInfo, galleryInfo.putToDownloadDir())
info = DownloadInfo(galleryInfo, galleryInfo.downloadDirname())
info.label = label
info.state = DownloadInfo.STATE_WAIT
// Add to all download list and map
@@ -541,7 +545,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
}.parMapNotNull(concurrency = 5) { (info, updateGallery, updateArtist) ->
info.downloadDir?.run {
val comicInfo = findFile(COMIC_INFO_FILE)?.let { readComicInfo(it) }
val comicInfo = find(COMIC_INFO_FILE)?.let { readComicInfo(it) }
if (comicInfo != null) {
val galleryInfo = if (updateGallery) {
info.pages = comicInfo.pageCount
@@ -564,8 +568,8 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
null
}
} else if (info.pages == 0) {
val galleryInfo = findFile(SPIDER_INFO_FILENAME)?.let {
readCompatFromUniFile(it)?.run {
val galleryInfo = find(SPIDER_INFO_FILENAME)?.let {
readCompatFromPath(it)?.run {
info.pages = pages
info.galleryInfo
}
@@ -605,6 +609,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
override fun onGet509(index: Int) {
launch {
mDownloadListener?.onGet509()
stopAllDownload()
}
}
@@ -626,7 +631,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
info.finished = finished
info.downloaded = downloaded
info.total = total
mDownloadListener?.onGetPage(info)
mDownloadListener?.onDownload(info)
mutableNotifyFlow.emit(info)
} ?: logcat(TAG, LogPriority.ERROR) { "Current task is null, but it should not be" }
}
@@ -702,11 +707,6 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
*/
fun onDownload(info: DownloadInfo)
/**
* Update page downloaded
*/
fun onGetPage(info: DownloadInfo)
/**
* Download done
*/
@@ -813,18 +813,21 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
private fun comparator() = SortMode.from(Settings.downloadSortMode.value).comparator()
}
var downloadLocation: UniFile
var downloadLocation: Path
get() = with(Settings) {
val uri = Uri.Builder().apply {
scheme(downloadScheme)
encodedAuthority(downloadAuthority)
encodedPath(downloadPath)
encodedQuery(downloadQuery)
encodedFragment(downloadFragment)
}.build()
uri.asUniFileOrNull() ?: AppConfig.defaultDownloadDir?.asUniFile() ?: UniFile.Stub
if (downloadScheme != null) {
Uri.Builder().apply {
scheme(downloadScheme)
encodedAuthority(downloadAuthority)
encodedPath(downloadPath)
encodedQuery(downloadQuery)
encodedFragment(downloadFragment)
}.build().toOkioPath()
} else {
AppConfig.defaultDownloadDir?.toOkioPath() ?: "".toPath()
}
}
set(value) = with(value.uri) {
set(value) = with(value.toUri()) {
Settings.edit {
downloadScheme = scheme
downloadAuthority = encodedAuthority
@@ -835,5 +838,5 @@ var downloadLocation: UniFile
}
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation / it }
val DownloadInfo.archiveFile get() = downloadDir?.findFile("$gid.cbz")
val GalleryInfo.tempDownloadDir get() = AppConfig.getTempDir("$gid")
val DownloadInfo.archiveFile get() = downloadDir?.run { find("$gid.cbz") ?: find("$gid.zip") }
val GalleryInfo.tempDownloadDir get() = AppConfig.getTempDir("$gid")?.toOkioPath()
@@ -21,8 +21,6 @@ import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.SystemClock
import androidx.annotation.IntDef
import androidx.collection.LongSparseArray
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationChannelCompat
@@ -36,55 +34,57 @@ import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.SimpleHandler
import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.unsafeLazy
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineScope {
class DownloadService :
Service(),
DownloadManager.DownloadListener,
CoroutineScope {
override val coroutineContext = Dispatchers.IO + SupervisorJob()
private val deferredMgr = async { DownloadManager.apply { setDownloadListener(this@DownloadService) } }
private var mNotifyManager: NotificationManagerCompat? = null
private var mDownloadingBuilder: NotificationCompat.Builder? = null
private var mDownloadedBuilder: NotificationCompat.Builder? = null
private var m509dBuilder: NotificationCompat.Builder? = null
private var mDownloadingDelay: NotificationDelay? = null
private var mDownloadedDelay: NotificationDelay? = null
private var m509Delay: NotificationDelay? = null
private var mChannelID: String? = null
private val deferredMgr = async { DownloadManager }
private val notifyManager by unsafeLazy { NotificationManagerCompat.from(this) }
private val downloadingNotification by unsafeLazy { initDownloadingNotification() }
private val downloadedNotification by lazy { initDownloadedNotification() }
private val error509Notification by lazy { init509Notification() }
private val channelId by unsafeLazy { "$packageName.download" }
override fun onCreate() {
super.onCreate()
mChannelID = "$packageName.download"
mNotifyManager = NotificationManagerCompat.from(this)
mNotifyManager!!.createNotificationChannel(
NotificationChannelCompat.Builder(mChannelID!!, NotificationManagerCompat.IMPORTANCE_LOW)
notifyManager.createNotificationChannel(
NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.download_service)).build(),
)
ensureDownloadingBuilder()
mDownloadingBuilder!!.setContentTitle(getString(R.string.download_service))
.setContentText(null)
.setSubText(null)
.setProgress(0, 0, true)
startForeground(ID_DOWNLOADING, mDownloadingBuilder!!.build())
downloadingNotification.builder.run {
setContentTitle(getString(R.string.download_service))
.setContentText(null)
.setSubText(null)
.setProgress(0, 0, true)
startForeground(ID_DOWNLOADING, build())
}
launch {
deferredMgr.await().setDownloadListener(this@DownloadService)
}
}
override fun onDestroy() {
super.onDestroy()
val scope = this
launch {
deferredMgr.await().setDownloadListener(null)
// Wait for the last notification to be posted
delay(DELAY)
scope.cancel()
}
mNotifyManager = null
mDownloadingBuilder = null
mDownloadedBuilder = null
m509dBuilder = null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -152,17 +152,14 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
}
}
override fun onBind(intent: Intent) = error("No bindService")
override fun onBind(intent: Intent) = null
private fun ensureDownloadingBuilder() {
if (mDownloadingBuilder != null) {
return
}
private fun initDownloadingNotification(): NotificationHandler {
val stopAllIntent = Intent(this, DownloadService::class.java)
stopAllIntent.action = ACTION_STOP_ALL
val piStopAll =
PendingIntent.getService(this, 0, stopAllIntent, PendingIntent.FLAG_IMMUTABLE)
mDownloadingBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!)
val builder = NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.setAutoCancel(false)
@@ -173,15 +170,11 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
piStopAll,
)
.setShowWhen(false)
.setChannelId(mChannelID!!)
mDownloadingDelay =
NotificationDelay(this, mNotifyManager!!, mDownloadingBuilder!!, ID_DOWNLOADING)
return NotificationHandler(this, notifyManager, builder, ID_DOWNLOADING)
.apply { launch { run() } }
}
private fun ensureDownloadedBuilder() {
if (mDownloadedBuilder != null) {
return
}
private fun initDownloadedNotification(): NotificationHandler {
val clearIntent = Intent(this, DownloadService::class.java)
clearIntent.action = ACTION_CLEAR
val piClear = PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_IMMUTABLE)
@@ -191,29 +184,26 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
activityIntent.action = ACTION_START_DOWNLOADSCENE
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
val piActivity = PendingIntent.getActivity(
this@DownloadService,
this,
0,
activityIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
mDownloadedBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!)
val builder = NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle(getString(R.string.stat_download_done_title))
.setDeleteIntent(piClear)
.setOngoing(false)
.setAutoCancel(true)
.setContentIntent(piActivity)
mDownloadedDelay =
NotificationDelay(this, mNotifyManager!!, mDownloadedBuilder!!, ID_DOWNLOADED)
return NotificationHandler(this, notifyManager, builder, ID_DOWNLOADED)
.apply { launch { run() } }
}
private fun ensure509Builder() {
if (m509dBuilder != null) {
return
}
m509dBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!)
private fun init509Notification(): NotificationHandler {
val builder = NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.ic_baseline_warning_24)
.setContentText(getString(R.string.stat_509_alert_title))
.setContentTitle(getString(R.string.stat_509_alert_title))
.setContentText(getString(R.string.stat_509_alert_text))
.setStyle(
NotificationCompat.BigTextStyle().bigText(getString(R.string.stat_509_alert_text)),
@@ -221,89 +211,61 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
.setAutoCancel(true)
.setOngoing(false)
.setCategory(NotificationCompat.CATEGORY_ERROR)
m509Delay = NotificationDelay(this, mNotifyManager!!, m509dBuilder!!, ID_509)
return NotificationHandler(this, notifyManager, builder, ID_509)
.apply { launch { run() } }
}
override fun onGet509() {
launch {
deferredMgr.await().stopAllDownload()
if (mNotifyManager == null) {
return@launch
}
ensure509Builder()
m509dBuilder!!.setWhen(System.currentTimeMillis())
m509Delay!!.show()
error509Notification.run {
builder.setWhen(System.currentTimeMillis())
show()
}
}
override fun onStart(info: DownloadInfo) {
if (mNotifyManager == null) {
return
}
ensureDownloadingBuilder()
val bundle = Bundle()
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)
val piActivity = PendingIntent.getActivity(
this@DownloadService,
this,
0,
activityIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
mDownloadingBuilder!!.setContentTitle(EhUtils.getSuitableTitle(info))
.setContentText(null)
.setSubText(null)
.setProgress(0, 0, true)
.setContentIntent(piActivity)
mDownloadingDelay!!.startForeground()
}
private fun onUpdate(info: DownloadInfo) {
if (mNotifyManager == null) {
return
downloadingNotification.run {
builder.setContentTitle(EhUtils.getSuitableTitle(info))
.setContentText(null)
.setSubText(null)
.setProgress(0, 0, true)
.setContentIntent(piActivity)
startForeground()
}
ensureDownloadingBuilder()
var speed = info.speed
if (speed < 0) {
speed = 0
}
var text = FileUtils.humanReadableByteCount(speed, false) + "/s"
val remaining = info.remaining
text = if (remaining >= 0) {
getString(
R.string.download_speed_text_2,
text,
ReadableTime.getShortTimeInterval(remaining),
)
} else {
getString(R.string.download_speed_text, text)
}
mDownloadingBuilder!!.setContentTitle(EhUtils.getSuitableTitle(info))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setSubText(if (info.total == -1 || info.finished == -1) null else info.finished.toString() + "/" + info.total)
.setProgress(info.total, info.finished, false)
mDownloadingDelay!!.startForeground()
}
override fun onDownload(info: DownloadInfo) {
onUpdate(info)
}
override fun onGetPage(info: DownloadInfo) {
onUpdate(info)
val speed = info.speed.coerceAtLeast(0)
val speedText = FileUtils.humanReadableByteCount(speed, false) + "/s"
val remaining = info.remaining
val text = if (remaining >= 0) {
val interval = ReadableTime.getShortTimeInterval(remaining)
getString(R.string.download_speed_text_2, speedText, interval)
} else {
getString(R.string.download_speed_text, speedText)
}
downloadingNotification.run {
builder.setContentTitle(EhUtils.getSuitableTitle(info))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setSubText(if (info.total == -1 || info.finished == -1) null else info.finished.toString() + "/" + info.total)
.setProgress(info.total, info.finished, false)
startForeground()
}
}
override fun onFinish(info: DownloadInfo) {
if (mNotifyManager == null) {
return
}
if (null != mDownloadingDelay) {
mDownloadingDelay!!.cancel()
}
ensureDownloadedBuilder()
downloadingNotification.cancel()
val finish = info.state == DownloadInfo.STATE_FINISH
val gid = info.gid
val index = sItemStateArray.indexOfKey(gid)
@@ -392,21 +354,18 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
} else {
style = null
}
mDownloadedBuilder!!.setContentText(text)
.setStyle(style)
.setWhen(System.currentTimeMillis())
.setNumber(sDownloadedCount)
mDownloadedDelay!!.show()
downloadedNotification.run {
builder.setContentText(text)
.setStyle(style)
.setWhen(System.currentTimeMillis())
.setNumber(sDownloadedCount)
show()
}
checkStopSelf()
}
override fun onCancel(info: DownloadInfo) {
if (mNotifyManager == null) {
return
}
if (null != mDownloadingDelay) {
mDownloadingDelay!!.cancel()
}
downloadingNotification.cancel()
checkStopSelf()
}
@@ -419,105 +378,49 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
}
}
private class NotificationDelay(
private var mService: Service,
private val mNotifyManager: NotificationManagerCompat,
private val mBuilder: NotificationCompat.Builder,
private val mId: Int,
) : Runnable {
private var mLastTime: Long = 0
private var mPosted = false
// false for show, true for cancel
@Ops
private var mOps = 0
private class NotificationHandler(
private val service: Service,
private val notifyManager: NotificationManagerCompat,
val builder: NotificationCompat.Builder,
private val id: Int,
) {
private val channel = Channel<Ops>(Channel.CONFLATED)
fun show() {
if (mPosted) {
mOps = OPS_NOTIFY
} else {
val now = SystemClock.currentThreadTimeMillis()
if (now - mLastTime > DELAY) {
// Wait long enough, do it now
if (ActivityCompat.checkSelfPermission(
mService,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return
}
mNotifyManager.notify(mId, mBuilder.build())
} else {
// Too quick, post delay
mOps = OPS_NOTIFY
mPosted = true
SimpleHandler.postDelayed(this, DELAY)
}
mLastTime = now
}
channel.trySend(Ops.Notify)
}
fun cancel() {
if (mPosted) {
mOps = OPS_CANCEL
} else {
val now = SystemClock.currentThreadTimeMillis()
if (now - mLastTime > DELAY) {
// Wait long enough, do it now
mNotifyManager.cancel(mId)
} else {
// Too quick, post delay
mOps = OPS_CANCEL
mPosted = true
SimpleHandler.postDelayed(this, DELAY)
}
}
channel.trySend(Ops.Cancel)
}
fun startForeground() {
if (mPosted) {
mOps = OPS_START_FOREGROUND
} else {
val now = SystemClock.currentThreadTimeMillis()
if (now - mLastTime > DELAY) {
// Wait long enough, do it now
mService.startForeground(mId, mBuilder.build())
} else {
// Too quick, post delay
mOps = OPS_START_FOREGROUND
mPosted = true
SimpleHandler.postDelayed(this, DELAY)
}
}
channel.trySend(Ops.StartForeground)
}
override fun run() {
mPosted = false
when (mOps) {
OPS_NOTIFY -> {
if (ActivityCompat.checkSelfPermission(
mService,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return
suspend fun run() {
channel.receiveAsFlow().sample(DELAY).collect {
when (it) {
Ops.Notify -> {
if (ActivityCompat.checkSelfPermission(
service,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
) {
notifyManager.notify(id, builder.build())
}
}
mNotifyManager.notify(mId, mBuilder.build())
}
OPS_CANCEL -> mNotifyManager.cancel(mId)
OPS_START_FOREGROUND -> mService.startForeground(mId, mBuilder.build())
Ops.Cancel -> notifyManager.cancel(id)
Ops.StartForeground -> service.startForeground(id, builder.build())
}
}
}
@IntDef(OPS_NOTIFY, OPS_CANCEL, OPS_START_FOREGROUND)
@Retention(AnnotationRetention.SOURCE)
private annotation class Ops
companion object {
private const val OPS_NOTIFY = 0
private const val OPS_CANCEL = 1
private const val OPS_START_FOREGROUND = 2
private const val DELAY: Long = 1000 // 1s
private enum class Ops {
Notify,
Cancel,
StartForeground,
}
}
@@ -544,6 +447,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
private const val ID_DOWNLOADING = 1
private const val ID_DOWNLOADED = 2
private const val ID_509 = 3
private const val DELAY = 1000L // 1s
private val sItemStateArray = LongSparseArray<Boolean>()
private val sItemTitleArray = LongSparseArray<String>()
private var sFailedCount = 0
@@ -28,12 +28,14 @@ import com.hippo.ehviewer.jni.openArchive
import com.hippo.ehviewer.jni.providePassword
import com.hippo.ehviewer.jni.releaseByteBuffer
import com.hippo.ehviewer.util.FileUtils
import com.hippo.unifile.UniFile
import com.hippo.unifile.displayPath
import com.hippo.ehviewer.util.displayPath
import com.hippo.files.openFileDescriptor
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.system.logcat
import java.nio.ByteBuffer
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
@@ -41,32 +43,31 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import moe.tarsin.coroutines.NamedMutex
import moe.tarsin.coroutines.withLock
import okio.Path
typealias PasswdInvalidator = (String) -> Boolean
typealias PasswdProvider = suspend (PasswdInvalidator) -> String
class ArchivePageLoader(
private val file: UniFile,
private val file: Path,
gid: Long = 0,
startPage: Int = 0,
passwdProvider: PasswdProvider? = null,
) : PageLoader2(gid, startPage) {
private lateinit var pfd: ParcelFileDescriptor
private val hostJob = launch(start = CoroutineStart.LAZY) {
logcat(DEBUG_TAG) { "Open archive ${file.uri.displayPath}" }
private val hostJob = async(start = CoroutineStart.LAZY) {
logcat(DEBUG_TAG) { "Open archive ${file.toUri().displayPath}" }
pfd = file.openFileDescriptor("r")
size = openArchive(pfd.fd, pfd.statSize, gid == 0L)
if (size == 0) {
return@launch
}
if (passwdProvider != null && needPassword()) {
val size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
if (size != 0 && passwdProvider != null && needPassword()) {
archivePasswds?.forEach {
it ?: return@forEach
if (providePassword(it)) return@launch
if (providePassword(it)) return@async size
}
val toAdd = passwdProvider { providePassword(it) }
archivePasswds = archivePasswds?.toMutableSet()?.apply { add(toAdd) } ?: setOf(toAdd)
}
size
}
override var size = 0
@@ -81,7 +82,7 @@ class ArchivePageLoader(
super.stop()
closeArchive()
pfd.close()
logcat(DEBUG_TAG) { "Close archive ${file.uri.displayPath} successfully!" }
logcat(DEBUG_TAG) { "Close archive ${file.toUri().displayPath} successfully!" }
}
private val mJobMap = hashMapOf<Int, Job>()
@@ -134,8 +135,8 @@ class ArchivePageLoader(
}
override suspend fun awaitReady(): Boolean {
hostJob.join()
return super.awaitReady() && size != 0
size = hostJob.await()
return super.awaitReady() && isReady
}
override val isReady: Boolean
@@ -149,22 +150,18 @@ class ArchivePageLoader(
FileUtils.getNameFromFilename(file.name)!!
}
override fun getImageExtension(index: Int): String {
return getExtension(index)
}
override fun getImageExtension(index: Int): String = getExtension(index)
override fun save(index: Int, file: UniFile): Boolean {
return runCatching {
file.openFileDescriptor("w").use {
extractToFd(index, it.fd)
}
}.getOrElse {
logcat(it)
false
override fun save(index: Int, file: Path): Boolean = runCatching {
file.openFileDescriptor("w").use {
extractToFd(index, it.fd)
}
}.getOrElse {
logcat(it)
false
}
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {}
override fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>) = Unit
}
private const val DEBUG_TAG = "ArchivePageLoader"
@@ -22,10 +22,11 @@ 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.unifile.UniFile
import okio.Path
class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
PageLoader2(mGalleryInfo.gid, startPage), OnSpiderListener {
PageLoader2(mGalleryInfo.gid, startPage),
OnSpiderListener {
private lateinit var mSpiderQueen: SpiderQueen
override fun start() {
super.start()
@@ -45,13 +46,9 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
EhUtils.getSuitableTitle(mGalleryInfo)
}
override fun getImageExtension(index: Int): String? {
return mSpiderQueen.getExtension(index)
}
override fun getImageExtension(index: Int): String? = mSpiderQueen.getExtension(index)
override fun save(index: Int, file: UniFile): Boolean {
return mSpiderQueen.save(index, file)
}
override fun save(index: Int, file: Path): Boolean = mSpiderQueen.save(index, file)
override val size: Int
get() = mSpiderQueen.size
@@ -64,9 +61,7 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
mSpiderQueen.forceRequest(index, orgImg)
}
override suspend fun awaitReady(): Boolean {
return super.awaitReady() && mSpiderQueen.awaitReady()
}
override suspend fun awaitReady(): Boolean = super.awaitReady() && mSpiderQueen.awaitReady()
override val isReady: Boolean
get() = ::mSpiderQueen.isInitialized && mSpiderQueen.isReady
@@ -110,7 +105,7 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
notifyPageFailed(index, error)
}
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {
mSpiderQueen.preloadPages(pages, pair)
override fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>) {
mSpiderQueen.preloadPages(pages, bounds)
}
}
@@ -15,35 +15,35 @@
*/
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
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import okio.Path
abstract class PageLoader2(private val gid: Long, var startPage: Int) : PageLoader(), CoroutineScope {
abstract class PageLoader2(private val gid: Long, var startPage: Int) :
PageLoader(),
CoroutineScope {
override val coroutineContext = Dispatchers.IO + Job()
private val progressJob = launch(start = CoroutineStart.LAZY) {
private val progressJob = async(start = CoroutineStart.LAZY) {
if (startPage == -1) {
startPage = EhDB.getReadProgress(gid)
}
}
override fun start() {
super.start()
progressJob.start()
}
@CallSuper
override suspend fun awaitReady(): Boolean {
progressJob.join()
return startPage != -1
progressJob.await()
return super.awaitReady() && startPage != -1
}
override fun stop() {
@@ -66,9 +66,5 @@ abstract class PageLoader2(private val gid: Long, var startPage: Int) : PageLoad
FileUtils.sanitizeFilename("$title - ${index + 1}.${it.lowercase()}")
}
abstract fun save(index: Int, file: UniFile): Boolean
fun saveToDir(index: Int, dir: UniFile): UniFile? = getImageFilename(index)?.let { filename ->
(dir / filename).takeIf { save(index, it) }
}
abstract fun save(index: Int, file: Path): Boolean
}
@@ -18,7 +18,6 @@
package com.hippo.ehviewer.image
import android.graphics.Bitmap
import android.graphics.drawable.Animatable
import androidx.compose.ui.unit.IntRect
import arrow.core.Either
import arrow.core.left
@@ -30,10 +29,12 @@ import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ErrorResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.size.Dimension
import coil3.size.Precision
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.coil.BitmapImageWithRect
import com.hippo.ehviewer.coil.hardwareThreshold
import com.hippo.ehviewer.coil.maybeCropBorder
import com.hippo.ehviewer.jni.isGif
import com.hippo.ehviewer.jni.mmap
@@ -42,12 +43,14 @@ import com.hippo.ehviewer.jni.rewriteGifSource
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastU
import com.hippo.unifile.UniFile
import com.hippo.files.openFileDescriptor
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.system.logcat
import java.nio.ByteBuffer
import okio.Path
import splitties.init.appCtx
class Image private constructor(image: CoilImage, private val src: AutoCloseable) {
class Image private constructor(image: CoilImage, private val src: ImageSource) {
val size = image.size
val rect = when (image) {
is BitmapImageWithRect -> image.rect
@@ -59,14 +62,12 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
else -> image
}
var isRecyclable = true
@Synchronized
fun recycle() {
when (val image = innerImage ?: return) {
is DrawableImage -> {
(image.drawable as Animatable).stop()
image.drawable.callback = null
src.close()
}
is DrawableImage -> src.close()
is BitmapImage -> image.bitmap.recycle()
}
innerImage = null
@@ -75,12 +76,14 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
companion object {
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
private suspend fun Either<ByteBufferSource, UniFileSource>.decodeCoil(): CoilImage {
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(): CoilImage {
val req = appCtx.imageRequest {
onLeft { data(it.source) }
onRight { data(it.source.uri) }
onRight { data(it.source.toUri()) }
size(Dimension(targetWidth), Dimension.Undefined)
precision(Precision.INEXACT)
allowHardware(false)
hardwareThreshold(Settings.hardwareBitmapThreshold)
maybeCropBorder(Settings.cropBorder.value)
memoryCachePolicy(CachePolicy.DISABLED)
}
@@ -93,7 +96,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
suspend fun decode(src: ImageSource): Image? {
return runCatching {
val image = when (src) {
is UniFileSource -> {
is PathSource -> {
if (isAtLeastP && !isAtLeastU) {
src.source.openFileDescriptor("rw").use {
val fd = it.fd
@@ -133,8 +136,8 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
sealed interface ImageSource : AutoCloseable
interface UniFileSource : ImageSource {
val source: UniFile
interface PathSource : ImageSource {
val source: Path
val type: String
}
@@ -10,4 +10,4 @@ external fun getExtension(index: Int): String
external fun needPassword(): Boolean
external fun providePassword(str: String): Boolean
external fun closeArchive()
external fun archiveFdBatch(fdBatch: IntArray, names: Array<String>, arcFd: Int, size: Int): Boolean
external fun archiveFdBatch(fdBatch: IntArray, names: Array<String>, arcFd: Int, size: Int)
@@ -0,0 +1,9 @@
package com.hippo.ehviewer.ktor
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.HttpClientEngineFactory
object Cronet : HttpClientEngineFactory<CronetConfig> {
override fun create(block: CronetConfig.() -> Unit): HttpClientEngine =
CronetEngine(CronetConfig().apply(block))
}
@@ -0,0 +1,13 @@
package com.hippo.ehviewer.ktor
import android.content.Context
import io.ktor.client.engine.HttpClientEngineConfig
import org.chromium.net.CronetEngine
class CronetConfig : HttpClientEngineConfig() {
var client: CronetEngine? = null
fun config(context: Context, block: CronetEngine.Builder.() -> Unit) {
client = CronetEngine.Builder(context).apply(block).build()
}
}
@@ -1,11 +1,6 @@
package com.hippo.ehviewer.ktor
import com.hippo.ehviewer.cronet.cronetDispatcher
import com.hippo.ehviewer.cronet.cronetHttpClient
import com.hippo.ehviewer.cronet.cronetHttpClientExecutor
import com.hippo.ehviewer.cronet.pool
import io.ktor.client.engine.HttpClientEngineBase
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.callContext
import io.ktor.client.request.HttpRequestData
import io.ktor.client.request.HttpResponseData
@@ -18,12 +13,15 @@ import io.ktor.util.date.GMTDate
import io.ktor.util.flattenForEach
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.InternalAPI
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import io.ktor.utils.io.writer
import java.nio.ByteBuffer
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.job
@@ -33,9 +31,12 @@ 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
class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cronet") {
// Limit thread to 1 since we are async & non-blocking
override val dispatcher = Dispatchers.Default.limitedParallelism(1)
private val executor = dispatcher.asExecutor()
private val pool = DirectByteBufferPool(32)
private val client = config.client ?: error("Cronet client is not configured")
@InternalAPI
override suspend fun execute(data: HttpRequestData) = executeHttpRequest(callContext(), data)
@@ -94,12 +95,12 @@ object CronetEngine : HttpClientEngineBase("Cronet") {
}
}
cronetHttpClient.newUrlRequestBuilder(data.url.toString(), callback, cronetHttpClientExecutor).apply {
client.newUrlRequestBuilder(data.url.toString(), callback, executor).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()?.let { setUploadDataProvider(it, cronetHttpClientExecutor) }
data.body.toUploadDataProvider()?.let { setUploadDataProvider(it, executor) }
}.build().apply {
start()
callContext.job.invokeOnCompletion { cancel() }
@@ -7,17 +7,11 @@ import okio.source
fun readLegacySpiderInfo(inputStream: InputStream): SpiderInfo {
val source = inputStream.source().buffer()
fun read(): String {
return source.readUtf8LineStrict()
}
fun read(): String = source.readUtf8LineStrict()
fun readInt(): Int {
return read().toInt()
}
fun readInt(): Int = read().toInt()
fun readLong(): Long {
return read().toLong()
}
fun readLong(): Long = read().toLong()
repeat(2) { read() } // We assert that only info v2
val gid = readLong()
val token = read()
@@ -5,11 +5,8 @@ import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.SimpleTagsConverter
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.unifile.UniFile
import com.hippo.unifile.openInputStream
import com.hippo.unifile.openOutputStream
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import com.hippo.files.openInputStream
import com.hippo.files.openOutputStream
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -19,10 +16,11 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.newGenericWriter
import nl.adaptivity.xmlutil.newWriter
import nl.adaptivity.xmlutil.serialization.XML
import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.xmlStreaming
import okio.Path
const val COMIC_INFO_FILE = "ComicInfo.xml"
private const val TAG_ORIGINAL = "original"
@@ -62,6 +60,7 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
else -> simpleTags?.forEach { tagString ->
val (namespace, tag) = tagString.split(':', limit = 2)
.takeIf { it.size == 2 } ?: return@forEach // Ignore temp tags that don't have namespace
when (val ns = TagNamespace(namespace)) {
Artist, Cosplayer -> artists.add(tag)
Group -> groups.add(tag)
@@ -97,22 +96,18 @@ fun ComicInfo.toSimpleTags() = listOfNotNull(
teams,
).flatten().ifEmpty { null }
fun ComicInfo.write(file: UniFile) {
file.openOutputStream().use {
OutputStreamWriter(it, Charsets.UTF_8).use { writer ->
xmlStreaming.newGenericWriter(writer).use { xmlWriter ->
xml.encodeToWriter(xmlWriter, ComicInfo.serializer(), this)
}
fun ComicInfo.write(file: Path) {
file.openOutputStream().bufferedWriter().use {
xmlStreaming.newWriter(it).use { writer ->
xml.encodeToWriter(writer, ComicInfo.serializer(), this)
}
}
}
fun readComicInfo(file: UniFile): ComicInfo? = runCatching {
file.openInputStream().use {
InputStreamReader(it, Charsets.UTF_8).use { reader ->
xmlStreaming.newGenericReader(reader).use { xmlReader ->
xml.decodeFromReader(ComicInfo.serializer(), xmlReader)
}
fun readComicInfo(file: Path): ComicInfo? = runCatching {
file.openInputStream().bufferedReader().use {
xmlStreaming.newReader(it).use { reader ->
xml.decodeFromReader(ComicInfo.serializer(), reader)
}
}
}.getOrNull()
@@ -18,12 +18,10 @@ object DownloadInfoMagics {
}
}
fun decodeMagicRequestOrUrl(encoded: String): Pair<String, String?> {
return if (encoded.startsWith(DOWNLOAD_INFO_DIRNAME_URL_MAGIC)) {
val (a, b) = encoded.removePrefix(DOWNLOAD_INFO_DIRNAME_URL_MAGIC).split(DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR)
a to b
} else {
encoded to null
}
fun decodeMagicRequestOrUrl(encoded: String): Pair<String, String?> = if (encoded.startsWith(DOWNLOAD_INFO_DIRNAME_URL_MAGIC)) {
val (a, b) = encoded.removePrefix(DOWNLOAD_INFO_DIRNAME_URL_MAGIC).split(DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR)
a to b
} else {
encoded to null
}
}
@@ -36,14 +36,18 @@ 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
import com.hippo.ehviewer.image.PathSource
import com.hippo.ehviewer.jni.archiveFdBatch
import com.hippo.ehviewer.util.FileUtils
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 com.hippo.ehviewer.util.sha1
import com.hippo.files.delete
import com.hippo.files.exists
import com.hippo.files.find
import com.hippo.files.isDirectory
import com.hippo.files.list
import com.hippo.files.openFileDescriptor
import com.hippo.files.openOutputStream
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.plugins.onDownload
import io.ktor.client.statement.HttpResponse
@@ -53,16 +57,23 @@ import io.ktor.http.isSuccess
import io.ktor.utils.io.jvm.nio.copyTo
import kotlin.io.path.readText
import kotlinx.coroutines.CancellationException
import okio.Path
class SpiderDen(val info: GalleryInfo) {
private val gid = info.gid
var downloadDir: UniFile? = null
var downloadDir: Path? = null
private set
private var tempDownloadDir: UniFile? = null
private var tempDownloadDir: Path? = null
private val saveAsCbz = Settings.saveAsCbz
private val archiveName = "$gid.cbz"
// Search in both directories to maintain compatibility
private val fileCache by lazy {
(tempDownloadDir?.list().orEmpty() + downloadDir?.list().orEmpty())
.associateBy { it.name } as MutableMap
}
private val imageDir
get() = tempDownloadDir.takeIf { saveAsCbz } ?: downloadDir
@@ -79,10 +90,10 @@ class SpiderDen(val info: GalleryInfo) {
mode = value
if (mode == SpiderQueen.MODE_DOWNLOAD) {
if (downloadDir == null) {
downloadDir = getGalleryDownloadDir(gid)?.takeIf { it.ensureDir() }
downloadDir = getGalleryDownloadDir(info)
}
if (saveAsCbz && tempDownloadDir == null) {
tempDownloadDir = info.tempDownloadDir?.takeIf { it.ensureDir() }
tempDownloadDir = info.tempDownloadDir!!
}
}
}
@@ -92,14 +103,12 @@ class SpiderDen(val info: GalleryInfo) {
return sCache.read(key) { true } ?: false
}
// Search in both directories to maintain compatibility
private fun findImageFile(index: Int): UniFile? {
return tempDownloadDir?.findImageFile(index) ?: downloadDir?.findImageFile(index)
private fun findImageFile(index: Int) = synchronized(fileCache) {
val head = perFilename(index)
fileCache.entries.firstOrNull { (name) -> name.startsWith(head) }?.value
}
private fun containInDownloadDir(index: Int): Boolean {
return findImageFile(index) != null
}
private fun containInDownloadDir(index: Int): Boolean = findImageFile(index) != null
private fun copyFromCacheToDownloadDir(index: Int): Boolean {
val dir = imageDir ?: return false
@@ -107,8 +116,8 @@ class SpiderDen(val info: GalleryInfo) {
return runCatching {
sCache.read(key) {
val extension = metadata.toFile().readText()
val file = dir.createFile(perFilename(index, extension)) ?: return false
data.asUniFile() sendTo file
val file = dir.findDownloadFileForIndex(index, extension)
data sendTo file
true
} ?: false
}.onFailure {
@@ -116,19 +125,17 @@ class SpiderDen(val info: GalleryInfo) {
}.getOrDefault(false)
}
operator fun contains(index: Int): Boolean {
return when (mode) {
SpiderQueen.MODE_READ -> {
containInCache(index) || containInDownloadDir(index)
}
operator fun contains(index: Int): Boolean = when (mode) {
SpiderQueen.MODE_READ -> {
containInCache(index) || containInDownloadDir(index)
}
SpiderQueen.MODE_DOWNLOAD -> {
containInDownloadDir(index) || copyFromCacheToDownloadDir(index)
}
SpiderQueen.MODE_DOWNLOAD -> {
containInDownloadDir(index) || copyFromCacheToDownloadDir(index)
}
else -> {
false
}
else -> {
false
}
}
@@ -137,16 +144,20 @@ class SpiderDen(val info: GalleryInfo) {
return sCache.remove(key)
}
private fun removeFromDownloadDir(index: Int): Boolean {
return findImageFile(index)?.delete() ?: false
private fun removeFromDownloadDir(index: Int) = findImageFile(index)?.run {
delete()
synchronized(fileCache) {
fileCache.remove(name)
}
}
fun remove(index: Int): Boolean {
return removeFromCache(index) or removeFromDownloadDir(index)
}
fun remove(index: Int) = removeFromCache(index).also { removeFromDownloadDir(index) }
private fun findDownloadFileForIndex(index: Int, extension: String): UniFile? {
return imageDir?.createFile(perFilename(index, extension))
private fun Path.findDownloadFileForIndex(index: Int, extension: String): Path {
val name = perFilename(index, extension)
return synchronized(fileCache) {
fileCache.getOrPut(name) { resolve(name) }
}
}
suspend fun makeHttpCallAndSaveImage(
@@ -171,10 +182,10 @@ class SpiderDen(val info: GalleryInfo) {
private suspend inline fun saveResponseMeta(
index: Int,
ext: String,
crossinline fops: suspend (UniFile) -> Unit,
crossinline fops: suspend (Path) -> Unit,
): Boolean {
findDownloadFileForIndex(index, ext)?.run {
fops(this)
imageDir?.run {
fops(findDownloadFileForIndex(index, ext))
return true
}
@@ -183,7 +194,7 @@ class SpiderDen(val info: GalleryInfo) {
val key = getImageKey(gid, index)
return sCache.suspendEdit(key) {
metadata.toFile().writeText(ext)
fops(data.asUniFile())
fops(data)
}
}
return false
@@ -196,16 +207,19 @@ class SpiderDen(val info: GalleryInfo) {
outFile.openOutputStream().use {
response.bodyAsChannel().copyTo(it.channel)
}
val expected = FileHashRegex.findAll(url).last().groupValues[1]
val actual = outFile.sha1()
check(expected == actual) { "File hash mismatch: expected $expected, but got $actual\nURL: $url" }
}
}
fun saveToUniFile(index: Int, file: UniFile): Boolean {
fun saveToPath(index: Int, file: Path): Boolean {
val key = getImageKey(gid, index)
// Read from diskCache first
sCache.read(key) {
runCatching {
data.asUniFile() sendTo file
data sendTo file
return true
}.onFailure {
logcat(it)
@@ -231,13 +245,13 @@ class SpiderDen(val info: GalleryInfo) {
?: findImageFile(index)?.name.let { FileUtils.getExtensionFromFilename(it) }
}
fun getImageSource(index: Int): UniFileSource? {
fun getImageSource(index: Int): PathSource? {
if (mode == SpiderQueen.MODE_READ) {
val key = getImageKey(gid, index)
val snapshot = sCache.openSnapshot(key)
if (snapshot != null) {
return object : UniFileSource, AutoCloseable by snapshot {
override val source = snapshot.data.asUniFile()
return object : PathSource, AutoCloseable by snapshot {
override val source = snapshot.data
override val type by lazy {
snapshot.metadata.toNioPath().readText()
}
@@ -245,7 +259,7 @@ class SpiderDen(val info: GalleryInfo) {
}
}
val source = findImageFile(index) ?: return null
return object : UniFileSource {
return object : PathSource {
override val source = source
override val type by lazy {
FileUtils.getExtensionFromFilename(source.name)!!
@@ -258,7 +272,7 @@ class SpiderDen(val info: GalleryInfo) {
suspend fun archive() {
if (saveAsCbz) {
downloadDir?.run {
findFile(archiveName) ?: createFile(archiveName)?.let { file ->
resolve(archiveName).let { file ->
runCatching {
archiveTo(file)
}.onFailure {
@@ -273,23 +287,23 @@ class SpiderDen(val info: GalleryInfo) {
suspend fun postArchive(): Boolean {
val dir = downloadDir
val archived = saveAsCbz && dir?.findFile(archiveName) != null
val archived = saveAsCbz && dir?.find(archiveName) != null
if (archived) {
dir.listFiles().parMap(concurrency = 10) {
if (it.name?.matches(filenamePattern) == true) {
dir.list().parMap(concurrency = 10) {
if (it.name.matches(FileNameRegex)) {
it.delete()
}
}
dir.findFile(SpiderQueen.SPIDER_INFO_FILENAME)?.delete()
(dir / SpiderQueen.SPIDER_INFO_FILENAME).delete()
tempDownloadDir?.delete()
}
return archived
}
suspend fun exportAsCbz(file: UniFile) =
downloadDir!!.findFile(archiveName)?.sendTo(file) ?: archiveTo(file)
suspend fun exportAsCbz(file: Path) =
downloadDir!!.find(archiveName)?.sendTo(file) ?: archiveTo(file)
private suspend fun archiveTo(file: UniFile) = resourceScope {
private suspend fun archiveTo(file: Path) = resourceScope {
val comicInfo = closeable {
val f = downloadDir!! / COMIC_INFO_FILE
if (!f.exists()) {
@@ -309,23 +323,20 @@ class SpiderDen(val info: GalleryInfo) {
}
suspend fun initDownloadDirIfExist() {
downloadDir = getGalleryDownloadDir(gid)?.takeIf { it.isDirectory }
downloadDir = getGalleryDownloadDir(info).takeIf { it.isDirectory }
tempDownloadDir = info.tempDownloadDir?.takeIf { it.isDirectory }
}
suspend fun initDownloadDir() {
downloadDir = getGalleryDownloadDir(gid) ?: (downloadLocation / info.putToDownloadDir())
check(downloadDir!!.ensureDir())
downloadDir = getGalleryDownloadDir(info)
}
suspend fun writeComicInfo(fetchMetadata: Boolean = true) {
downloadDir?.run {
createFile(COMIC_INFO_FILE)?.also {
resolve(COMIC_INFO_FILE).also {
runCatching {
if (info !is GalleryDetail && fetchMetadata) {
withNonCancellableContext {
EhEngine.fillGalleryListByApi(listOf(info))
}
EhEngine.fillGalleryListByApi(listOf(info))
}
info.getComicInfo().apply {
write(it)
@@ -342,25 +353,22 @@ class SpiderDen(val info: GalleryInfo) {
}
}
private val filenamePattern = Regex("^\\d{8}\\.\\w{3,4}")
private val FileNameRegex = Regex("^\\d{8}\\.\\w{3,4}")
private val FileHashRegex = Regex("/([0-9a-f]{40})(?:-\\d+){3}-\\w+")
fun perFilename(index: Int, extension: String = ""): String {
return "%08d.%s".format(index + 1, extension)
}
fun perFilename(index: Int, extension: String = ""): String = "%08d.%s".format(index + 1, extension)
private fun UniFile.findImageFile(index: Int): UniFile? {
val head = perFilename(index)
return findFirst { name -> name.startsWith(head) }
}
suspend fun GalleryInfo.putToDownloadDir(): String {
val title = getSuitableTitle(this)
val dirname = FileUtils.sanitizeFilename("$gid-$title")
EhDB.putDownloadDirname(gid, dirname)
suspend fun GalleryInfo.downloadDirname(): String {
var dirname = EhDB.getDownloadDirname(gid)
if (dirname == null) {
val title = getSuitableTitle(this)
dirname = FileUtils.sanitizeFilename("$gid-$title")
EhDB.putDownloadDirname(gid, dirname)
}
return dirname
}
suspend fun getGalleryDownloadDir(gid: Long): UniFile? {
val dirname = EhDB.getDownloadDirname(gid) ?: return null
suspend fun getGalleryDownloadDir(info: GalleryInfo): Path {
val dirname = info.downloadDirname()
return downloadLocation / dirname
}
@@ -4,15 +4,15 @@ import com.hippo.ehviewer.coil.edit
import com.hippo.ehviewer.coil.read
import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.legacy.readLegacySpiderInfo
import com.hippo.unifile.UniFile
import com.hippo.unifile.openInputStream
import com.hippo.unifile.openOutputStream
import com.hippo.files.openInputStream
import com.hippo.files.openOutputStream
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path
import okio.Path.Companion.toOkioPath
import splitties.init.appCtx
@@ -35,7 +35,7 @@ private val cbor = Cbor {
ignoreUnknownKeys = true
}
fun SpiderInfo.write(file: UniFile) {
fun SpiderInfo.write(file: Path) {
file.openOutputStream().use {
it.write(cbor.encodeToByteArray(this))
}
@@ -58,24 +58,20 @@ private val spiderInfoCache by lazy {
}
}
fun readFromCache(gid: Long): SpiderInfo? {
return spiderInfoCache.read(gid.toString()) {
runCatching {
cbor.decodeFromByteArray<SpiderInfo>(data.toFile().readBytes())
}.onFailure {
logcat(it)
}.getOrNull()
}
}
fun readCompatFromUniFile(file: UniFile): SpiderInfo? {
return runCatching {
file.openInputStream().use {
cbor.decodeFromByteArray<SpiderInfo>(it.readBytes())
}
}.getOrNull() ?: runCatching {
file.openInputStream().use { readLegacySpiderInfo(it) }
fun readFromCache(gid: Long): SpiderInfo? = spiderInfoCache.read(gid.toString()) {
runCatching {
cbor.decodeFromByteArray<SpiderInfo>(data.toFile().readBytes())
}.onFailure {
logcat(it)
}.getOrNull()
}
fun readCompatFromPath(file: Path): SpiderInfo? = runCatching {
file.openInputStream().use {
cbor.decodeFromByteArray<SpiderInfo>(it.readBytes())
}
}.getOrNull() ?: runCatching {
file.openInputStream().use { readLegacySpiderInfo(it) }
}.getOrNull()
const val TOKEN_FAILED = "failed"
@@ -38,7 +38,7 @@ import com.hippo.ehviewer.client.parser.GalleryMultiPageViewerPTokenParser
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.util.displayString
import com.hippo.unifile.UniFile
import com.hippo.files.find
import eu.kanade.tachiyomi.util.system.logcat
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration.Companion.milliseconds
@@ -50,6 +50,7 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
@@ -61,6 +62,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeout
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path
import splitties.init.appCtx
class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineScope {
@@ -223,7 +225,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
check(!(mReadReference < 0 || mDownloadReference < 0)) { "Mode reference < 0" }
}
private val prepareJob = launch { doPrepare() }
private val prepareJob = async { doPrepare() }
private val archiveJob = launch(start = CoroutineStart.LAZY) { mSpiderDen.archive() }
private suspend fun doPrepare() {
@@ -234,7 +236,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
suspend fun awaitReady(): Boolean {
prepareJob.join()
prepareJob.await()
return isReady
}
@@ -244,12 +246,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()
}
@@ -299,12 +304,12 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
mWorkerScope.launch(index, force, orgImg)
}
fun save(index: Int, file: UniFile): Boolean {
fun save(index: Int, file: Path): Boolean {
val state = getPageState(index)
return if (STATE_FINISHED != state) {
false
} else {
mSpiderDen.saveToUniFile(index, file)
mSpiderDen.saveToPath(index, file)
}
}
@@ -317,16 +322,14 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}
private fun readSpiderInfoFromLocal(): SpiderInfo? {
return mSpiderDen.downloadDir?.run {
findFile(SPIDER_INFO_FILENAME)?.let { file ->
readCompatFromUniFile(file)?.takeIf {
it.gid == galleryInfo.gid && it.token == galleryInfo.token
}
private fun readSpiderInfoFromLocal(): SpiderInfo? = mSpiderDen.downloadDir?.run {
find(SPIDER_INFO_FILENAME)?.let { file ->
readCompatFromPath(file)?.takeIf {
it.gid == galleryInfo.gid && it.token == galleryInfo.token
}
}
?: readFromCache(galleryInfo.gid)?.takeIf { it.gid == galleryInfo.gid && it.token == galleryInfo.token }
}
?: readFromCache(galleryInfo.gid)?.takeIf { it.gid == galleryInfo.gid && it.token == galleryInfo.token }
private fun readPreviews(body: String, index: Int, spiderInfo: SpiderInfo) {
spiderInfo.previewPages = parsePreviewPages(body)
@@ -346,21 +349,19 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}
private suspend fun readSpiderInfoFromInternet(): SpiderInfo? {
return runSuspendCatching {
ehRequest(
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
referer,
).fetchUsingAsText {
val pages = parsePages(this)
val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, pages)
readPreviews(this, 0, spiderInfo)
spiderInfo
}
}.onFailure {
logcat(it)
}.getOrNull()
}
private suspend fun readSpiderInfoFromInternet(): SpiderInfo? = runSuspendCatching {
ehRequest(
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
referer,
).fetchUsingAsText {
val pages = parsePages(this)
val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, pages)
readPreviews(this, 0, spiderInfo)
spiderInfo
}
}.onFailure {
logcat(it)
}.getOrNull()
suspend fun getPTokenFromMultiPageViewer(index: Int): String? {
val spiderInfo = mSpiderInfo
@@ -414,13 +415,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
@Synchronized
private fun writeSpiderInfoToLocal() {
if (!isReady) return
mSpiderDen.downloadDir?.run { createFile(SPIDER_INFO_FILENAME)?.also { mSpiderInfo.write(it) } }
mSpiderDen.downloadDir?.run { mSpiderInfo.write(resolve(SPIDER_INFO_FILENAME)) }
mSpiderInfo.saveToCache()
}
private fun isStateDone(state: Int): Boolean {
return state == STATE_FINISHED || state == STATE_FAILED
}
private fun isStateDone(state: Int): Boolean = state == STATE_FINISHED || state == STATE_FAILED
fun updatePageState(index: Int, @State state: Int, error: String? = null) {
var oldState: Int
@@ -483,13 +482,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 +509,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,16 +541,16 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private fun doLaunchDownloadJob(index: Int, force: Boolean, orgImg: Boolean = false) {
val state = mPageStateArray[index]
if (!force) {
if (state == STATE_FINISHED) return
if (index in mSpiderDen) return updatePageState(index, STATE_FINISHED)
}
if (!force && state == STATE_FINISHED) return
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 {
if (!force && index in mSpiderDen) {
return@runCatching updatePageState(index, STATE_FINISHED)
}
delayLock.withLock {
delay(mDownloadDelay - lastRequestTime.elapsedNow())
lastRequestTime = TimeSource.Monotonic.markNow()
@@ -648,15 +642,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
}
}
@@ -18,6 +18,7 @@ package com.hippo.ehviewer.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -40,7 +41,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
@@ -55,18 +55,21 @@ import com.hippo.ehviewer.download.downloadDir
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.download.tempDownloadDir
import com.hippo.ehviewer.ui.destinations.ReaderScreenDestination
import com.hippo.ehviewer.ui.reader.ReaderScreenArgs
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.bgWork
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.isAtLeastT
import com.hippo.ehviewer.util.mapToLongArray
import com.hippo.ehviewer.util.requestPermission
import com.hippo.ehviewer.util.toEpochMillis
import com.hippo.ehviewer.util.toLocalDateTime
import com.hippo.unifile.UniFile
import com.hippo.files.delete
import com.hippo.files.isDirectory
import com.hippo.files.openOutputStream
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.sync.Mutex
@@ -78,20 +81,20 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.todayIn
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path
import splitties.init.appCtx
private fun removeNoMediaFile(downloadDir: UniFile) {
val noMedia = downloadDir / ".nomedia"
noMedia.delete()
private fun removeNoMediaFile(downloadDir: Path) {
(downloadDir / ".nomedia").delete()
}
private fun ensureNoMediaFile(downloadDir: UniFile) {
downloadDir.createFile(".nomedia") ?: return
private fun ensureNoMediaFile(downloadDir: Path) {
(downloadDir / ".nomedia").openOutputStream().close()
}
private val lck = Mutex()
suspend fun keepNoMediaFileStatus(downloadDir: UniFile = downloadLocation) {
suspend fun keepNoMediaFileStatus(downloadDir: Path = downloadLocation) {
if (downloadDir.isDirectory) {
lck.withLock {
if (Settings.mediaScan) {
@@ -190,7 +193,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,10 +209,14 @@ suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
add(localFav)
addAll(cloudFav)
}
if (galleryInfo.favoriteSlot >= 0 && galleryInfo.favoriteNote == null) {
galleryInfo.favoriteNote = bgWork { EhEngine.getFavoriteNote(galleryInfo.gid, galleryInfo.token) }
}
val (slot, note) = awaitSelectItemWithIconAndTextField(
items,
title = R.string.add_favorites_dialog_title,
hint = R.string.favorite_note,
initialNote = galleryInfo.favoriteNote.orEmpty(),
maxChar = MAX_FAVNOTE_CHAR,
)
return doModifyFavorites(galleryInfo, if (isFavorited) slot - 2 else slot - 1, localFavorited, note)
@@ -248,6 +255,7 @@ private suspend fun doModifyFavorites(
in 0..9 -> {
EhEngine.modifyFavorites(galleryInfo.gid, galleryInfo.token, slot, note)
galleryInfo.favoriteNote = note
true
}
@@ -273,21 +281,14 @@ suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorite
localFavorited = EhDB.containLocalFavorites(galleryInfo.gid),
)
context (Context, DestinationsNavigator)
fun navToReader(
info: BaseGalleryInfo,
page: Int = -1,
) {
if (Settings.newReader) {
navigate(ReaderScreenDestination(info, page))
} else {
val intent = Intent(this@Context, ReaderActivity::class.java)
intent.action = ReaderActivity.ACTION_EH
intent.putExtra(ReaderActivity.KEY_GALLERY_INFO, info)
intent.putExtra(ReaderActivity.KEY_PAGE, page)
startActivity(intent)
}
}
fun DestinationsNavigator.navToReader(info: BaseGalleryInfo, page: Int = -1) =
navToReader(ReaderScreenArgs.Gallery(info, page))
fun DestinationsNavigator.navToReader(uri: Uri) =
navToReader(ReaderScreenArgs.Archive(uri))
private fun DestinationsNavigator.navToReader(args: ReaderScreenArgs) =
navigate(ReaderScreenDestination(args)) { launchSingleTop = true }
context(DialogState, Context, DestinationsNavigator)
suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
@@ -440,9 +441,7 @@ suspend fun DialogState.awaitSelectDate(): String? {
title = R.string.go_to,
yearRange = initial.year..yesterday.year,
selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
return utcTimeMillis in dateRange
}
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis in dateRange
},
)
val date = dateMillis?.run { toLocalDateTime().date.toString() }
@@ -1,25 +1,37 @@
package com.hippo.ehviewer.ui
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import com.hippo.ehviewer.ui.screen.implicit
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.NoopTransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.togetherWith
import com.hippo.ehviewer.util.findActivity
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.coroutines.CoroutineScope
@Composable
inline fun <R> composing(
inline fun <R> AnimatedVisibilityScope.composing(
navigator: DestinationsNavigator,
block: @Composable context(MainActivity, SnackbarHostState, DialogState, DestinationsNavigator, CoroutineScope)
block: @Composable context(MainActivity, SnackbarHostState, DialogState, SharedTransitionScope, TransitionsVisibilityScope, DestinationsNavigator, CoroutineScope)
() -> R,
) = block(
LocalContext.current.run { remember { findActivity<MainActivity>() } },
LocalSnackBarHostState.current,
LocalDialogState.current,
navigator,
rememberCoroutineScope(),
)
) = with(NoopTransitionsVisibilityScope) {
togetherWith(implicit<AnimatedVisibilityScope>()) {
block(
with(LocalContext.current) { remember { findActivity() } },
LocalSnackBarHostState.current,
LocalDialogState.current,
LocalSharedTransitionScope.current,
this,
navigator,
rememberCoroutineScope(),
)
}
}
@@ -17,6 +17,8 @@ package com.hippo.ehviewer.ui
import android.annotation.SuppressLint
import android.app.assist.AssistContent
import android.content.ContentResolver.SCHEME_CONTENT
import android.content.ContentResolver.SCHEME_FILE
import android.content.Intent
import android.content.pm.verify.domain.DomainVerificationManager
import android.content.pm.verify.domain.DomainVerificationUserState.DOMAIN_STATE_NONE
@@ -24,6 +26,8 @@ import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
@@ -135,6 +139,7 @@ import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.updater.AppUpdater
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.calculateFraction
import com.hippo.ehviewer.util.displayString
@@ -142,10 +147,11 @@ import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.getUrlFromClipboard
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastS
import com.hippo.unifile.asUniFile
import com.hippo.unifile.sha1
import com.hippo.ehviewer.util.sha1
import com.hippo.files.isDirectory
import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
import com.ramcosta.composedestinations.spec.Direction
import com.ramcosta.composedestinations.utils.currentDestinationAsState
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import eu.kanade.tachiyomi.util.lang.withIOContext
@@ -160,7 +166,7 @@ import moe.tarsin.coroutines.runSuspendCatching
import splitties.systemservices.clipboardManager
import splitties.systemservices.connectivityManager
private val navItems = arrayOf<Triple<DirectionDestinationSpec, Int, ImageVector>>(
private val navItems = arrayOf<Triple<Direction, 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),
@@ -226,7 +232,7 @@ class MainActivity : EhActivity() {
}
suspend fun DialogState.checkDownloadLocation() {
val valid = withIOContext { downloadLocation.ensureDir() }
val valid = withIOContext { downloadLocation.isDirectory }
if (!valid) {
awaitPermissionOrCancel(
confirmText = R.string.open_settings,
@@ -242,17 +248,19 @@ class MainActivity : EhActivity() {
}
}
LaunchedEffect(Unit) {
runCatching { dialogState.checkDownloadLocation() }
runCatching { dialogState.checkAppLinkVerify() }
runSuspendCatching {
withIOContext {
AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
if (!AppConfig.isBenchmark) {
LaunchedEffect(Unit) {
runCatching { dialogState.checkDownloadLocation() }
runCatching { dialogState.checkAppLinkVerify() }
runSuspendCatching {
withIOContext {
AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
}
}
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
}
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
}
}
@@ -261,10 +269,19 @@ class MainActivity : EhActivity() {
intentFlow.collect { intent ->
when (intent.action) {
Intent.ACTION_VIEW -> {
val url = intent.data?.toString()
if (url != null && !navigator.navWithUrl(url)) {
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
addTextToClipboard(new)
val uri = intent.data ?: return@collect
when (uri.scheme) {
SCHEME_CONTENT, SCHEME_FILE -> {
navigator.navToReader(uri)
}
else -> {
val url = uri.toString()
if (!navigator.navWithUrl(url)) {
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
addTextToClipboard(new)
}
}
}
}
Intent.ACTION_SEND -> {
@@ -277,7 +294,7 @@ class MainActivity : EhActivity() {
} else if (type != null && type.startsWith("image/")) {
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
if (null != uri) {
val hash = withIOContext { uri.asUniFile().sha1() }
val hash = withIOContext { uri.toOkioPath().sha1() }
navigator.navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
@@ -348,15 +365,15 @@ 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 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,
@@ -415,7 +432,7 @@ class MainActivity : EhActivity() {
}
},
drawerState = navDrawerState,
gesturesEnabled = !drawerLocked || navDrawerState.isOpen,
gesturesEnabled = drawerEnabled && sideSheetState.isClosed || navDrawerState.isOpen,
) {
val sheet = sideSheet.firstOrNull()
val radius by remember {
@@ -444,14 +461,18 @@ class MainActivity : EhActivity() {
}
},
drawerState = sideSheetState,
gesturesEnabled = sheet != null && !drawerLocked,
gesturesEnabled = sheet != null && drawerEnabled,
) {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
SharedTransitionLayout {
CompositionLocalProvider(LocalSharedTransitionScope provides this) {
DestinationsNavHost(
navGraph = NavGraphs.root,
start = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
}
}
}
}
}
@@ -530,16 +551,16 @@ class MainActivity : EhActivity() {
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!") }
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> { error("CompositionLocal LocalSharedTransitionScope 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 {
@@ -5,12 +5,9 @@ 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
@@ -21,19 +18,6 @@ import soup.compose.material.motion.animation.materialSharedAxisXIn
import soup.compose.material.motion.animation.materialSharedAxisXOut
import soup.compose.material.motion.animation.rememberSlideDistance
inline fun ComposeView.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
EhTheme {
val dialogState = remember { DialogState() }
dialogState.Intercept()
CompositionLocalProvider(
LocalDialogState provides dialogState,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
content()
}
}
}
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
EhTheme {
ProvideVectorPainterCache {
@@ -41,7 +25,6 @@ inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -
dialogState.Intercept()
CompositionLocalProvider(
LocalDialogState provides dialogState,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
content()
}
@@ -49,15 +32,17 @@ inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -
}
}
private typealias Ty = AnimatedContentTransitionScope<NavBackStackEntry>
@Composable
fun rememberEhNavAnim(): NavHostAnimatedDestinationStyle {
val slideDistance = rememberSlideDistance()
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) }
override val enterTransition: Ty.() -> EnterTransition = { materialSharedAxisXIn(true, slideDistance) }
override val exitTransition: Ty.() -> ExitTransition = { materialSharedAxisXOut(true, slideDistance) }
override val popEnterTransition: Ty.() -> EnterTransition = { materialSharedAxisXIn(false, slideDistance) }
override val popExitTransition: Ty.() -> ExitTransition = { materialSharedAxisXOut(false, slideDistance) }
}
}
}
@@ -4,25 +4,30 @@ import android.graphics.drawable.Animatable
import android.graphics.drawable.DrawableWrapper
import android.text.Html
import android.widget.TextView
import coil3.asDrawable
import coil3.imageLoader
import coil3.request.crossfade
import coil3.size.Size
import com.hippo.ehviewer.ktbuilder.imageRequest
class CoilImageGetter(
private val textView: TextView,
) : Html.ImageGetter {
override fun getDrawable(source: String) = object : DrawableWrapper(null) {}.apply {
textView.context.imageLoader.enqueue(
textView.context.imageRequest {
data(source)
crossfade(false)
target { drawable ->
setDrawable(drawable.asDrawable(textView.resources))
if (drawable is Animatable) drawable.start()
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
textView.text = textView.text
}
},
)
with(textView.context) {
imageLoader.enqueue(
imageRequest {
data(source)
crossfade(false)
size(Size.ORIGINAL)
target { drawable ->
setDrawable(drawable.asDrawable(resources))
if (drawable is Animatable) drawable.start()
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
textView.text = textView.text
}
},
)
}
}
}
@@ -40,5 +40,6 @@ suspend fun postLogin() = coroutineScope {
}.onFailure {
Settings.gallerySite = EhUrl.SITE_E
}
Settings.hasSignedIn.value = true
Settings.needSignIn = false
}
@@ -60,7 +60,6 @@ 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
@@ -80,7 +79,6 @@ import kotlinx.coroutines.Job
@Destination<RootGraph>(start = true)
@Composable
fun SignInScreen(navigator: DestinationsNavigator) {
LockDrawer(true)
val windowSizeClass = LocalWindowSizeClass.current
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
@@ -13,7 +13,6 @@ 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
@@ -23,12 +22,10 @@ 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<RootGraph>
@Composable
fun WebViewSignInScreen(navigator: DestinationsNavigator) {
LockDrawer(true)
val coroutineScope = rememberCoroutineScope()
val state = rememberWebViewState(url = EhUrl.URL_SIGN_IN)
val client = remember {
@@ -39,16 +36,7 @@ fun WebViewSignInScreen(navigator: DestinationsNavigator) {
view.destroy()
return
}
var getId = false
var getHash = false
EhCookieStore.load(Url(EhUrl.HOST_E)).forEach {
if (EhCookieStore.KEY_IPB_MEMBER_ID == it.name) {
getId = true
} else if (EhCookieStore.KEY_IPB_PASS_HASH == it.name) {
getHash = true
}
}
if (getId && getHash) {
if (EhCookieStore.hasSignedIn()) {
present = true
coroutineScope.launchIO {
withNonCancellableContext { postLogin() }
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
@@ -43,6 +44,7 @@ import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.util.FileUtils
private val ids = Tuple9(1, 2, 3, 4, 5, 6, 7, 8, 9)
@@ -90,6 +92,7 @@ private val constraintSet = ConstraintSet {
}
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun DownloadCard(
onClick: () -> Unit,
@@ -1,5 +1,7 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.material3.ShapeDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
@@ -7,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.Image
@@ -14,6 +17,7 @@ import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.ui.tools.shouldCrop
@Composable
@@ -23,6 +27,7 @@ fun requestOf(model: GalleryInfo): ImageRequest {
return remember(model) { context.imageRequest(model) }
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
@NonRestartableComposable
fun EhAsyncThumb(
@@ -33,13 +38,17 @@ fun EhAsyncThumb(
) = AsyncImage(
model = requestOf(model),
contentDescription = null,
modifier = modifier,
modifier = modifier
// https://github.com/FooIbar/EhViewer/issues/1527
// .sharedBounds(key = "${model.gid}")
.clip(ShapeDefaults.Medium),
onSuccess = onSuccess?.let { callback ->
{ callback(it.result.image) }
},
contentScale = contentScale,
)
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun EhAsyncCropThumb(
key: GalleryInfo,
@@ -49,7 +58,10 @@ fun EhAsyncCropThumb(
AsyncImage(
model = requestOf(key),
contentDescription = null,
modifier = modifier,
modifier = modifier
// https://github.com/FooIbar/EhViewer/issues/1527
// .sharedBounds(key = "${key.gid}")
.clip(ShapeDefaults.Medium),
onSuccess = {
if (it.result.image.shouldCrop) {
contentScale = ContentScale.Crop
@@ -24,22 +24,24 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
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.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import arrow.resilience.Schedule
import arrow.resilience.retry
import com.hippo.ehviewer.ui.tools.PredictiveBackEasing
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
import com.hippo.ehviewer.ui.tools.snackBarPadding
import kotlin.time.Duration.Companion.microseconds
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
@@ -65,35 +67,32 @@ class FabLayoutState(
val expandProgress = Animatable(if (initialValue == FabLayoutValue.Expand) 1f else 0f)
.apply { updateBounds(0f, 1f) }
private suspend fun downTo(value: FabLayoutValue, priority: MutatePriority) {
mutatorMutex.mutate(priority) {
if (expandProgress.value != 0f) expandProgress.animateTo(0f, animationSpec)
if (value == FabLayoutValue.Hidden) {
appearProgress.animateTo(0f, animationSpec)
}
}
}
private suspend fun upTo(value: FabLayoutValue, priority: MutatePriority) {
mutatorMutex.mutate(priority) {
if (appearProgress.value != 1f) appearProgress.animateTo(1f, animationSpec)
if (value == FabLayoutValue.Expand) {
expandProgress.animateTo(1f, animationSpec)
}
}
}
suspend fun waitCollapse() {
private suspend fun downTo(value: FabLayoutValue, priority: MutatePriority) = mutatorMutex.mutate(priority) {
if (expandProgress.value != 0f) {
snapshotFlow { expandProgress.value }.first { it == 0f }
expandProgress.animateTo(0f, animationSpec)
}
if (value == FabLayoutValue.Hidden) {
appearProgress.animateTo(0f, animationSpec)
}
}
private suspend fun upTo(value: FabLayoutValue, priority: MutatePriority) = mutatorMutex.mutate(priority) {
if (appearProgress.value != 1f) {
appearProgress.animateTo(1f, animationSpec)
}
if (value == FabLayoutValue.Expand) {
expandProgress.animateTo(1f, animationSpec)
}
}
suspend fun show(priority: MutatePriority = MutatePriority.Default) = upTo(FabLayoutValue.Primary, priority)
suspend fun hide(priority: MutatePriority = MutatePriority.Default) = downTo(FabLayoutValue.Hidden, priority)
suspend fun collapse(priority: MutatePriority = MutatePriority.Default) = downTo(FabLayoutValue.Primary, priority)
suspend fun expand(priority: MutatePriority = MutatePriority.Default) = upTo(FabLayoutValue.Expand, priority)
}
private val fabSyncSchedule = Schedule.linear<Throwable>(100.microseconds)
fun interface FabBuilder {
fun onClick(icon: ImageVector, autoClose: Boolean, that: suspend () -> Unit)
fun onClick(icon: ImageVector, that: suspend () -> Unit) = onClick(icon, true, that)
@@ -117,12 +116,14 @@ fun FabLayout(
}
val state = rememberFabLayoutState(newState)
LaunchedEffect(newState) {
when (newState) {
FabLayoutValue.Expand -> state.expand()
FabLayoutValue.Hidden -> state.hide()
FabLayoutValue.Primary -> {
state.collapse()
state.show()
fabSyncSchedule.retry {
when (newState) {
FabLayoutValue.Expand -> state.expand()
FabLayoutValue.Hidden -> state.hide()
FabLayoutValue.Primary -> {
state.collapse()
state.show()
}
}
}
}
@@ -130,9 +131,13 @@ fun FabLayout(
val secondaryFab by delegateSnapshotUpdate {
record { buildFab(builder) }
transform { onEachLatest { state.waitCollapse() } }
transform { onEachLatest { state.collapse(MutatePriority.PreventUserInput) } }
}
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) {
@@ -174,7 +179,7 @@ fun FabLayout(
}
},
modifier = Modifier.padding(20.dp).offset {
val distance = lerp(0, 150 * (size - index) + 50, animatedProgress)
val distance = lerp(0, interval * (size - index) + padding, animatedProgress)
IntOffset(0, -distance)
},
) {
@@ -210,4 +215,6 @@ private fun buildFab(builder: FabBuilder.() -> Unit) = buildList {
}
}
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
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -9,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
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.automirrored.filled.Label
@@ -36,6 +36,7 @@ import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.SadAndroid
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
@Composable
fun GalleryDetailHeaderInfoCard(
@@ -96,6 +97,7 @@ fun GalleryDetailHeaderInfoCard(
}
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun GalleryDetailHeaderCard(
info: GalleryInfo,
@@ -109,7 +111,10 @@ fun GalleryDetailHeaderCard(
Card {
EhAsyncCropThumb(
key = remember(info.gid) { info },
modifier = Modifier.height(dimensionResource(id = R.dimen.gallery_detail_thumb_height)).width(dimensionResource(id = R.dimen.gallery_detail_thumb_width)),
modifier = Modifier.size(
dimensionResource(id = R.dimen.gallery_detail_thumb_width),
dimensionResource(id = R.dimen.gallery_detail_thumb_height),
),
)
}
Spacer(modifier = Modifier.weight(0.5F))
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -51,6 +52,7 @@ import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.ElevatedCard
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
import com.hippo.ehviewer.util.FavouriteStatusRouter
private val ids = Tuple7(1, 2, 3, 4, 5, 6, 7)
@@ -88,6 +90,7 @@ private val constraintSet = ConstraintSet {
}
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun GalleryInfoListItem(
onClick: () -> Unit,
@@ -199,6 +202,7 @@ fun GalleryInfoListItem(
}
}
context(SharedTransitionScope, TransitionsVisibilityScope)
@Composable
fun GalleryInfoGridItem(
onClick: () -> Unit,
@@ -53,6 +53,7 @@ import androidx.paging.compose.itemKey
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.exception.NoHitsFoundException
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.SadAndroid
@@ -116,6 +117,11 @@ fun GalleryList(
}
},
) {
val showLoadStateIndicator = when (val state = data.loadState.append) {
LoadState.Loading -> true
is LoadState.Error -> state.error !is NoHitsFoundException
is LoadState.NotLoading -> false
}
if (listMode == 0) {
val columnWidth by collectDetailSizeAsState()
FastScrollLazyVerticalGrid(
@@ -136,7 +142,7 @@ fun GalleryList(
detailItemContent(info)
}
}
if (data.loadState.append !is LoadState.NotLoading) {
if (showLoadStateIndicator) {
item(span = { GridItemSpan(maxLineSpan) }) {
LoadStateIndicator(state = data.loadState.append) {
data.retry()
@@ -151,9 +157,9 @@ fun GalleryList(
columns = StaggeredGridCells.Fixed(thumbColumns),
modifier = contentModifier,
state = thumbListState,
contentPadding = contentPadding + PaddingValues(marginH, marginV),
verticalItemSpacing = gridInterval,
horizontalArrangement = Arrangement.spacedBy(gridInterval),
contentPadding = contentPadding + PaddingValues(marginH, marginV),
) {
items(
count = data.itemCount,
@@ -165,7 +171,7 @@ fun GalleryList(
thumbItemContent(info)
}
}
if (data.loadState.append !is LoadState.NotLoading) {
if (showLoadStateIndicator) {
item(span = StaggeredGridItemSpan.FullLine) {
LoadStateIndicator(state = data.loadState.append) {
data.retry()
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
@@ -24,17 +25,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import arrow.core.raise.ensure
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANG_TAGS
@@ -62,9 +63,11 @@ private val categoryTable = arrayOf(
fun SearchFilter(
modifier: Modifier = Modifier,
category: Int,
onCategoryChanged: (Int) -> Unit,
onCategoryChange: (Int) -> Unit,
language: Int,
onLanguageChange: (Int) -> Unit,
advancedOption: AdvancedSearchOption,
onAdvancedOptionChanged: (AdvancedSearchOption) -> Unit,
onAdvancedOptionChange: (AdvancedSearchOption) -> Unit,
) = Column(modifier) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -82,7 +85,7 @@ fun SearchFilter(
items(categories, { it.first }) {
FilterChip(
selected = isCategoryChecked(it.first),
onClick = { onCategoryChanged(category xor it.first) },
onClick = { onCategoryChange(category xor it.first) },
label = { Text(text = stringResource(id = it.second)) },
modifier = Modifier.thenIf(animateItems) { animateItem() },
)
@@ -109,14 +112,11 @@ fun SearchFilter(
}
}
}
var languageFilter by Settings.languageFilter.asMutableState()
DropdownFilterChip(
label = languageStr,
menuItems = languages,
selectedItemIndex = languageFilter + 1,
onSelectedItemIndexChange = {
languageFilter = it - 1
},
selectedItemIndex = language + 1,
onSelectedItemIndexChange = { onLanguageChange(it - 1) },
)
val minRatingItems = stringArrayResource(id = R.array.search_min_rating)
val minRatingStr = stringResource(id = R.string.search_sr)
@@ -125,7 +125,7 @@ fun SearchFilter(
menuItems = minRatingItems.asList(),
selectedItemIndex = (advancedOption.minRating - 1).coerceAtLeast(0),
onSelectedItemIndexChange = {
onAdvancedOptionChanged(advancedOption.copy(minRating = if (it == 0) 0 else it + 1))
onAdvancedOptionChange(advancedOption.copy(minRating = if (it == 0) 0 else it + 1))
},
)
val pageErr1 = stringResource(R.string.search_sp_err1)
@@ -153,23 +153,27 @@ fun SearchFilter(
val (from, to) = dialogState.awaitResult(
initial = advancedOption.fromPage to advancedOption.toPage,
title = R.string.key_pages,
invalidator = { (from, to) ->
if (to != 0 && to < 10) {
pageErr1
} else if (from != 0 && to != 0 && to - from < 20) {
pageErr2
} else {
null
invalidator = { (min, max) ->
if (max != 0) {
if (min != 0) {
ensure(min <= (max / 2).coerceAtMost(max - 20)) { pageErr2 }
} else {
ensure(max >= 10) { pageErr1 }
}
}
},
) { error ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
OutlinedTextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
value = expectedValue.first.takeIf { it > 0 }?.toString().orEmpty(),
onValueChange = {
expectedValue = expectedValue.copy(first = it.toIntOrDefault(0).coerceAtLeast(0))
expectedValue = expectedValue.copy(first = it.toIntOrDefault(0).coerceIn(0, 1000))
},
modifier = Modifier.width(112.dp).padding(16.dp),
singleLine = true,
@@ -180,7 +184,7 @@ fun SearchFilter(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
value = expectedValue.second.takeIf { it > 0 }?.toString().orEmpty(),
onValueChange = {
expectedValue = expectedValue.copy(second = it.toIntOrDefault(0).coerceAtLeast(0))
expectedValue = expectedValue.copy(second = it.toIntOrDefault(0).coerceIn(0, 2000))
},
modifier = Modifier.width(112.dp).padding(16.dp),
singleLine = true,
@@ -190,11 +194,14 @@ fun SearchFilter(
}
if (error != null) {
ListItem(
headlineContent = { Text(text = error) },
headlineContent = {
Text(text = error, style = MaterialTheme.typography.bodySmall)
},
leadingContent = {
Icon(imageVector = Icons.Default.Info, contentDescription = null)
},
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = MaterialTheme.colorScheme.error,
leadingIconColor = MaterialTheme.colorScheme.error,
),
@@ -202,13 +209,13 @@ fun SearchFilter(
}
}
}
onAdvancedOptionChanged(advancedOption.copy(fromPage = from, toPage = to))
onAdvancedOptionChange(advancedOption.copy(fromPage = from, toPage = to))
}
},
label = { Text(text = pagesText) },
)
fun checked(bit: Int) = advancedOption.advanceSearch and bit != 0
fun AdvancedSearchOption.inv(bit: Int) = onAdvancedOptionChanged(copy(advanceSearch = advanceSearch xor bit))
fun AdvancedSearchOption.inv(bit: Int) = onAdvancedOptionChange(copy(advanceSearch = advanceSearch xor bit))
FilterChip(
selected = checked(AdvanceTable.SH),
onClick = { advancedOption.inv(AdvanceTable.SH) },
@@ -0,0 +1,23 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.ui.geometry.Offset
import com.hippo.ehviewer.Settings
import me.saket.telephoto.zoomable.DoubleClickToZoomListener
import me.saket.telephoto.zoomable.ZoomableState
object DoubleTapZoom : DoubleClickToZoomListener {
override suspend fun onDoubleClick(state: ZoomableState, centroid: Offset) {
if (Settings.doubleTapToZoom.value) {
val zoomFraction = state.zoomFraction ?: return // Content isn't ready yet
if (zoomFraction > 0.05f) {
state.resetZoom()
} else {
// Workaround for https://github.com/saket/telephoto/issues/45
state.zoomTo(
zoomFactor = state.contentTransformation.scaleMetadata.initialScale.scaleX * 2f,
centroid = centroid,
)
}
}
}
}
@@ -1,35 +1,27 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.gallery.PageLoader2
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.CONTINUOUS_VERTICAL
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.DEFAULT
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.LEFT_TO_RIGHT
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.RIGHT_TO_LEFT
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.VERTICAL
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.WEBTOON
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState
import me.saket.telephoto.zoomable.zoomable
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
@Composable
fun GalleryPager(
@@ -37,119 +29,57 @@ fun GalleryPager(
pagerState: PagerState,
lazyListState: LazyListState,
pageLoader: PageLoader2,
showNavigationOverlay: Boolean,
onNavigationModeChange: () -> Unit,
onSelectPage: (ReaderPage) -> Unit,
onMenuRegionClick: () -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
val items = pageLoader.pages
when (type) {
DEFAULT, LEFT_TO_RIGHT, RIGHT_TO_LEFT -> {
HorizontalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
reverseLayout = type == RIGHT_TO_LEFT,
key = { it },
) { index ->
val page = items[index]
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
val status by page.status.collectAsState()
val boxModifier = if (status == Page.State.READY) {
Modifier.zoomable(
state = zoomableState,
onClick = { onMenuRegionClick() },
onLongClick = { onSelectPage(page) },
)
} else {
Modifier.combinedClickable(
interactionSource = null,
indication = null,
onClick = { onMenuRegionClick() },
).fillMaxSize()
}
Box(
modifier = boxModifier,
contentAlignment = Alignment.Center,
) {
PagerItem(
page = page,
pageLoader = pageLoader,
zoomableState = zoomableState,
webtoon = false,
)
}
}
}
VERTICAL -> {
VerticalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
key = { it },
) { index ->
val page = items[index]
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
val status by page.status.collectAsState()
val boxModifier = if (status == Page.State.READY) {
Modifier.zoomable(
state = zoomableState,
onClick = { onMenuRegionClick() },
onLongClick = { onSelectPage(page) },
)
} else {
Modifier.combinedClickable(
interactionSource = null,
indication = null,
onClick = { onMenuRegionClick() },
).fillMaxSize()
}
Box(
modifier = boxModifier,
contentAlignment = Alignment.Center,
) {
PagerItem(
page = page,
pageLoader = pageLoader,
zoomableState = zoomableState,
webtoon = false,
)
}
}
}
WEBTOON, CONTINUOUS_VERTICAL -> {
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
LazyColumn(
modifier = modifier.zoomable(
state = zoomableState,
onClick = { onMenuRegionClick() },
onLongClick = { ofs ->
val info = lazyListState.layoutInfo.visibleItemsInfo.find { info ->
info.offset <= ofs.y && info.offset + info.size > ofs.y
}
if (info == null && type == CONTINUOUS_VERTICAL) {
// Maybe user long-click on gaps? ¯\_(ツ)_/¯
return@zoomable
}
info ?: error("Internal error finding long click item!!! offset:$ofs")
onSelectPage(items[info.index])
},
),
state = lazyListState,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(if (type != WEBTOON) 15.dp else 0.dp),
) {
items(items, key = { it.index }) { page ->
PagerItem(
page = page,
pageLoader = pageLoader,
zoomableState = zoomableState,
webtoon = true,
)
}
}
}
val isPagerType = !ReadingModeType.isWebtoon(type)
val pagerNavigation by Settings.readerPagerNav.collectAsState()
val pagerInvertMode by Settings.readerPagerNavInverted.collectAsState()
val webtoonNavigation by Settings.readerWebtoonNav.collectAsState()
val webtoonInvertMode by Settings.readerWebtoonNavInverted.collectAsState()
val navigationType = if (isPagerType) pagerNavigation else webtoonNavigation
val navigation = remember(navigationType, type) {
ViewerNavigation.fromPreference(navigationType, ReadingModeType.isVertical(type))
}
val invertMode = if (isPagerType) pagerInvertMode else webtoonInvertMode
var firstLaunch by remember { mutableStateOf(true) }
val regions = remember(navigation, invertMode) {
if (firstLaunch) {
firstLaunch = false
} else {
onNavigationModeChange()
}
navigation.invertMode = TappingInvertMode.entries[invertMode]
navigation.regions
}
val navigator by rememberUpdatedState(regions)
if (isPagerType) {
PagerViewer(
pagerState = pagerState,
isRtl = type == RIGHT_TO_LEFT,
isVertical = type == VERTICAL,
pageLoader = pageLoader,
navigator = { navigator },
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
contentPadding = contentPadding,
modifier = modifier,
)
} else {
WebtoonViewer(
lazyListState = lazyListState,
withGaps = type == CONTINUOUS_VERTICAL,
pageLoader = pageLoader,
navigator = { navigator },
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
contentPadding = contentPadding,
modifier = modifier,
)
}
NavigationOverlay(showNavigationOverlay, regions, modifier = Modifier.fillMaxSize())
}
private val zoomSpec = ZoomSpec(maxZoomFactor = 3f)
@@ -0,0 +1,65 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
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.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
@Composable
fun NavigationOverlay(
visible: Boolean,
regions: NavigationRegions,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = visible && regions.isNotEmpty(),
enter = fadeIn(AnimationSpec),
exit = fadeOut(AnimationSpec),
) {
BoxWithConstraints(modifier) {
val fontSize = MaterialTheme.typography.headlineSmall.fontSize
val style = TextStyle(
color = Color.White,
fontSize = fontSize,
)
val strokeStyle = TextStyle(
color = Color.Black,
fontSize = fontSize,
drawStyle = Stroke(8f),
)
val scaleX = maxWidth
val scaleY = maxHeight
regions.forEach { region ->
val rect = region.rectF
val color = colorResource(region.type.colorRes)
val text = stringResource(region.type.nameRes)
Box(
modifier = Modifier.size(scaleX * rect.width(), scaleY * rect.height())
.offset(scaleX * rect.left, scaleY * rect.top).background(color),
contentAlignment = Alignment.Center,
) {
Text(text, style = strokeStyle)
Text(text, style = style)
}
}
}
}
}
private val AnimationSpec = tween<Float>(1000)
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff. Show more