mirror of
https://github.com/mihonapp/mihon.git
synced 2026-11-01 12:35:36 -08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feadad5e1e | ||
|
|
fbcbe40714 | ||
|
|
ab1f749289 | ||
|
|
f59dabd4d7 | ||
|
|
e8f2c3f988 | ||
|
|
ca8ffacd10 | ||
|
|
036a69c089 | ||
|
|
335c50c92e | ||
|
|
9e3f4c0fec | ||
|
|
0cfc078b00 | ||
|
|
52c348f32b | ||
|
|
42474cd46e | ||
|
|
8780f1ca8a | ||
|
|
b95886a00d | ||
|
|
e5b44d4d34 | ||
|
|
7e930f1afb | ||
|
|
9158e4c9e6 | ||
|
|
5f7dc2b81f | ||
|
|
2a5581df98 | ||
|
|
ba70dd31e9 | ||
|
|
f23a2c374d | ||
|
|
1ed4c5a171 | ||
|
|
7ea60402e5 | ||
|
|
a202c33707 | ||
|
|
a805922392 | ||
|
|
4df6a4705b | ||
|
|
f43a0f5d9d | ||
|
|
bffef9cb05 | ||
|
|
3f7f2a67c4 | ||
|
|
eb0f35a7a5 | ||
|
|
f09e43efb2 | ||
|
|
92a3ae5c7f | ||
|
|
2cf9536cb1 | ||
|
|
9612d955db | ||
|
|
5ca2a39328 | ||
|
|
fb2b224030 | ||
|
|
ee454f3a82 | ||
|
|
4fd0081817 | ||
|
|
68dbd7a185 | ||
|
|
7475f4554c | ||
|
|
8e688ed51e | ||
|
|
cca6b5e2e7 | ||
|
|
4c316ccc3a | ||
|
|
7c7392a533 | ||
|
|
d4fe03cd18 | ||
|
|
e9670d8a12 | ||
|
|
87e808548b | ||
|
|
79fc3a0a8a | ||
|
|
c8b820a08f | ||
|
|
f45d0ad31d |
@@ -30,7 +30,7 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I have written a short but informative title.
|
- label: I have written a short but informative title.
|
||||||
required: true
|
required: true
|
||||||
- label: I have updated the app to version **[0.21.0](https://github.com/mihonapp/mihon/releases/latest)**.
|
- label: I have updated the app to version **[0.21.3](https://github.com/mihonapp/mihon/releases/latest)**.
|
||||||
required: true
|
required: true
|
||||||
- label: I will fill out all of the requested information in this form.
|
- label: I will fill out all of the requested information in this form.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ body:
|
|||||||
label: Mihon version
|
label: Mihon version
|
||||||
description: You can find your Mihon version in **More → About**.
|
description: You can find your Mihon version in **More → About**.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Example: "0.21.0"
|
Example: "0.21.3"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
|
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
|
||||||
required: true
|
required: true
|
||||||
- label: I have updated the app to version **[0.21.0](https://github.com/mihonapp/mihon/releases/latest)**.
|
- label: I have updated the app to version **[0.21.3](https://github.com/mihonapp/mihon/releases/latest)**.
|
||||||
required: true
|
required: true
|
||||||
- label: I have filled out all of the requested information in this form, including specific version numbers.
|
- label: I have filled out all of the requested information in this form, including specific version numbers.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||||
|
|
||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
|
||||||
with:
|
with:
|
||||||
java-version-file: .github/.java-version
|
java-version-file: .github/.java-version
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
|
||||||
with:
|
with:
|
||||||
java-version-file: .github/.java-version
|
java-version-file: .github/.java-version
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
@@ -90,7 +90,7 @@ jobs:
|
|||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
|
||||||
with:
|
with:
|
||||||
java-version-file: .github/.java-version
|
java-version-file: .github/.java-version
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
|
|||||||
+47
-30
@@ -11,42 +11,56 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
|||||||
- `Other` - for technical stuff.
|
- `Other` - for technical stuff.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [v0.21.3] - 2026-10-26
|
||||||
### Added
|
### Added
|
||||||
- Add experimental high quality reader renderer (Settings → Advanced → Use high quality renderer) with better image scaling, page transition animations, dual page view and display cutout handling ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3388](https://github.com/mihonapp/mihon/pull/3388))
|
- Add a Time Format setting to customize how times are displayed ([@MajorTanya](https://github.com/MajorTanya)) ([#4118](https://github.com/mihonapp/mihon/pull/4118))
|
||||||
- Match background and transition page colors to the old reader ([@AntsyLich](https://github.com/AntsyLich)) ([#3765](https://github.com/mihonapp/mihon/pull/3765))
|
|
||||||
- Support the Fit width, Fit height and Original size scale types and the zoom start position ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3801](https://github.com/mihonapp/mihon/pull/3801))
|
|
||||||
- Fix preloading and navigation across chapters, and right-to-left navigation ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3823](https://github.com/mihonapp/mihon/pull/3823))
|
|
||||||
- Add a "None" page transition and fix the background color of animated images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3843](https://github.com/mihonapp/mihon/pull/3843))
|
|
||||||
- Improve transition and error pages, dual page animations and border cropping ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3852](https://github.com/mihonapp/mihon/pull/3852))
|
|
||||||
- Fix navigation directions and long strip bounds, and fade in loaded pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3870](https://github.com/mihonapp/mihon/pull/3870))
|
|
||||||
- Add a "Page flip" transition, a long strip minimum width setting and separate dual page settings, and show wide pages on their own in dual page view ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3886](https://github.com/mihonapp/mihon/pull/3886))
|
|
||||||
- Fix freezes and running out of memory when jumping between pages with the slider ([@Danidukiyu](https://github.com/Danidukiyu)) ([#3872](https://github.com/mihonapp/mihon/pull/3872))
|
|
||||||
- Add HDR, ICC color profile and EXIF orientation support ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933))
|
|
||||||
- Add a page gap setting for long strip with gaps and a "Disable zoom out" setting for long strip modes ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933))
|
|
||||||
- Fix a crash on Android 15 and long strip progress and background color ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3949](https://github.com/mihonapp/mihon/pull/3949))
|
|
||||||
- Keep the reader menu open while moving through pages with the slider ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3956](https://github.com/mihonapp/mihon/pull/3956))
|
|
||||||
- Show pages progressively while they download ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4023](https://github.com/mihonapp/mihon/pull/4023))
|
|
||||||
- Lower memory use for animated images and support cropping borders in long strip ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4029](https://github.com/mihonapp/mihon/pull/4029))
|
|
||||||
- Pair more wide pages in dual page view by comparing them with neighbouring pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4051](https://github.com/mihonapp/mihon/pull/4051))
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Use a new decoder for images Android can't decode itself, like AVIF, JPEG XL and HEIF, and support JPEG 2000 images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
|
- Update the sources with the most entries first during library updates ([@AntsyLich](https://github.com/AntsyLich)) ([`b8070cd`](https://github.com/mihonapp/mihon/commit/b8070cd))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Create and restore backups faster and with less memory ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken), [@AntsyLich](https://github.com/AntsyLich)) ([`771ba33`](https://github.com/mihonapp/mihon/commit/771ba33))
|
||||||
|
- Read backups faster before restoring, and decode each entry only once while restoring ([@AntsyLich](https://github.com/AntsyLich)) ([`3ed83a8`](https://github.com/mihonapp/mihon/commit/3ed83a8))
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- Remove "Custom hardware bitmap threshold" and "Custom display profile" advanced settings ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
|
- Remove the "Split tall images" download setting ([@AntsyLich](https://github.com/AntsyLich)) ([`8b9faa3`](https://github.com/mihonapp/mihon/commit/8b9faa3))
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fixed refreshing chapters failing with the app language set to Arabic or another language with its own digits ([@AntsyLich](https://github.com/AntsyLich))
|
- Fixed WebView crashing for sources that set an Origin header ([@AntsyLich](https://github.com/AntsyLich)) ([`5f63c98`](https://github.com/mihonapp/mihon/commit/5f63c98))
|
||||||
- Fixed a crash on launch on some Samsung devices running Android 9 to 11 ([@AntsyLich](https://github.com/AntsyLich))
|
- Fixed WebView sending source headers to other sites ([@AntsyLich](https://github.com/AntsyLich)) ([`a01867d`](https://github.com/mihonapp/mihon/commit/a01867d))
|
||||||
- Fixed the start and resume button going under the taskbar on tablets ([@AntsyLich](https://github.com/AntsyLich))
|
- Fixed downloads not showing for entries of a source sharing its name with another source ([@AntsyLich](https://github.com/AntsyLich)) ([`deeb9b2`](https://github.com/mihonapp/mihon/commit/deeb9b2))
|
||||||
- Fixed a crash when updating an extension while others were installing ([@AntsyLich](https://github.com/AntsyLich))
|
- Fixed covers reloading when removing an entry from the library ([@AntsyLich](https://github.com/AntsyLich)) ([`a424282`](https://github.com/mihonapp/mihon/commit/a424282))
|
||||||
- Fixed library entries without a date added getting their last update time as date added when updating from v0.20.x ([@AntsyLich](https://github.com/AntsyLich))
|
- Fixed the More tab icon pausing and then bouncing back when leaving the tab ([@AntsyLich](https://github.com/AntsyLich)) ([`2ca6961`](https://github.com/mihonapp/mihon/commit/2ca6961))
|
||||||
- Fixed the app closing when WebView's page process crashed ([@AntsyLich](https://github.com/AntsyLich))
|
- Fixed Shizuku extension installer being unavailable with Sui ([@AntsyLich](https://github.com/AntsyLich)) ([`bd2ada2`](https://github.com/mihonapp/mihon/commit/bd2ada2))
|
||||||
- Fixed installing or updating an extension sometimes cancelling the install of another one ([@AntsyLich](https://github.com/AntsyLich))
|
|
||||||
- Fixed updating from v0.20.x taking up to hours on libraries with many duplicate entries ([@AntsyLich](https://github.com/AntsyLich))
|
## [v0.21.2] - 2026-10-13
|
||||||
- Fixed the app staying on the updating screen for good when updating its data failed, it now shows what went wrong, and updating failing on leftover data of deleted entries ([@AntsyLich](https://github.com/AntsyLich))
|
### Fixed
|
||||||
- Fixed library updates ignoring the smart update restrictions for entries whose details were never fetched ([@AntsyLich](https://github.com/AntsyLich))
|
- Fix long floating point numbers showing up in Comick progress ([@MajorTanya](https://github.com/MajorTanya)) ([#4090](https://github.com/mihonapp/mihon/pull/4090))
|
||||||
- Fixed resuming a chapter sometimes opening the next chapter partway through, and saving progress to it, in the right to left reader ([@AntsyLich](https://github.com/AntsyLich))
|
- Fixed updating from v0.20.x failing when the database had duplicate history or tracking entries, or damaged indexes ([@AntsyLich](https://github.com/AntsyLich)) ([`ea2b73a`](https://github.com/mihonapp/mihon/commit/ea2b73a))
|
||||||
|
- Fixed a crash when a back gesture was still in progress as the reader or a dialog closed ([@AntsyLich](https://github.com/AntsyLich)) ([`43de88c`](https://github.com/mihonapp/mihon/commit/43de88c))
|
||||||
|
- Fixed Kitsu login and other requests that send data failing with "Failed to bypass Cloudflare" ([@AntsyLich](https://github.com/AntsyLich)) ([`7ae858e`](https://github.com/mihonapp/mihon/commit/7ae858e))
|
||||||
|
- Fixed tracker start date being off by a day when adding tracking to an entry with read history in some timezones ([@hazptr](https://github.com/hazptr)) ([#4049](https://github.com/mihonapp/mihon/pull/4049))
|
||||||
|
- Fixed the paged reader crashing when moving to another chapter after a wide page of a neighbouring chapter was split ([@AntsyLich](https://github.com/AntsyLich)) ([`3156b4a`](https://github.com/mihonapp/mihon/commit/3156b4a))
|
||||||
|
- Fixed a crash when opening the filters of a source with an invalid filter ([@AntsyLich](https://github.com/AntsyLich)) ([`c9e1b6e`](https://github.com/mihonapp/mihon/commit/c9e1b6e))
|
||||||
|
- Fixed a crash when installing an extension on a device where the system installer can't ask for confirmation ([@AntsyLich](https://github.com/AntsyLich)) ([`2df48e1`](https://github.com/mihonapp/mihon/commit/2df48e1))
|
||||||
|
- Fixed a crash when tapping in the long strip reader as it closed ([@AntsyLich](https://github.com/AntsyLich)) ([`c371d63`](https://github.com/mihonapp/mihon/commit/c371d63))
|
||||||
|
- Fixed chapters downloaded before v0.21.0 showing as not downloaded when their name started with a space or dot, was long, or had a non-ASCII scanlator ([@AntsyLich](https://github.com/AntsyLich)) ([`f0f1e4d`](https://github.com/mihonapp/mihon/commit/f0f1e4d))
|
||||||
|
- Fixed slowdowns and running out of memory when many requests to a site hit a Cloudflare challenge at once ([@tigeryu8900](https://github.com/tigeryu8900), [@AntsyLich](https://github.com/AntsyLich)) ([`5a5ff22`](https://github.com/mihonapp/mihon/commit/5a5ff22))
|
||||||
|
|
||||||
|
## [v0.21.1] - 2026-10-12
|
||||||
|
### Fixed
|
||||||
|
- Fixed refreshing chapters failing with the app language set to Arabic or another language with its own digits ([@AntsyLich](https://github.com/AntsyLich)) ([`8457fb9`](https://github.com/mihonapp/mihon/commit/8457fb9))
|
||||||
|
- Fixed a crash on launch on some Samsung devices running Android 9 to 11 ([@AntsyLich](https://github.com/AntsyLich)) ([`abb126a`](https://github.com/mihonapp/mihon/commit/abb126a))
|
||||||
|
- Fixed the start and resume button going under the taskbar on tablets ([@AntsyLich](https://github.com/AntsyLich)) ([`c279291`](https://github.com/mihonapp/mihon/commit/c279291))
|
||||||
|
- Fixed a crash when updating an extension while others were installing ([@AntsyLich](https://github.com/AntsyLich)) ([`07b9fb6`](https://github.com/mihonapp/mihon/commit/07b9fb6))
|
||||||
|
- Fixed library entries without a date added getting their last update time as date added when updating from v0.20.x ([@AntsyLich](https://github.com/AntsyLich)) ([`6dffca1`](https://github.com/mihonapp/mihon/commit/6dffca1))
|
||||||
|
- Fixed the app closing when WebView's page process crashed ([@AntsyLich](https://github.com/AntsyLich)) ([`649fc83`](https://github.com/mihonapp/mihon/commit/649fc83))
|
||||||
|
- Fixed installing or updating an extension sometimes cancelling the install of another one ([@AntsyLich](https://github.com/AntsyLich)) ([`ae749bb`](https://github.com/mihonapp/mihon/commit/ae749bb))
|
||||||
|
- Fixed updating from v0.20.x taking up to hours on libraries with many duplicate entries ([@AntsyLich](https://github.com/AntsyLich)) ([`a999e49`](https://github.com/mihonapp/mihon/commit/a999e49))
|
||||||
|
- Fixed the app staying on the updating screen for good when updating its data failed, it now shows what went wrong, and updating failing on leftover data of deleted entries ([@AntsyLich](https://github.com/AntsyLich)) ([`9dbb714`](https://github.com/mihonapp/mihon/commit/9dbb714))
|
||||||
|
- Fixed library updates ignoring the smart update restrictions for entries whose details were never fetched ([@AntsyLich](https://github.com/AntsyLich)) ([`010ac71`](https://github.com/mihonapp/mihon/commit/010ac71))
|
||||||
|
- Fixed resuming a chapter sometimes opening the next chapter partway through, and saving progress to it, in the right to left reader ([@AntsyLich](https://github.com/AntsyLich)) ([`51b61a3`](https://github.com/mihonapp/mihon/commit/51b61a3))
|
||||||
|
|
||||||
## [v0.21.0] - 2026-10-12
|
## [v0.21.0] - 2026-10-12
|
||||||
### Added
|
### Added
|
||||||
@@ -794,7 +808,10 @@ Same as v0.19.6
|
|||||||
- Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich))
|
- Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich))
|
||||||
- Minimum supported Android version to 8 ([@AntsyLich](https://github.com/AntsyLich)) ([`dfb3091`](https://github.com/mihonapp/mihon/commit/dfb3091e380dda3e9bfb64bf5c9a685cf3a03d0e))
|
- Minimum supported Android version to 8 ([@AntsyLich](https://github.com/AntsyLich)) ([`dfb3091`](https://github.com/mihonapp/mihon/commit/dfb3091e380dda3e9bfb64bf5c9a685cf3a03d0e))
|
||||||
|
|
||||||
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.21.0...main
|
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.21.3...main
|
||||||
|
[v0.21.3]: https://github.com/mihonapp/mihon/compare/v0.21.2...v0.21.3
|
||||||
|
[v0.21.2]: https://github.com/mihonapp/mihon/compare/v0.21.1...v0.21.2
|
||||||
|
[v0.21.1]: https://github.com/mihonapp/mihon/compare/v0.21.0...v0.21.1
|
||||||
[v0.21.0]: https://github.com/mihonapp/mihon/compare/v0.20.4...v0.21.0
|
[v0.21.0]: https://github.com/mihonapp/mihon/compare/v0.20.4...v0.21.0
|
||||||
[v0.20.4]: https://github.com/mihonapp/mihon/compare/v0.20.3...v0.20.4
|
[v0.20.4]: https://github.com/mihonapp/mihon/compare/v0.20.3...v0.20.4
|
||||||
[v0.20.3]: https://github.com/mihonapp/mihon/compare/v0.20.2...v0.20.3
|
[v0.20.3]: https://github.com/mihonapp/mihon/compare/v0.20.2...v0.20.3
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "app.mihon"
|
applicationId = "app.mihon"
|
||||||
|
|
||||||
versionCode = 36
|
versionCode = 39
|
||||||
versionName = "0.21.0"
|
versionName = "0.21.3"
|
||||||
|
|
||||||
buildConfigField("boolean", "TELEMETRY_INCLUDED", "${Config.includeTelemetry}")
|
buildConfigField("boolean", "TELEMETRY_INCLUDED", "${Config.includeTelemetry}")
|
||||||
buildConfigField("boolean", "UPDATER_ENABLED", "${Config.enableUpdater}")
|
buildConfigField("boolean", "UPDATER_ENABLED", "${Config.enableUpdater}")
|
||||||
@@ -229,6 +229,7 @@ dependencies {
|
|||||||
debugImplementation(libs.androidx.compose.uiTooling)
|
debugImplementation(libs.androidx.compose.uiTooling)
|
||||||
implementation(libs.androidx.compose.uiToolingPreview)
|
implementation(libs.androidx.compose.uiToolingPreview)
|
||||||
implementation(libs.androidx.compose.uiUtil)
|
implementation(libs.androidx.compose.uiUtil)
|
||||||
|
implementation(libs.androidx.navigationEvent)
|
||||||
|
|
||||||
implementation(libs.androidx.interpolator)
|
implementation(libs.androidx.interpolator)
|
||||||
|
|
||||||
@@ -291,11 +292,8 @@ dependencies {
|
|||||||
}
|
}
|
||||||
implementation(libs.image.decoder)
|
implementation(libs.image.decoder)
|
||||||
|
|
||||||
implementation(libs.webgpuviewer)
|
|
||||||
|
|
||||||
// UI libraries
|
// UI libraries
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation(libs.flexibleAdapter)
|
|
||||||
implementation(libs.photoView)
|
implementation(libs.photoView)
|
||||||
implementation(libs.directionalViewPager) {
|
implementation(libs.directionalViewPager) {
|
||||||
exclude(group = "androidx.viewpager", module = "viewpager")
|
exclude(group = "androidx.viewpager", module = "viewpager")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import dev.icerock.moko.resources.StringResource
|
|||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import dev.zacsweers.metro.SingleIn
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||||
import tachiyomi.core.common.preference.Preference
|
import tachiyomi.core.common.preference.Preference
|
||||||
import tachiyomi.core.common.preference.PreferenceStore
|
import tachiyomi.core.common.preference.PreferenceStore
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
@@ -37,7 +38,12 @@ class BasePreferences(
|
|||||||
PRIVATE(MR.strings.ext_installer_private, false),
|
PRIVATE(MR.strings.ext_installer_private, false),
|
||||||
}
|
}
|
||||||
|
|
||||||
val highQualityRenderer: Preference<Boolean> = preferenceStore.getBoolean("pref_high_quality_renderer_key", false)
|
val displayProfile: Preference<String> = preferenceStore.getString("pref_display_profile_key", "")
|
||||||
|
|
||||||
|
val hardwareBitmapThreshold: Preference<Int> = preferenceStore.getInt(
|
||||||
|
"pref_hardware_bitmap_threshold",
|
||||||
|
GLUtil.SAFE_TEXTURE_LIMIT,
|
||||||
|
)
|
||||||
|
|
||||||
val installationId: Preference<String> = preferenceStore.getString(Preference.appStateKey("installation_id"), "")
|
val installationId: Preference<String> = preferenceStore.getString(Preference.appStateKey("installation_id"), "")
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import eu.kanade.tachiyomi.data.track.EnhancedTracker
|
|||||||
import eu.kanade.tachiyomi.data.track.Tracker
|
import eu.kanade.tachiyomi.data.track.Tracker
|
||||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||||
@@ -61,10 +59,7 @@ class AddTracks(
|
|||||||
.minOrNull()
|
.minOrNull()
|
||||||
|
|
||||||
firstReadChapterDate?.let {
|
firstReadChapterDate?.let {
|
||||||
val startDate = firstReadChapterDate.toEpochMilliseconds().convertEpochMillisZone(
|
val startDate = firstReadChapterDate.toEpochMilliseconds()
|
||||||
TimeZone.currentSystemDefault(),
|
|
||||||
TimeZone.UTC,
|
|
||||||
)
|
|
||||||
track = track.copy(
|
track = track.copy(
|
||||||
startDate = Instant.fromEpochMilliseconds(startDate),
|
startDate = Instant.fromEpochMilliseconds(startDate),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
package eu.kanade.domain.ui
|
package eu.kanade.domain.ui
|
||||||
|
|
||||||
|
import android.text.format.DateFormat
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import dev.zacsweers.metro.SingleIn
|
import dev.zacsweers.metro.SingleIn
|
||||||
import eu.kanade.domain.ui.model.AppTheme
|
import eu.kanade.domain.ui.model.AppTheme
|
||||||
import eu.kanade.domain.ui.model.TabletUiMode
|
import eu.kanade.domain.ui.model.TabletUiMode
|
||||||
import eu.kanade.domain.ui.model.ThemeMode
|
import eu.kanade.domain.ui.model.ThemeMode
|
||||||
|
import eu.kanade.domain.ui.model.TimeFormat
|
||||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||||
import eu.kanade.tachiyomi.util.system.isDynamicColorAvailable
|
import eu.kanade.tachiyomi.util.system.isDynamicColorAvailable
|
||||||
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.preference.Preference
|
import tachiyomi.core.common.preference.Preference
|
||||||
import tachiyomi.core.common.preference.PreferenceStore
|
import tachiyomi.core.common.preference.PreferenceStore
|
||||||
import tachiyomi.core.common.preference.getEnum
|
import tachiyomi.core.common.preference.getEnum
|
||||||
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeFormatterBuilder
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -38,14 +43,53 @@ class UiPreferences(
|
|||||||
|
|
||||||
val dateFormat: Preference<String> = preferenceStore.getString("app_date_format", "")
|
val dateFormat: Preference<String> = preferenceStore.getString("app_date_format", "")
|
||||||
|
|
||||||
|
val timeFormat: Preference<TimeFormat> = preferenceStore.getEnum("app_time_format", TimeFormat.FOLLOW_LANGUAGE)
|
||||||
|
|
||||||
val tabletUiMode: Preference<TabletUiMode> = preferenceStore.getEnum("tablet_ui_mode", TabletUiMode.AUTOMATIC)
|
val tabletUiMode: Preference<TabletUiMode> = preferenceStore.getEnum("tablet_ui_mode", TabletUiMode.AUTOMATIC)
|
||||||
|
|
||||||
val imagesInDescription: Preference<Boolean> = preferenceStore.getBoolean("pref_render_images_description", true)
|
val imagesInDescription: Preference<Boolean> = preferenceStore.getBoolean("pref_render_images_description", true)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun dateFormat(format: String): DateTimeFormatter = when (format) {
|
fun dateFormat(format: String, locale: Locale): DateTimeFormatter = when (format) {
|
||||||
"" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
|
"" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(locale)
|
||||||
else -> DateTimeFormatter.ofPattern(format, Locale.getDefault())
|
else -> DateTimeFormatter.ofPattern(format, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun timeFormat(timeFormat: TimeFormat, locale: Locale, is24HourSystem: Boolean): DateTimeFormatter {
|
||||||
|
val use24Hours = when (timeFormat) {
|
||||||
|
TimeFormat.FOLLOW_LANGUAGE -> {
|
||||||
|
return DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeFormat.FOLLOW_SYSTEM -> is24HourSystem
|
||||||
|
TimeFormat.HOURS_24 -> true
|
||||||
|
TimeFormat.HOURS_12 -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
val skeleton = if (use24Hours) "Hm" else "hm"
|
||||||
|
val pattern = DateFormat.getBestDateTimePattern(locale, skeleton)
|
||||||
|
return try {
|
||||||
|
DateTimeFormatter.ofPattern(pattern, locale)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
val fallbackPattern = if (use24Hours) "HH:mm" else "h:mm a"
|
||||||
|
logcat(LogPriority.ERROR, e) {
|
||||||
|
"Failed to apply best pattern ('$pattern'), falling back to '$fallbackPattern'"
|
||||||
|
}
|
||||||
|
DateTimeFormatter.ofPattern(fallbackPattern, locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dateTimeFormat(
|
||||||
|
dateFormat: String,
|
||||||
|
timeFormat: TimeFormat,
|
||||||
|
locale: Locale,
|
||||||
|
is24HourSystem: Boolean,
|
||||||
|
): DateTimeFormatter {
|
||||||
|
return DateTimeFormatterBuilder()
|
||||||
|
.append(dateFormat(dateFormat, locale))
|
||||||
|
.appendLiteral(' ')
|
||||||
|
.append(timeFormat(timeFormat, locale, is24HourSystem))
|
||||||
|
.toFormatter(locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package eu.kanade.domain.ui.model
|
||||||
|
|
||||||
|
import dev.icerock.moko.resources.StringResource
|
||||||
|
import tachiyomi.i18n.MR
|
||||||
|
|
||||||
|
enum class TimeFormat(val titleRes: StringResource) {
|
||||||
|
FOLLOW_LANGUAGE(MR.strings.follow_language),
|
||||||
|
FOLLOW_SYSTEM(MR.strings.follow_system),
|
||||||
|
HOURS_24(MR.strings.time_24_hours),
|
||||||
|
HOURS_12(MR.strings.time_12_hours),
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package eu.kanade.presentation.components
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLocale
|
||||||
import eu.kanade.domain.ui.UiPreferences
|
import eu.kanade.domain.ui.UiPreferences
|
||||||
import eu.kanade.tachiyomi.util.lang.toRelativeString
|
import eu.kanade.tachiyomi.util.lang.toRelativeString
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -27,10 +28,11 @@ fun relativeDateText(
|
|||||||
localDate: LocalDate?,
|
localDate: LocalDate?,
|
||||||
): String {
|
): String {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val locale = LocalLocale.current
|
||||||
|
|
||||||
val preferences = remember { context.appGraph.uiPreferences }
|
val preferences = remember { context.appGraph.uiPreferences }
|
||||||
val relativeTime = remember { preferences.relativeTime.get() }
|
val relativeTime = remember { preferences.relativeTime.get() }
|
||||||
val dateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat.get()) }
|
val dateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat.get(), locale.platformLocale) }
|
||||||
|
|
||||||
return localDate?.toRelativeString(
|
return localDate?.toRelativeString(
|
||||||
context = context,
|
context = context,
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
package eu.kanade.presentation.download
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.MutatePriority
|
||||||
|
import androidx.compose.foundation.gestures.ScrollScope
|
||||||
|
import androidx.compose.foundation.gestures.ScrollableState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.toMutableStateList
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.kanade.presentation.components.DropdownMenu
|
||||||
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.ui.download.DownloadQueueSection
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
|
import mihon.icons.materialsymbols.rounded.DragHandle
|
||||||
|
import mihon.icons.materialsymbols.rounded.MoreVert
|
||||||
|
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
||||||
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
|
import sh.calvin.reorderable.rememberScroller
|
||||||
|
import tachiyomi.i18n.MR
|
||||||
|
import tachiyomi.presentation.core.components.ListGroupHeader
|
||||||
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DownloadQueueList(
|
||||||
|
sections: List<DownloadQueueSection>,
|
||||||
|
lazyListState: LazyListState,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
onReorder: (List<Download>) -> Unit,
|
||||||
|
onMove: (Download, toTop: Boolean) -> Unit,
|
||||||
|
onMoveSeries: (Download, toTop: Boolean) -> Unit,
|
||||||
|
onCancel: (Download) -> Unit,
|
||||||
|
onCancelSeries: (Download) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val sectionsState = remember { sections.toMutableStateList() }
|
||||||
|
// While a section is dragged its downloads are hidden, so it moves past whole sections
|
||||||
|
var isSectionDragging by remember { mutableStateOf(false) }
|
||||||
|
// While a download is dragged only its own section's rows can be swapped with it
|
||||||
|
var draggingSectionId by remember { mutableStateOf<Long?>(null) }
|
||||||
|
// The release animation starts from the finger, so the last dragged row stays held while it settles too
|
||||||
|
var lastDraggedKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
val listCoordinates = remember { CoordinatesHolder() }
|
||||||
|
val draggingSection by rememberUpdatedState(sectionsState.find { it.id == draggingSectionId })
|
||||||
|
// Auto-scroll would otherwise carry a download held at its section's edge off screen
|
||||||
|
val sectionScrollState = remember(lazyListState) {
|
||||||
|
LimitedScrollableState(lazyListState) { pixels ->
|
||||||
|
draggingSection?.let { lazyListState.limitScrollToSection(it, pixels) } ?: pixels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val reorderableState = rememberReorderableLazyListState(
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
scrollThresholdPadding = contentPadding,
|
||||||
|
scroller = rememberScroller(
|
||||||
|
scrollableState = sectionScrollState,
|
||||||
|
pixelAmountProvider = { lazyListState.layoutInfo.viewportSize.height * 0.05f },
|
||||||
|
),
|
||||||
|
) { from, to ->
|
||||||
|
val fromKey = from.key as String
|
||||||
|
val toKey = to.key as String
|
||||||
|
when {
|
||||||
|
fromKey.startsWith(SECTION_KEY) && toKey.startsWith(SECTION_KEY) -> {
|
||||||
|
val fromIndex = sectionsState.indexOfFirst { it.key == fromKey }
|
||||||
|
val toIndex = sectionsState.indexOfFirst { it.key == toKey }
|
||||||
|
sectionsState.add(toIndex, sectionsState.removeAt(fromIndex))
|
||||||
|
}
|
||||||
|
// Downloads don't change section
|
||||||
|
fromKey.startsWith(DOWNLOAD_KEY) && toKey.startsWith(DOWNLOAD_KEY) -> {
|
||||||
|
val sectionIndex = sectionsState.indexOfFirst { section ->
|
||||||
|
section.downloads.any { it.key == fromKey }
|
||||||
|
}
|
||||||
|
val section = sectionsState[sectionIndex]
|
||||||
|
val toIndex = section.downloads.indexOfFirst { it.key == toKey }
|
||||||
|
if (toIndex != -1) {
|
||||||
|
val downloads = section.downloads.toMutableList()
|
||||||
|
val fromIndex = downloads.indexOfFirst { it.key == fromKey }
|
||||||
|
downloads.add(toIndex, downloads.removeAt(fromIndex))
|
||||||
|
sectionsState[sectionIndex] = section.copy(downloads = downloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(sections) {
|
||||||
|
if (!reorderableState.isAnyItemDragging) {
|
||||||
|
sectionsState.clear()
|
||||||
|
sectionsState.addAll(sections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onDragStopped = {
|
||||||
|
isSectionDragging = false
|
||||||
|
draggingSectionId = null
|
||||||
|
onReorder(sectionsState.flatMap { it.downloads })
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstDownload = sectionsState.firstOrNull()?.downloads?.firstOrNull()
|
||||||
|
val lastDownload = sectionsState.lastOrNull()?.downloads?.lastOrNull()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onGloballyPositioned { listCoordinates.value = it },
|
||||||
|
state = lazyListState,
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
) {
|
||||||
|
sectionsState.forEach { section ->
|
||||||
|
item(key = section.key) {
|
||||||
|
ReorderableItem(reorderableState, section.key, enabled = draggingSectionId == null) { isDragging ->
|
||||||
|
DownloadQueueHeader(
|
||||||
|
section = section,
|
||||||
|
isDragging = isDragging,
|
||||||
|
onDragStarted = { isSectionDragging = true },
|
||||||
|
onDragStopped = onDragStopped,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isSectionDragging) {
|
||||||
|
items(
|
||||||
|
items = section.downloads,
|
||||||
|
key = { it.key },
|
||||||
|
) { download ->
|
||||||
|
ReorderableItem(
|
||||||
|
state = reorderableState,
|
||||||
|
key = download.key,
|
||||||
|
enabled = draggingSectionId == null || draggingSectionId == section.id,
|
||||||
|
) { isDragging ->
|
||||||
|
DownloadQueueItem(
|
||||||
|
download = download,
|
||||||
|
isDragging = isDragging,
|
||||||
|
modifier = if (isDragging || download.key == lastDraggedKey) {
|
||||||
|
Modifier.keepInSection(
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
listCoordinates = listCoordinates,
|
||||||
|
sectionKey = section.key,
|
||||||
|
lastDownloadKey = section.downloads.last().key,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
canMoveToTop = download != firstDownload,
|
||||||
|
canMoveToBottom = download != lastDownload,
|
||||||
|
onDragStarted = {
|
||||||
|
draggingSectionId = section.id
|
||||||
|
lastDraggedKey = download.key
|
||||||
|
},
|
||||||
|
onDragStopped = onDragStopped,
|
||||||
|
onMove = { toTop -> onMove(download, toTop) },
|
||||||
|
onMoveSeries = { toTop -> onMoveSeries(download, toTop) },
|
||||||
|
onCancel = { onCancel(download) },
|
||||||
|
onCancelSeries = { onCancelSeries(download) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy list keys must fit in a Bundle
|
||||||
|
private const val SECTION_KEY = "section-"
|
||||||
|
private const val DOWNLOAD_KEY = "download-"
|
||||||
|
private val DownloadQueueSection.key get() = "$SECTION_KEY$id"
|
||||||
|
private val Download.key get() = "$DOWNLOAD_KEY${chapter.id}"
|
||||||
|
|
||||||
|
private class CoordinatesHolder {
|
||||||
|
var value: LayoutCoordinates? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [lazyListState], with every scroll step passed through [limit] first.
|
||||||
|
*/
|
||||||
|
private class LimitedScrollableState(
|
||||||
|
private val lazyListState: LazyListState,
|
||||||
|
private val limit: (Float) -> Float,
|
||||||
|
) : ScrollableState by lazyListState {
|
||||||
|
override suspend fun scroll(scrollPriority: MutatePriority, block: suspend ScrollScope.() -> Unit) {
|
||||||
|
lazyListState.scroll(scrollPriority) {
|
||||||
|
val scope = this
|
||||||
|
object : ScrollScope {
|
||||||
|
override fun scrollBy(pixels: Float) = scope.scrollBy(limit(pixels))
|
||||||
|
}.block()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limits [pixels] so the list scrolls no further than the end of [section] reaching the bottom, or its header
|
||||||
|
* reaching the top, while that edge is on screen.
|
||||||
|
*/
|
||||||
|
private fun LazyListState.limitScrollToSection(section: DownloadQueueSection, pixels: Float): Float {
|
||||||
|
val items = layoutInfo.visibleItemsInfo
|
||||||
|
return if (pixels > 0) {
|
||||||
|
val last = items.firstOrNull { it.key == section.downloads.last().key } ?: return pixels
|
||||||
|
val contentEnd = layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding
|
||||||
|
pixels.coerceAtMost((last.offset + last.size - contentEnd).coerceAtLeast(0).toFloat())
|
||||||
|
} else {
|
||||||
|
val header = items.firstOrNull { it.key == section.key } ?: return pixels
|
||||||
|
pixels.coerceAtLeast((header.offset).coerceAtMost(0).toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops a dragged download at the edges of its section, between the header and the section's last slot, instead of
|
||||||
|
* letting it float over another source it can't be dropped in.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun Modifier.keepInSection(
|
||||||
|
lazyListState: LazyListState,
|
||||||
|
listCoordinates: CoordinatesHolder,
|
||||||
|
sectionKey: String,
|
||||||
|
lastDownloadKey: String,
|
||||||
|
): Modifier {
|
||||||
|
var correction by remember { mutableFloatStateOf(0f) }
|
||||||
|
return this
|
||||||
|
// Before the correction layer, so it sees where the drag put the row
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
val list = listCoordinates.value?.takeIf { it.isAttached } ?: return@onGloballyPositioned
|
||||||
|
val top = list.localPositionOf(coordinates, Offset.Zero).y
|
||||||
|
val layoutInfo = lazyListState.layoutInfo
|
||||||
|
val itemStart = layoutInfo.beforeContentPadding
|
||||||
|
val header = layoutInfo.visibleItemsInfo.firstOrNull { it.key == sectionKey }
|
||||||
|
val last = layoutInfo.visibleItemsInfo.firstOrNull { it.key == lastDownloadKey }
|
||||||
|
val minTop = header?.let { (itemStart + it.offset + it.size).toFloat() } ?: Float.NEGATIVE_INFINITY
|
||||||
|
val maxTop = last?.let { (itemStart + it.offset + it.size - coordinates.size.height).toFloat() }
|
||||||
|
?: Float.POSITIVE_INFINITY
|
||||||
|
correction = top.coerceIn(minTop, maxOf(minTop, maxTop)) - top
|
||||||
|
}
|
||||||
|
.graphicsLayer { translationY = correction }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReorderableCollectionItemScope.DownloadQueueHeader(
|
||||||
|
section: DownloadQueueSection,
|
||||||
|
isDragging: Boolean,
|
||||||
|
onDragStarted: () -> Unit,
|
||||||
|
onDragStopped: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.padding(top = MaterialTheme.padding.small),
|
||||||
|
color = listItemContainerColor(isDragging),
|
||||||
|
shadowElevation = listItemElevation(isDragging),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(end = MaterialTheme.padding.small),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ListGroupHeader(
|
||||||
|
text = "${section.name} (${section.downloads.size})",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
DragHandle(onDragStarted = onDragStarted, onDragStopped = onDragStopped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReorderableCollectionItemScope.DownloadQueueItem(
|
||||||
|
download: Download,
|
||||||
|
isDragging: Boolean,
|
||||||
|
canMoveToTop: Boolean,
|
||||||
|
canMoveToBottom: Boolean,
|
||||||
|
onDragStarted: () -> Unit,
|
||||||
|
onDragStopped: () -> Unit,
|
||||||
|
onMove: (toTop: Boolean) -> Unit,
|
||||||
|
onMoveSeries: (toTop: Boolean) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onCancelSeries: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val progress by remember(download) { download.pagesProgressFlow() }.collectAsState(null)
|
||||||
|
val animatedProgress by animateFloatAsState(progress?.fraction ?: 0f, label = "progress")
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
color = listItemContainerColor(isDragging),
|
||||||
|
shadowElevation = listItemElevation(isDragging),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = MaterialTheme.padding.small,
|
||||||
|
end = MaterialTheme.padding.extraSmall,
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
DragHandle(onDragStarted = onDragStarted, onDragStopped = onDragStopped)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = MaterialTheme.padding.small),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) {
|
||||||
|
Text(
|
||||||
|
text = download.manga.title,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
progress?.let {
|
||||||
|
Text(
|
||||||
|
text = "${it.downloadedPages}/${it.pageCount}",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = download.chapter.name,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { animatedProgress },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = MaterialTheme.padding.extraSmall),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DownloadQueueItemMenu(
|
||||||
|
canMoveToTop = canMoveToTop,
|
||||||
|
canMoveToBottom = canMoveToBottom,
|
||||||
|
onMove = onMove,
|
||||||
|
onMoveSeries = onMoveSeries,
|
||||||
|
onCancel = onCancel,
|
||||||
|
onCancelSeries = onCancelSeries,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReorderableCollectionItemScope.DragHandle(onDragStarted: () -> Unit, onDragStopped: () -> Unit) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MaterialSymbols.Rounded.DragHandle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.draggableHandle(
|
||||||
|
onDragStarted = { onDragStarted() },
|
||||||
|
onDragStopped = onDragStopped,
|
||||||
|
)
|
||||||
|
.padding(MaterialTheme.padding.small),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DownloadQueueItemMenu(
|
||||||
|
canMoveToTop: Boolean,
|
||||||
|
canMoveToBottom: Boolean,
|
||||||
|
onMove: (toTop: Boolean) -> Unit,
|
||||||
|
onMoveSeries: (toTop: Boolean) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onCancelSeries: () -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val closeMenu = { expanded = false }
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { expanded = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MaterialSymbols.Rounded.MoreVert,
|
||||||
|
contentDescription = stringResource(MR.strings.action_menu),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = closeMenu) {
|
||||||
|
if (canMoveToTop) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(MR.strings.action_move_to_top)) },
|
||||||
|
onClick = {
|
||||||
|
closeMenu()
|
||||||
|
onMove(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(MR.strings.action_move_to_top_all_for_series)) },
|
||||||
|
onClick = {
|
||||||
|
closeMenu()
|
||||||
|
onMoveSeries(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (canMoveToBottom) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(MR.strings.action_move_to_bottom)) },
|
||||||
|
onClick = {
|
||||||
|
closeMenu()
|
||||||
|
onMove(false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(MR.strings.action_move_to_bottom_all_for_series)) },
|
||||||
|
onClick = {
|
||||||
|
closeMenu()
|
||||||
|
onMoveSeries(false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(MR.strings.action_cancel)) },
|
||||||
|
onClick = {
|
||||||
|
closeMenu()
|
||||||
|
onCancel()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(MR.strings.cancel_all_for_series)) },
|
||||||
|
onClick = {
|
||||||
|
closeMenu()
|
||||||
|
onCancelSeries()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A neutral lifted surface while dragged, rather than Material's tertiaryContainer list tint
|
||||||
|
@Composable
|
||||||
|
private fun listItemContainerColor(isDragging: Boolean): Color {
|
||||||
|
return if (isDragging) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.background
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun listItemElevation(isDragging: Boolean): Dp {
|
||||||
|
val elevation = ListItemDefaults.elevation()
|
||||||
|
return if (isDragging) elevation.draggedElevation else elevation.elevation
|
||||||
|
}
|
||||||
|
|
||||||
|
private class PagesProgress(
|
||||||
|
val pageCount: Int,
|
||||||
|
val downloadedPages: Int,
|
||||||
|
totalProgress: Int,
|
||||||
|
) {
|
||||||
|
val fraction = if (pageCount == 0) 0f else totalProgress / (pageCount * 100f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress of the pages of this download, or null until its pages are known.
|
||||||
|
*/
|
||||||
|
private fun Download.pagesProgressFlow() = pagesFlow.flatMapLatest { pages ->
|
||||||
|
when {
|
||||||
|
pages == null -> flowOf(null)
|
||||||
|
pages.isEmpty() -> flowOf(PagesProgress(pageCount = 0, downloadedPages = 0, totalProgress = 0))
|
||||||
|
else ->
|
||||||
|
combine(pages.map { page -> combine(page.statusFlow, page.progressFlow, ::Pair) }) { states ->
|
||||||
|
PagesProgress(
|
||||||
|
pageCount = pages.size,
|
||||||
|
downloadedPages = states.count { (status, _) -> status == Page.State.Ready },
|
||||||
|
totalProgress = states.sumOf { (_, progress) -> progress },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,14 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLocale
|
||||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import eu.kanade.domain.ui.UiPreferences
|
||||||
import eu.kanade.presentation.components.AppBar
|
import eu.kanade.presentation.components.AppBar
|
||||||
import eu.kanade.presentation.components.AppBarActions
|
import eu.kanade.presentation.components.AppBarActions
|
||||||
import eu.kanade.presentation.components.AppBarTitle
|
import eu.kanade.presentation.components.AppBarTitle
|
||||||
@@ -16,8 +21,10 @@ import eu.kanade.presentation.components.SearchToolbar
|
|||||||
import eu.kanade.presentation.components.relativeDateText
|
import eu.kanade.presentation.components.relativeDateText
|
||||||
import eu.kanade.presentation.history.components.HistoryItem
|
import eu.kanade.presentation.history.components.HistoryItem
|
||||||
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
|
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
|
||||||
|
import eu.kanade.presentation.util.rememberIs24HourSystem
|
||||||
import eu.kanade.tachiyomi.ui.history.HistoryViewModel
|
import eu.kanade.tachiyomi.ui.history.HistoryViewModel
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import mihon.app.di.appGraph
|
||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.rounded.DeleteSweep
|
import mihon.icons.materialsymbols.rounded.DeleteSweep
|
||||||
import tachiyomi.domain.history.model.HistoryWithRelations
|
import tachiyomi.domain.history.model.HistoryWithRelations
|
||||||
@@ -28,6 +35,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
|
|||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import tachiyomi.presentation.core.screens.EmptyScreen
|
import tachiyomi.presentation.core.screens.EmptyScreen
|
||||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||||
|
import tachiyomi.presentation.core.util.collectAsState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HistoryScreen(
|
fun HistoryScreen(
|
||||||
@@ -99,6 +107,14 @@ private fun HistoryScreenContent(
|
|||||||
onClickDelete: (HistoryWithRelations) -> Unit,
|
onClickDelete: (HistoryWithRelations) -> Unit,
|
||||||
onClickFavorite: (HistoryWithRelations) -> Unit,
|
onClickFavorite: (HistoryWithRelations) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val locale = LocalLocale.current
|
||||||
|
val is24HourSystem = rememberIs24HourSystem()
|
||||||
|
val timeFormat by remember { context.appGraph.uiPreferences.timeFormat }.collectAsState()
|
||||||
|
val timeFormatter = remember(timeFormat, locale, is24HourSystem) {
|
||||||
|
UiPreferences.timeFormat(timeFormat, locale.platformLocale, is24HourSystem)
|
||||||
|
}
|
||||||
|
|
||||||
FastScrollLazyColumn(
|
FastScrollLazyColumn(
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
) {
|
) {
|
||||||
@@ -124,6 +140,7 @@ private fun HistoryScreenContent(
|
|||||||
HistoryItem(
|
HistoryItem(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = Modifier.animateItem(),
|
||||||
history = value,
|
history = value,
|
||||||
|
timeFormatter = timeFormatter,
|
||||||
onClickCover = { onClickCover(value) },
|
onClickCover = { onClickCover(value) },
|
||||||
onClickResume = { onClickResume(value) },
|
onClickResume = { onClickResume(value) },
|
||||||
onClickDelete = { onClickDelete(value) },
|
onClickDelete = { onClickDelete(value) },
|
||||||
|
|||||||
@@ -31,12 +31,15 @@ import tachiyomi.domain.history.model.HistoryWithRelations
|
|||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.padding
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
private val HistoryItemHeight = 96.dp
|
private val HistoryItemHeight = 96.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HistoryItem(
|
fun HistoryItem(
|
||||||
history: HistoryWithRelations,
|
history: HistoryWithRelations,
|
||||||
|
timeFormatter: DateTimeFormatter,
|
||||||
onClickCover: () -> Unit,
|
onClickCover: () -> Unit,
|
||||||
onClickResume: () -> Unit,
|
onClickResume: () -> Unit,
|
||||||
onClickDelete: () -> Unit,
|
onClickDelete: () -> Unit,
|
||||||
@@ -68,7 +71,9 @@ fun HistoryItem(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
)
|
)
|
||||||
val readAt = remember { history.readAt?.toTimestampString() ?: "" }
|
val readAt = remember(timeFormatter, history.readAt) {
|
||||||
|
history.readAt?.toTimestampString(timeFormatter) ?: ""
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = if (history.chapterNumber > -1) {
|
text = if (history.chapterNumber > -1) {
|
||||||
stringResource(
|
stringResource(
|
||||||
@@ -114,6 +119,7 @@ private fun HistoryItemPreviews(
|
|||||||
Surface {
|
Surface {
|
||||||
HistoryItem(
|
HistoryItem(
|
||||||
history = historyWithRelations,
|
history = historyWithRelations,
|
||||||
|
timeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT),
|
||||||
onClickCover = {},
|
onClickCover = {},
|
||||||
onClickResume = {},
|
onClickResume = {},
|
||||||
onClickDelete = {},
|
onClickDelete = {},
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -26,9 +25,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
@@ -36,27 +33,16 @@ import androidx.compose.ui.window.Dialog
|
|||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import ca.mpreg.webgpuviewer.renderer.GainmapInput
|
|
||||||
import ca.mpreg.webgpuviewer.renderer.Image
|
|
||||||
import ca.mpreg.webgpuviewer.viewer.ImagePage
|
|
||||||
import ca.mpreg.webgpuviewer.viewer.ImageViewer
|
|
||||||
import ca.mpreg.webgpuviewer.viewer.ImageViewerState
|
|
||||||
import coil3.asDrawable
|
import coil3.asDrawable
|
||||||
import coil3.imageLoader
|
import coil3.imageLoader
|
||||||
import coil3.request.CachePolicy
|
import coil3.request.CachePolicy
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.SuccessResult
|
|
||||||
import coil3.size.Size
|
import coil3.size.Size
|
||||||
import eu.kanade.presentation.components.AppBar
|
import eu.kanade.presentation.components.AppBar
|
||||||
import eu.kanade.presentation.components.AppBarActions
|
import eu.kanade.presentation.components.AppBarActions
|
||||||
import eu.kanade.presentation.components.DropdownMenu
|
import eu.kanade.presentation.components.DropdownMenu
|
||||||
import eu.kanade.presentation.manga.EditCoverAction
|
import eu.kanade.presentation.manga.EditCoverAction
|
||||||
import eu.kanade.tachiyomi.data.coil.ImageDecoder
|
|
||||||
import eu.kanade.tachiyomi.data.coil.newDecoder
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import mihon.app.di.appGraph
|
|
||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.rounded.Close
|
import mihon.icons.materialsymbols.rounded.Close
|
||||||
import mihon.icons.materialsymbols.rounded.Edit
|
import mihon.icons.materialsymbols.rounded.Edit
|
||||||
@@ -79,9 +65,6 @@ fun MangaCoverDialog(
|
|||||||
onEditClick: ((EditCoverAction) -> Unit)?,
|
onEditClick: ((EditCoverAction) -> Unit)?,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val useNewRenderer = LocalContext.current.appGraph.basePreferences.highQualityRenderer.get()
|
|
||||||
val view = LocalView.current
|
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
properties = DialogProperties(
|
properties = DialogProperties(
|
||||||
@@ -168,59 +151,6 @@ fun MangaCoverDialog(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
if (useNewRenderer) {
|
|
||||||
val state = remember(manga) { ImageViewerState() }
|
|
||||||
LaunchedEffect(manga) {
|
|
||||||
val request = ImageRequest.Builder(view.context)
|
|
||||||
.data(manga)
|
|
||||||
.size(Size.ORIGINAL)
|
|
||||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
|
||||||
.newDecoder(true)
|
|
||||||
.build()
|
|
||||||
val result = view.context.imageLoader.execute(request) as? SuccessResult ?: return@LaunchedEffect
|
|
||||||
val res = result.image as? ImageDecoder.DecodeResultImage ?: return@LaunchedEffect
|
|
||||||
// Held, then freed, around the upload: its buffer alone doesn't keep
|
|
||||||
// the frame's native pixels alive.
|
|
||||||
val page = res.frame.use {
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
ImagePage.ImageSingle(
|
|
||||||
Image(
|
|
||||||
res.image,
|
|
||||||
res.width,
|
|
||||||
res.height,
|
|
||||||
createMipMaps = true,
|
|
||||||
backgroundColor = 0,
|
|
||||||
hdr = res.isHdr,
|
|
||||||
hdrHeadroom = res.hdrHeadroom,
|
|
||||||
gainmap = res.gainmap?.let {
|
|
||||||
GainmapInput(
|
|
||||||
pixels = it.pixels,
|
|
||||||
width = it.width,
|
|
||||||
height = it.height,
|
|
||||||
channels = it.channels,
|
|
||||||
gamma = it.gamma,
|
|
||||||
minContentBoost = it.minContentBoost,
|
|
||||||
maxContentBoost = it.maxContentBoost,
|
|
||||||
offsetSdr = it.offsetSdr,
|
|
||||||
offsetHdr = it.offsetHdr,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.apply {
|
|
||||||
fetchPage = { index ->
|
|
||||||
if (index == 0) page else null
|
|
||||||
}
|
|
||||||
invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImageViewer(state = state)
|
|
||||||
return@Scaffold
|
|
||||||
}
|
|
||||||
|
|
||||||
val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() }
|
val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() }
|
||||||
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
|
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ private fun getMarkdownColors(): MarkdownColors {
|
|||||||
inlineCodeBackground = codeBackground,
|
inlineCodeBackground = codeBackground,
|
||||||
dividerColor = MaterialTheme.colorScheme.outlineVariant,
|
dividerColor = MaterialTheme.colorScheme.outlineVariant,
|
||||||
tableBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f),
|
tableBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f),
|
||||||
|
highlightText = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
highlightBackground = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-3
@@ -6,6 +6,8 @@ import android.content.Intent
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.webkit.WebStorage
|
import android.webkit.WebStorage
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -41,6 +43,7 @@ import eu.kanade.tachiyomi.network.PREF_DOH_QUAD101
|
|||||||
import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9
|
import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9
|
||||||
import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN
|
import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN
|
||||||
import eu.kanade.tachiyomi.ui.more.OnboardingScreen
|
import eu.kanade.tachiyomi.ui.more.OnboardingScreen
|
||||||
|
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||||
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
|
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
|
||||||
import eu.kanade.tachiyomi.util.system.isShizukuInstalled
|
import eu.kanade.tachiyomi.util.system.isShizukuInstalled
|
||||||
import eu.kanade.tachiyomi.util.system.powerManager
|
import eu.kanade.tachiyomi.util.system.powerManager
|
||||||
@@ -53,6 +56,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
|
import tachiyomi.core.common.util.system.ImageUtil
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
@@ -97,6 +101,15 @@ object SettingsAdvancedScreen : SearchableSettings {
|
|||||||
true
|
true
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
preference = networkPreferences.redactSensitiveHeaders,
|
||||||
|
title = stringResource(MR.strings.pref_redact_sensitive_headers),
|
||||||
|
subtitle = stringResource(MR.strings.pref_redact_sensitive_headers_summary),
|
||||||
|
onValueChanged = {
|
||||||
|
context.toast(MR.strings.requires_app_restart)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
),
|
||||||
Preference.PreferenceItem.TextPreference(
|
Preference.PreferenceItem.TextPreference(
|
||||||
title = stringResource(MR.strings.pref_debug_info),
|
title = stringResource(MR.strings.pref_debug_info),
|
||||||
onClick = { navigator.push(DebugInfoScreen()) },
|
onClick = { navigator.push(DebugInfoScreen()) },
|
||||||
@@ -333,12 +346,43 @@ object SettingsAdvancedScreen : SearchableSettings {
|
|||||||
basePreferences: BasePreferences,
|
basePreferences: BasePreferences,
|
||||||
): Preference.PreferenceGroup {
|
): Preference.PreferenceGroup {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val chooseColorProfile = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
) { uri ->
|
||||||
|
uri?.let {
|
||||||
|
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||||
|
basePreferences.displayProfile.set(uri.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
return Preference.PreferenceGroup(
|
return Preference.PreferenceGroup(
|
||||||
title = stringResource(MR.strings.pref_category_reader),
|
title = stringResource(MR.strings.pref_category_reader),
|
||||||
preferenceItems = listOf(
|
preferenceItems = listOf(
|
||||||
Preference.PreferenceItem.SwitchPreference(
|
Preference.PreferenceItem.ListPreference(
|
||||||
preference = basePreferences.highQualityRenderer,
|
preference = basePreferences.hardwareBitmapThreshold,
|
||||||
title = stringResource(MR.strings.pref_high_quality_renderer),
|
entries = GLUtil.CUSTOM_TEXTURE_LIMIT_OPTIONS
|
||||||
|
.mapIndexed { index, option ->
|
||||||
|
val display = if (index == 0) {
|
||||||
|
stringResource(MR.strings.pref_hardware_bitmap_threshold_default, option)
|
||||||
|
} else {
|
||||||
|
option.toString()
|
||||||
|
}
|
||||||
|
option to display
|
||||||
|
}
|
||||||
|
.toMap(),
|
||||||
|
title = stringResource(MR.strings.pref_hardware_bitmap_threshold),
|
||||||
|
subtitleProvider = { value, options ->
|
||||||
|
stringResource(MR.strings.pref_hardware_bitmap_threshold_summary, options[value].orEmpty())
|
||||||
|
},
|
||||||
|
visible = !ImageUtil.HARDWARE_BITMAP_UNSUPPORTED &&
|
||||||
|
GLUtil.DEVICE_TEXTURE_LIMIT > GLUtil.SAFE_TEXTURE_LIMIT,
|
||||||
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = stringResource(MR.strings.pref_display_profile),
|
||||||
|
subtitle = basePreferences.displayProfile.get(),
|
||||||
|
onClick = {
|
||||||
|
chooseColorProfile.launch(arrayOf("*/*"))
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+22
-4
@@ -7,17 +7,20 @@ import androidx.compose.runtime.ReadOnlyComposable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLocale
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
import eu.kanade.domain.ui.UiPreferences
|
import eu.kanade.domain.ui.UiPreferences
|
||||||
import eu.kanade.domain.ui.model.TabletUiMode
|
import eu.kanade.domain.ui.model.TabletUiMode
|
||||||
import eu.kanade.domain.ui.model.ThemeMode
|
import eu.kanade.domain.ui.model.ThemeMode
|
||||||
|
import eu.kanade.domain.ui.model.TimeFormat
|
||||||
import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode
|
import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode
|
||||||
import eu.kanade.presentation.more.settings.Preference
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
import eu.kanade.presentation.more.settings.screen.appearance.AppLanguageScreen
|
import eu.kanade.presentation.more.settings.screen.appearance.AppLanguageScreen
|
||||||
import eu.kanade.presentation.more.settings.widget.AppThemeModePreferenceWidget
|
import eu.kanade.presentation.more.settings.widget.AppThemeModePreferenceWidget
|
||||||
import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget
|
import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget
|
||||||
|
import eu.kanade.presentation.util.rememberIs24HourSystem
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toJavaLocalDateTime
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
@@ -101,12 +104,14 @@ object SettingsAppearanceScreen : SearchableSettings {
|
|||||||
): Preference.PreferenceGroup {
|
): Preference.PreferenceGroup {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
val locale = LocalLocale.current
|
||||||
|
val is24HourSystem = rememberIs24HourSystem()
|
||||||
|
|
||||||
val now = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).toJavaLocalDateTime() }
|
val now = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).toJavaLocalDateTime() }
|
||||||
|
|
||||||
val dateFormat by uiPreferences.dateFormat.collectAsState()
|
val dateFormat by uiPreferences.dateFormat.collectAsState()
|
||||||
val formattedNow = remember(dateFormat) {
|
val formattedDateNow = remember(dateFormat) {
|
||||||
UiPreferences.dateFormat(dateFormat).format(now)
|
UiPreferences.dateFormat(dateFormat, locale.platformLocale).format(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Preference.PreferenceGroup(
|
return Preference.PreferenceGroup(
|
||||||
@@ -130,18 +135,31 @@ object SettingsAppearanceScreen : SearchableSettings {
|
|||||||
preference = uiPreferences.dateFormat,
|
preference = uiPreferences.dateFormat,
|
||||||
entries = DateFormats
|
entries = DateFormats
|
||||||
.associateWith {
|
.associateWith {
|
||||||
val formattedDate = UiPreferences.dateFormat(it).format(now)
|
val formattedDate = UiPreferences.dateFormat(it, locale.platformLocale).format(now)
|
||||||
"${it.ifEmpty { stringResource(MR.strings.label_default) }} ($formattedDate)"
|
"${it.ifEmpty { stringResource(MR.strings.label_default) }} ($formattedDate)"
|
||||||
},
|
},
|
||||||
title = stringResource(MR.strings.pref_date_format),
|
title = stringResource(MR.strings.pref_date_format),
|
||||||
),
|
),
|
||||||
|
Preference.PreferenceItem.ListPreference(
|
||||||
|
preference = uiPreferences.timeFormat,
|
||||||
|
entries = TimeFormat.entries
|
||||||
|
.associateWith {
|
||||||
|
val formattedTime = UiPreferences.timeFormat(
|
||||||
|
it,
|
||||||
|
locale.platformLocale,
|
||||||
|
is24HourSystem,
|
||||||
|
).format(now)
|
||||||
|
"${stringResource(it.titleRes)} ($formattedTime)"
|
||||||
|
},
|
||||||
|
title = stringResource(MR.strings.pref_time_format),
|
||||||
|
),
|
||||||
Preference.PreferenceItem.SwitchPreference(
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
preference = uiPreferences.relativeTime,
|
preference = uiPreferences.relativeTime,
|
||||||
title = stringResource(MR.strings.pref_relative_format),
|
title = stringResource(MR.strings.pref_relative_format),
|
||||||
subtitle = stringResource(
|
subtitle = stringResource(
|
||||||
MR.strings.pref_relative_format_summary,
|
MR.strings.pref_relative_format_summary,
|
||||||
stringResource(MR.strings.relative_time_today),
|
stringResource(MR.strings.relative_time_today),
|
||||||
formattedNow,
|
formattedDateNow,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Preference.PreferenceItem.SwitchPreference(
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
|||||||
-5
@@ -43,11 +43,6 @@ object SettingsDownloadScreen : SearchableSettings {
|
|||||||
preference = downloadPreferences.saveChaptersAsCBZ,
|
preference = downloadPreferences.saveChaptersAsCBZ,
|
||||||
title = stringResource(MR.strings.save_chapter_as_cbz),
|
title = stringResource(MR.strings.save_chapter_as_cbz),
|
||||||
),
|
),
|
||||||
Preference.PreferenceItem.SwitchPreference(
|
|
||||||
preference = downloadPreferences.splitTallImages,
|
|
||||||
title = stringResource(MR.strings.split_tall_images),
|
|
||||||
subtitle = stringResource(MR.strings.split_tall_images_summary),
|
|
||||||
),
|
|
||||||
Preference.PreferenceItem.SliderPreference(
|
Preference.PreferenceItem.SliderPreference(
|
||||||
preference = downloadPreferences.parallelSourceLimit,
|
preference = downloadPreferences.parallelSourceLimit,
|
||||||
valueRange = 1..10,
|
valueRange = 1..10,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package eu.kanade.presentation.more.settings.screen.about
|
package eu.kanade.presentation.more.settings.screen.about
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.text.format.DateFormat
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -27,6 +28,7 @@ import eu.kanade.presentation.more.LogoHeader
|
|||||||
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
|
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
|
||||||
import eu.kanade.presentation.util.LocalBackPress
|
import eu.kanade.presentation.util.LocalBackPress
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
|
import eu.kanade.presentation.util.rememberIs24HourSystem
|
||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import eu.kanade.tachiyomi.data.updater.RELEASE_URL
|
import eu.kanade.tachiyomi.data.updater.RELEASE_URL
|
||||||
import eu.kanade.tachiyomi.ui.more.NewUpdateScreen
|
import eu.kanade.tachiyomi.ui.more.NewUpdateScreen
|
||||||
@@ -61,6 +63,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
|
|||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
object AboutScreen : Screen() {
|
object AboutScreen : Screen() {
|
||||||
@@ -74,6 +77,7 @@ object AboutScreen : Screen() {
|
|||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
var isCheckingUpdates by remember { mutableStateOf(false) }
|
var isCheckingUpdates by remember { mutableStateOf(false) }
|
||||||
val crashLogUtil = remember { context.appGraph.crashLogUtil }
|
val crashLogUtil = remember { context.appGraph.crashLogUtil }
|
||||||
|
val is24HourSystem = rememberIs24HourSystem()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { scrollBehavior ->
|
topBar = { scrollBehavior ->
|
||||||
@@ -96,7 +100,9 @@ object AboutScreen : Screen() {
|
|||||||
item {
|
item {
|
||||||
TextPreferenceWidget(
|
TextPreferenceWidget(
|
||||||
title = stringResource(MR.strings.version),
|
title = stringResource(MR.strings.version),
|
||||||
subtitle = getVersionName(withBuildDate = true),
|
// Keyed on the system 12/24h setting, which can change while the app is backgrounded.
|
||||||
|
// Date/time prefs only change in Appearance, which recreates this screen on return.
|
||||||
|
subtitle = remember(is24HourSystem) { getVersionName(withBuildDate = true) },
|
||||||
onPreferenceClick = {
|
onPreferenceClick = {
|
||||||
val deviceInfo = crashLogUtil.getDebugInfo()
|
val deviceInfo = crashLogUtil.getDebugInfo()
|
||||||
context.copyToClipboard("Debug information", deviceInfo)
|
context.copyToClipboard("Debug information", deviceInfo)
|
||||||
@@ -275,11 +281,16 @@ object AboutScreen : Screen() {
|
|||||||
|
|
||||||
internal fun getFormattedBuildTime(): String {
|
internal fun getFormattedBuildTime(): String {
|
||||||
return try {
|
return try {
|
||||||
|
val context = Injekt.get<Context>()
|
||||||
|
val uiPreferences = context.appGraph.uiPreferences
|
||||||
Instant.parse(BuildConfig.BUILD_TIME)
|
Instant.parse(BuildConfig.BUILD_TIME)
|
||||||
.toLocalDateTime(TimeZone.currentSystemDefault())
|
.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
.toDateTimestampString(
|
.toDateTimestampString(
|
||||||
UiPreferences.dateFormat(
|
UiPreferences.dateTimeFormat(
|
||||||
Injekt.get<Context>().appGraph.uiPreferences.dateFormat.get(),
|
uiPreferences.dateFormat.get(),
|
||||||
|
uiPreferences.timeFormat.get(),
|
||||||
|
Locale.getDefault(),
|
||||||
|
DateFormat.is24HourFormat(context),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
+5
-1
@@ -18,6 +18,7 @@ import eu.kanade.presentation.more.settings.Preference
|
|||||||
import eu.kanade.presentation.more.settings.PreferenceScaffold
|
import eu.kanade.presentation.more.settings.PreferenceScaffold
|
||||||
import eu.kanade.presentation.more.settings.screen.about.AboutScreen
|
import eu.kanade.presentation.more.settings.screen.about.AboutScreen
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
|
import eu.kanade.presentation.util.rememberIs24HourSystem
|
||||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||||
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
||||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||||
@@ -59,6 +60,7 @@ class DebugInfoScreen : Screen() {
|
|||||||
private fun getAppInfoGroup(): Preference.PreferenceGroup {
|
private fun getAppInfoGroup(): Preference.PreferenceGroup {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val is24HourSystem = rememberIs24HourSystem()
|
||||||
|
|
||||||
val installationIdPref = remember { context.appGraph.basePreferences.installationId }
|
val installationIdPref = remember { context.appGraph.basePreferences.installationId }
|
||||||
val installationId by installationIdPref.collectAsState()
|
val installationId by installationIdPref.collectAsState()
|
||||||
@@ -72,7 +74,9 @@ class DebugInfoScreen : Screen() {
|
|||||||
),
|
),
|
||||||
Preference.PreferenceItem.TextPreference(
|
Preference.PreferenceItem.TextPreference(
|
||||||
title = "Build time",
|
title = "Build time",
|
||||||
subtitle = AboutScreen.getFormattedBuildTime(),
|
// Keyed on the system 12/24h setting, which can change while the app is backgrounded.
|
||||||
|
// Date/time prefs only change in Appearance, which recreates this screen on return.
|
||||||
|
subtitle = remember(is24HourSystem) { AboutScreen.getFormattedBuildTime() },
|
||||||
),
|
),
|
||||||
Preference.PreferenceItem.TextPreference(
|
Preference.PreferenceItem.TextPreference(
|
||||||
title = "Installation ID",
|
title = "Installation ID",
|
||||||
|
|||||||
+50
-18
@@ -1,6 +1,7 @@
|
|||||||
package eu.kanade.presentation.more.settings.screen.debug
|
package eu.kanade.presentation.more.settings.screen.debug
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.text.format.DateFormat
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -9,6 +10,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -31,11 +33,13 @@ import eu.kanade.domain.ui.UiPreferences
|
|||||||
import eu.kanade.presentation.components.AppBar
|
import eu.kanade.presentation.components.AppBar
|
||||||
import eu.kanade.presentation.components.AppBarActions
|
import eu.kanade.presentation.components.AppBarActions
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
|
import eu.kanade.presentation.util.rememberIs24HourSystem
|
||||||
import eu.kanade.tachiyomi.util.lang.toDateTimestampString
|
import eu.kanade.tachiyomi.util.lang.toDateTimestampString
|
||||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||||
import eu.kanade.tachiyomi.util.system.workManager
|
import eu.kanade.tachiyomi.util.system.workManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
@@ -46,6 +50,8 @@ import tachiyomi.i18n.MR
|
|||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import tachiyomi.presentation.core.util.plus
|
import tachiyomi.presentation.core.util.plus
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class WorkerInfoScreen : Screen() {
|
class WorkerInfoScreen : Screen() {
|
||||||
@@ -60,6 +66,12 @@ class WorkerInfoScreen : Screen() {
|
|||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
|
||||||
val viewModel = metroViewModel<WorkerInfoViewModel>()
|
val viewModel = metroViewModel<WorkerInfoViewModel>()
|
||||||
|
|
||||||
|
val is24HourSystem = rememberIs24HourSystem()
|
||||||
|
// Keyed on the system 12/24h setting, which can change while the app is backgrounded.
|
||||||
|
// Date/time prefs only change in Appearance, which recreates this screen on return.
|
||||||
|
LaunchedEffect(is24HourSystem) { viewModel.onSystemTimeFormatChanged(is24HourSystem) }
|
||||||
|
|
||||||
val enqueued by viewModel.enqueued.collectAsState()
|
val enqueued by viewModel.enqueued.collectAsState()
|
||||||
val finished by viewModel.finished.collectAsState()
|
val finished by viewModel.finished.collectAsState()
|
||||||
val running by viewModel.running.collectAsState()
|
val running by viewModel.running.collectAsState()
|
||||||
@@ -124,29 +136,53 @@ class WorkerInfoScreen : Screen() {
|
|||||||
@ViewModelKey
|
@ViewModelKey
|
||||||
@ContributesIntoMap(AppScope::class)
|
@ContributesIntoMap(AppScope::class)
|
||||||
class WorkerInfoViewModel(
|
class WorkerInfoViewModel(
|
||||||
private val context: Context,
|
context: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val workManager = context.workManager
|
private val workManager = context.workManager
|
||||||
|
private val dateFormatPref = context.appGraph.uiPreferences.dateFormat
|
||||||
|
private val timeFormatPref = context.appGraph.uiPreferences.timeFormat
|
||||||
|
private val is24HourSystem = MutableStateFlow(DateFormat.is24HourFormat(context))
|
||||||
|
|
||||||
val finished = workManager
|
private val dateTimeFormatter = combine(
|
||||||
.getWorkInfosFlow(
|
dateFormatPref.changes(),
|
||||||
|
timeFormatPref.changes(),
|
||||||
|
is24HourSystem,
|
||||||
|
) { date, time, is24 ->
|
||||||
|
UiPreferences.dateTimeFormat(date, time, Locale.getDefault(), is24)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSystemTimeFormatChanged(value: Boolean) {
|
||||||
|
is24HourSystem.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
val finished = combine(
|
||||||
|
workManager.getWorkInfosFlow(
|
||||||
WorkQuery.fromStates(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED),
|
WorkQuery.fromStates(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED),
|
||||||
)
|
),
|
||||||
.map(::constructString)
|
dateTimeFormatter,
|
||||||
|
::constructString,
|
||||||
|
)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
|
||||||
|
|
||||||
val running = workManager
|
val running = combine(
|
||||||
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
|
workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING)),
|
||||||
.map(::constructString)
|
dateTimeFormatter,
|
||||||
|
::constructString,
|
||||||
|
)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
|
||||||
|
|
||||||
val enqueued = workManager
|
val enqueued = combine(
|
||||||
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED))
|
workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED)),
|
||||||
.map(::constructString)
|
dateTimeFormatter,
|
||||||
|
::constructString,
|
||||||
|
)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
|
||||||
|
|
||||||
private fun constructString(list: List<WorkInfo>) = buildString {
|
private fun constructString(
|
||||||
|
list: List<WorkInfo>,
|
||||||
|
dateTimeFormatter: DateTimeFormatter,
|
||||||
|
) = buildString {
|
||||||
if (list.isEmpty()) {
|
if (list.isEmpty()) {
|
||||||
appendLine("-")
|
appendLine("-")
|
||||||
} else {
|
} else {
|
||||||
@@ -160,11 +196,7 @@ class WorkerInfoScreen : Screen() {
|
|||||||
if (workInfo.state == WorkInfo.State.ENQUEUED) {
|
if (workInfo.state == WorkInfo.State.ENQUEUED) {
|
||||||
val timestamp = Instant.fromEpochMilliseconds(workInfo.nextScheduleTimeMillis)
|
val timestamp = Instant.fromEpochMilliseconds(workInfo.nextScheduleTimeMillis)
|
||||||
.toLocalDateTime(TimeZone.currentSystemDefault())
|
.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
.toDateTimestampString(
|
.toDateTimestampString(dateTimeFormatter)
|
||||||
UiPreferences.dateFormat(
|
|
||||||
context.appGraph.uiPreferences.dateFormat.get(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
appendLine("Next scheduled run: $timestamp")
|
appendLine("Next scheduled run: $timestamp")
|
||||||
appendLine("Attempt #${workInfo.runAttemptCount + 1}")
|
appendLine("Attempt #${workInfo.runAttemptCount + 1}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
|
|||||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
|
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
|
||||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
|
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
@@ -31,7 +30,6 @@ import java.text.NumberFormat
|
|||||||
internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
|
internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
|
||||||
HeadingItem(MR.strings.pref_category_for_this_series)
|
HeadingItem(MR.strings.pref_category_for_this_series)
|
||||||
val manga by viewModel.mangaFlow.collectAsState()
|
val manga by viewModel.mangaFlow.collectAsState()
|
||||||
val viewer by viewModel.viewerFlow.collectAsState()
|
|
||||||
|
|
||||||
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
|
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
|
||||||
SettingsChipRow(MR.strings.pref_category_reading_mode) {
|
SettingsChipRow(MR.strings.pref_category_reading_mode) {
|
||||||
@@ -44,62 +42,6 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewer is WebGpuViewer) {
|
|
||||||
val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get()
|
|
||||||
val resolved = ReadingMode.fromPreference(
|
|
||||||
when {
|
|
||||||
readingMode == ReadingMode.DEFAULT -> default
|
|
||||||
else -> manga?.readingMode?.toInt() ?: default
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) {
|
|
||||||
val dualPageView by viewModel.preferences.dualPageView.collectAsState()
|
|
||||||
SettingsChipRow(MR.strings.pref_dual_page_view) {
|
|
||||||
ReaderPreferences.DualPageView.entries.map {
|
|
||||||
FilterChip(
|
|
||||||
selected = it == dualPageView,
|
|
||||||
onClick = { viewModel.preferences.dualPageView.set(it) },
|
|
||||||
label = { Text(stringResource(it.titleRes)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolved == ReadingMode.WEBTOON || resolved == ReadingMode.CONTINUOUS_VERTICAL) {
|
|
||||||
val numberFormat = remember { NumberFormat.getPercentInstance() }
|
|
||||||
val continuousMinWidth by viewModel.preferences.continuousMinWidth.collectAsState()
|
|
||||||
SliderItem(
|
|
||||||
value = continuousMinWidth,
|
|
||||||
valueRange = ReaderPreferences.let { 1..100 },
|
|
||||||
label = stringResource(MR.strings.pref_continuous_minwidth),
|
|
||||||
valueString = numberFormat.format(continuousMinWidth / 100f),
|
|
||||||
onChange = {
|
|
||||||
viewModel.preferences.continuousMinWidth.set(it)
|
|
||||||
},
|
|
||||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (resolved == ReadingMode.CONTINUOUS_VERTICAL) {
|
|
||||||
val continuousGap by viewModel.preferences.continuousGap.collectAsState()
|
|
||||||
SliderItem(
|
|
||||||
value = continuousGap,
|
|
||||||
valueRange = ReaderPreferences.let { 1..100 },
|
|
||||||
label = stringResource(MR.strings.pref_continuous_gap),
|
|
||||||
valueString = numberFormat.format(continuousGap / 100f),
|
|
||||||
onChange = {
|
|
||||||
viewModel.preferences.continuousGap.set(it)
|
|
||||||
},
|
|
||||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
CheckboxItem(
|
|
||||||
label = stringResource(MR.strings.pref_webtoon_disable_zoom_out),
|
|
||||||
pref = viewModel.preferences.webtoonDisableZoomOut,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val orientation = remember(manga) { ReaderOrientation.fromPreference(manga?.readerOrientation?.toInt()) }
|
val orientation = remember(manga) { ReaderOrientation.fromPreference(manga?.readerOrientation?.toInt()) }
|
||||||
SettingsChipRow(MR.strings.rotation_type) {
|
SettingsChipRow(MR.strings.rotation_type) {
|
||||||
ReaderOrientation.entries.map {
|
ReaderOrientation.entries.map {
|
||||||
@@ -111,10 +53,11 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (viewer) {
|
val viewer by viewModel.viewerFlow.collectAsState()
|
||||||
is WebtoonViewer -> WebtoonViewerSettings(viewModel)
|
if (viewer is WebtoonViewer) {
|
||||||
is WebGpuViewer -> WebGpuViewerSettings(viewModel)
|
WebtoonViewerSettings(viewModel)
|
||||||
else -> PagerViewerSettings(viewModel)
|
} else {
|
||||||
|
PagerViewerSettings(viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,120 +235,3 @@ private fun ColumnScope.TapZonesItems(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) {
|
|
||||||
HeadingItem(MR.strings.webgpu_viewer)
|
|
||||||
|
|
||||||
val manga by viewModel.mangaFlow.collectAsState()
|
|
||||||
val viewer by viewModel.viewerFlow.collectAsState()
|
|
||||||
|
|
||||||
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
|
|
||||||
val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get()
|
|
||||||
val resolved = ReadingMode.fromPreference(
|
|
||||||
when {
|
|
||||||
readingMode == ReadingMode.DEFAULT -> default
|
|
||||||
else -> manga?.readingMode?.toInt() ?: default
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val isDual = (viewer as? WebGpuViewer)?.isDualPageMode() == true
|
|
||||||
|
|
||||||
val navigationModePager by viewModel.preferences.navigationModePager.collectAsState()
|
|
||||||
val pagerNavInverted by viewModel.preferences.pagerNavInverted.collectAsState()
|
|
||||||
TapZonesItems(
|
|
||||||
selected = navigationModePager,
|
|
||||||
onSelect = viewModel.preferences.navigationModePager::set,
|
|
||||||
invertMode = pagerNavInverted,
|
|
||||||
onSelectInvertMode = viewModel.preferences.pagerNavInverted::set,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isDual) {
|
|
||||||
val transitionAnimation by viewModel.preferences.transitionAnimationDual.collectAsState()
|
|
||||||
SettingsChipRow(MR.strings.pref_transition_animation_dual) {
|
|
||||||
(
|
|
||||||
ReaderPreferences.TransitionAnimation.entries - ReaderPreferences.TransitionAnimation.FLIP_LEFT -
|
|
||||||
ReaderPreferences.TransitionAnimation.FLIP_RIGHT
|
|
||||||
).forEach {
|
|
||||||
FilterChip(
|
|
||||||
selected = it == transitionAnimation,
|
|
||||||
onClick = { viewModel.preferences.transitionAnimationDual.set(it) },
|
|
||||||
label = { Text(stringResource(it.titleRes)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val cutoutMode by viewModel.preferences.cutoutModeDual.collectAsState()
|
|
||||||
SettingsChipRow(MR.strings.pref_cutout_mode_dual) {
|
|
||||||
ReaderPreferences.CutoutMode.entries.forEach {
|
|
||||||
FilterChip(
|
|
||||||
selected = it == cutoutMode,
|
|
||||||
onClick = { viewModel.preferences.cutoutModeDual.set(it) },
|
|
||||||
label = { Text(stringResource(it.titleRes)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolved != ReadingMode.WEBTOON && resolved != ReadingMode.CONTINUOUS_VERTICAL) {
|
|
||||||
val imageScaleType by viewModel.preferences.imageScaleType.collectAsState()
|
|
||||||
SettingsChipRow(MR.strings.pref_image_scale_type) {
|
|
||||||
ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach {
|
|
||||||
FilterChip(
|
|
||||||
selected = ReaderPreferences.ImageScaleType[imageScaleType - 1] == it,
|
|
||||||
onClick = {
|
|
||||||
viewModel.preferences.imageScaleType.set(ReaderPreferences.ImageScaleType.indexOf(it) + 1)
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(it)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val zoomStart by viewModel.preferences.zoomStart.collectAsState()
|
|
||||||
SettingsChipRow(MR.strings.pref_zoom_start) {
|
|
||||||
ReaderPreferences.ZoomStart.mapIndexed { index, it ->
|
|
||||||
FilterChip(
|
|
||||||
selected = zoomStart == index + 1,
|
|
||||||
onClick = { viewModel.preferences.zoomStart.set(index + 1) },
|
|
||||||
label = { Text(stringResource(it)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CheckboxItem(
|
|
||||||
label = stringResource(MR.strings.pref_crop_borders),
|
|
||||||
pref = viewModel.preferences.cropBorders,
|
|
||||||
)
|
|
||||||
|
|
||||||
CheckboxItem(
|
|
||||||
label = stringResource(MR.strings.pref_landscape_zoom),
|
|
||||||
pref = viewModel.preferences.landscapeZoom,
|
|
||||||
)
|
|
||||||
|
|
||||||
CheckboxItem(
|
|
||||||
label = stringResource(MR.strings.pref_navigate_pan),
|
|
||||||
pref = viewModel.preferences.navigateToPan,
|
|
||||||
)
|
|
||||||
|
|
||||||
val transitionAnimation by viewModel.preferences.transitionAnimation.collectAsState()
|
|
||||||
SettingsChipRow(MR.strings.pref_transition_animation) {
|
|
||||||
ReaderPreferences.TransitionAnimation.entries.forEach {
|
|
||||||
FilterChip(
|
|
||||||
selected = it == transitionAnimation,
|
|
||||||
onClick = { viewModel.preferences.transitionAnimation.set(it) },
|
|
||||||
label = { Text(stringResource(it.titleRes)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val cutoutMode by viewModel.preferences.cutoutMode.collectAsState()
|
|
||||||
SettingsChipRow(MR.strings.pref_cutout_mode) {
|
|
||||||
ReaderPreferences.CutoutMode.entries.forEach {
|
|
||||||
FilterChip(
|
|
||||||
selected = it == cutoutMode,
|
|
||||||
onClick = { viewModel.preferences.cutoutMode.set(it) },
|
|
||||||
label = { Text(stringResource(it.titleRes)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package eu.kanade.presentation.util
|
||||||
|
|
||||||
|
import android.text.format.DateFormat
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberIs24HourSystem(): Boolean {
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
var is24 by remember { mutableStateOf(DateFormat.is24HourFormat(context)) }
|
||||||
|
DisposableEffect(lifecycleOwner.lifecycle) {
|
||||||
|
val observer = object : DefaultLifecycleObserver {
|
||||||
|
override fun onResume(owner: LifecycleOwner) {
|
||||||
|
is24 = DateFormat.is24HourFormat(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return is24
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
import cafe.adriel.voyager.core.stack.mutableStateStackOf
|
import cafe.adriel.voyager.core.stack.mutableStateStackOf
|
||||||
import com.kevinnzou.web.AccompanistWebChromeClient
|
import com.kevinnzou.web.AccompanistWebChromeClient
|
||||||
import com.kevinnzou.web.AccompanistWebViewClient
|
import com.kevinnzou.web.AccompanistWebViewClient
|
||||||
@@ -108,6 +109,8 @@ fun WebViewScreenContent(
|
|||||||
onDispose { isActive = false }
|
onDispose { isActive = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val headersHost = remember { url.toUri().host }
|
||||||
|
|
||||||
val webClient = remember {
|
val webClient = remember {
|
||||||
object : AccompanistWebViewClient() {
|
object : AccompanistWebViewClient() {
|
||||||
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
|
||||||
@@ -147,6 +150,9 @@ fun WebViewScreenContent(
|
|||||||
// Ignore intents urls
|
// Ignore intents urls
|
||||||
if (url.startsWith("intent://")) return true
|
if (url.startsWith("intent://")) return true
|
||||||
|
|
||||||
|
// Only send source headers to the source itself
|
||||||
|
if (request.url.host != headersHost) return false
|
||||||
|
|
||||||
// Only open valid web urls
|
// Only open valid web urls
|
||||||
if (url.startsWith("http") || url.startsWith("https")) {
|
if (url.startsWith("http") || url.startsWith("https")) {
|
||||||
if (url != view?.url) {
|
if (url != view?.url) {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
|
|||||||
import eu.kanade.tachiyomi.network.NetworkPreferences
|
import eu.kanade.tachiyomi.network.NetworkPreferences
|
||||||
import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate
|
import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate
|
||||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||||
|
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||||
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
||||||
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
||||||
import eu.kanade.tachiyomi.util.system.cancelNotification
|
import eu.kanade.tachiyomi.util.system.cancelNotification
|
||||||
@@ -68,6 +69,7 @@ import org.conscrypt.Conscrypt
|
|||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.preference.Preference
|
import tachiyomi.core.common.preference.Preference
|
||||||
import tachiyomi.core.common.preference.PreferenceStore
|
import tachiyomi.core.common.preference.PreferenceStore
|
||||||
|
import tachiyomi.core.common.util.system.ImageUtil
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.source.service.SourceManager
|
import tachiyomi.domain.source.service.SourceManager
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
@@ -184,6 +186,14 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
|
|||||||
.onEach(TelemetryConfig::setCrashlyticsEnabled)
|
.onEach(TelemetryConfig::setCrashlyticsEnabled)
|
||||||
.launchIn(scope)
|
.launchIn(scope)
|
||||||
|
|
||||||
|
basePreferences.hardwareBitmapThreshold.let { preference ->
|
||||||
|
if (!preference.isSet()) preference.set(GLUtil.DEVICE_TEXTURE_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
basePreferences.hardwareBitmapThreshold.changes()
|
||||||
|
.onEach { ImageUtil.hardwareBitmapThreshold = it }
|
||||||
|
.launchIn(scope)
|
||||||
|
|
||||||
setAppCompatDelegateThemeMode(uiPreferences.themeMode.get())
|
setAppCompatDelegateThemeMode(uiPreferences.themeMode.get())
|
||||||
|
|
||||||
// Updates widget update
|
// Updates widget update
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import android.content.Context
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||||
import kotlinx.serialization.SerializationException
|
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.serialization.DeserializationStrategy
|
||||||
import kotlinx.serialization.protobuf.ProtoBuf
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
|
import okio.BufferedSource
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.gzip
|
import okio.gzip
|
||||||
import okio.source
|
import okio.source
|
||||||
@@ -19,9 +23,48 @@ class BackupDecoder(
|
|||||||
private val parser: ProtoBuf,
|
private val parser: ProtoBuf,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Decode a potentially-gzipped backup.
|
* Decode everything but the entries of a potentially-gzipped backup, counting the entries without decoding them.
|
||||||
*/
|
*/
|
||||||
fun decode(uri: Uri): Backup {
|
fun decodeMetadata(uri: Uri): Metadata {
|
||||||
|
return read(uri) { reader ->
|
||||||
|
orInvalid {
|
||||||
|
var entryCount = 0
|
||||||
|
while (reader.nextEntry()) {
|
||||||
|
reader.skipEntry()
|
||||||
|
entryCount++
|
||||||
|
}
|
||||||
|
Metadata(entryCount, reader.readMetadata())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like [decodeMetadata], but hands each entry to [onEntry] decoded as [entry], which can declare only the fields
|
||||||
|
* it needs so the rest are skipped.
|
||||||
|
*/
|
||||||
|
fun <T> decodeMetadata(uri: Uri, entry: DeserializationStrategy<T>, onEntry: (T) -> Unit): Metadata {
|
||||||
|
return read(uri) { reader ->
|
||||||
|
var entryCount = 0
|
||||||
|
while (orInvalid { reader.nextEntry() }) {
|
||||||
|
onEntry(orInvalid { reader.readEntry(entry) })
|
||||||
|
entryCount++
|
||||||
|
}
|
||||||
|
Metadata(entryCount, orInvalid { reader.readMetadata() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the entries of a potentially-gzipped backup one at a time.
|
||||||
|
*/
|
||||||
|
fun decodeEntries(uri: Uri): Flow<BackupManga> = flow {
|
||||||
|
read(uri) { reader ->
|
||||||
|
while (orInvalid { reader.nextEntry() }) {
|
||||||
|
emit(orInvalid { reader.readEntry() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <T> read(uri: Uri, block: (BackupReader) -> T): T {
|
||||||
return context.contentResolver.openInputStream(uri)!!.use { inputStream ->
|
return context.contentResolver.openInputStream(uri)!!.use { inputStream ->
|
||||||
val source = inputStream.source().buffer()
|
val source = inputStream.source().buffer()
|
||||||
|
|
||||||
@@ -29,22 +72,32 @@ class BackupDecoder(
|
|||||||
require(2)
|
require(2)
|
||||||
}
|
}
|
||||||
val id1id2 = peeked.readShort()
|
val id1id2 = peeked.readShort()
|
||||||
val backupString = when (id1id2.toInt()) {
|
val backupSource: BufferedSource = when (id1id2.toInt()) {
|
||||||
0x1f8b -> source.gzip().buffer() // 0x1f8b is gzip magic bytes
|
0x1f8b -> source.gzip().buffer() // 0x1f8b is gzip magic bytes
|
||||||
MAGIC_JSON_SIGNATURE1, MAGIC_JSON_SIGNATURE2, MAGIC_JSON_SIGNATURE3 -> {
|
MAGIC_JSON_SIGNATURE1, MAGIC_JSON_SIGNATURE2, MAGIC_JSON_SIGNATURE3 -> {
|
||||||
throw IOException(context.stringResource(MR.strings.invalid_backup_file_json))
|
throw IOException(context.stringResource(MR.strings.invalid_backup_file_json))
|
||||||
}
|
}
|
||||||
else -> source
|
else -> source
|
||||||
}.use { it.readByteArray() }
|
|
||||||
|
|
||||||
try {
|
|
||||||
parser.decodeFromByteArray(Backup.serializer(), backupString)
|
|
||||||
} catch (_: SerializationException) {
|
|
||||||
throw IOException(context.stringResource(MR.strings.invalid_backup_file_unknown))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
backupSource.use { block(BackupReader(it, parser)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only around reading, so the JSON error above and anything thrown downstream of emit keep their own messages
|
||||||
|
private inline fun <T> orInvalid(block: () -> T): T {
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw IOException(context.stringResource(MR.strings.invalid_backup_file_unknown), e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Metadata(
|
||||||
|
val entryCount: Int,
|
||||||
|
val backup: Backup,
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAGIC_JSON_SIGNATURE1 = 0x7b7d // `{}`
|
private const val MAGIC_JSON_SIGNATURE1 = 0x7b7d // `{}`
|
||||||
private const val MAGIC_JSON_SIGNATURE2 = 0x7b22 // `{"`
|
private const val MAGIC_JSON_SIGNATURE2 = 0x7b22 // `{"`
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package eu.kanade.tachiyomi.data.backup
|
|||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.BackupTracking
|
||||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.protobuf.ProtoNumber
|
||||||
import tachiyomi.domain.source.service.SourceManager
|
import tachiyomi.domain.source.service.SourceManager
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -18,8 +21,11 @@ class BackupFileValidator(
|
|||||||
* @return List of missing sources or missing trackers.
|
* @return List of missing sources or missing trackers.
|
||||||
*/
|
*/
|
||||||
suspend fun validate(uri: Uri): Results {
|
suspend fun validate(uri: Uri): Results {
|
||||||
|
val trackerIds = mutableSetOf<Int>()
|
||||||
val backup = try {
|
val backup = try {
|
||||||
backupDecoder.decode(uri)
|
backupDecoder.decodeMetadata(uri, TrackedManga.serializer()) { manga ->
|
||||||
|
manga.tracking.mapTo(trackerIds) { it.syncId }
|
||||||
|
}.backup
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException(e)
|
throw IllegalStateException(e)
|
||||||
}
|
}
|
||||||
@@ -38,11 +44,7 @@ class BackupFileValidator(
|
|||||||
.distinct()
|
.distinct()
|
||||||
.sorted()
|
.sorted()
|
||||||
|
|
||||||
val trackers = backup.backupManga
|
val missingTrackers = trackerIds
|
||||||
.flatMap { it.tracking }
|
|
||||||
.map { it.syncId }
|
|
||||||
.distinct()
|
|
||||||
val missingTrackers = trackers
|
|
||||||
.mapNotNull { trackerManager.get(it.toLong()) }
|
.mapNotNull { trackerManager.get(it.toLong()) }
|
||||||
.filter { !it.isLoggedIn }
|
.filter { !it.isLoggedIn }
|
||||||
.map { it.name }
|
.map { it.name }
|
||||||
@@ -55,4 +57,10 @@ class BackupFileValidator(
|
|||||||
val missingSources: List<String>,
|
val missingSources: List<String>,
|
||||||
val missingTrackers: List<String>,
|
val missingTrackers: List<String>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Only the field validation reads, so decoding skips the rest of each entry
|
||||||
|
@Serializable
|
||||||
|
private class TrackedManga(
|
||||||
|
@ProtoNumber(18) val tracking: List<BackupTracking> = emptyList(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||||
|
import kotlinx.serialization.DeserializationStrategy
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
|
import okio.Buffer
|
||||||
|
import okio.BufferedSink
|
||||||
|
import okio.BufferedSource
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the same bytes as encoding a whole [Backup], without holding all of its entries at once.
|
||||||
|
*/
|
||||||
|
class BackupWriter(
|
||||||
|
private val sink: BufferedSink,
|
||||||
|
private val parser: ProtoBuf,
|
||||||
|
) {
|
||||||
|
var isEmpty = true
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun writeEntry(entry: BackupManga) {
|
||||||
|
val bytes = parser.encodeToByteArray(BackupManga.serializer(), entry)
|
||||||
|
sink.writeVarint(ENTRY_TAG)
|
||||||
|
sink.writeVarint(bytes.size.toLong())
|
||||||
|
sink.write(bytes)
|
||||||
|
isEmpty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the other fields after the entries, as [metadata] without entries of its own.
|
||||||
|
*/
|
||||||
|
fun writeMetadata(metadata: Backup) {
|
||||||
|
val bytes = parser.encodeToByteArray(Backup.serializer(), metadata)
|
||||||
|
sink.write(bytes)
|
||||||
|
if (bytes.isNotEmpty()) isEmpty = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads an encoded [Backup] one entry at a time. Every other field is set aside for [readMetadata], wherever it
|
||||||
|
* appears.
|
||||||
|
*
|
||||||
|
* Malformed or truncated input throws an [IOException].
|
||||||
|
*/
|
||||||
|
class BackupReader(
|
||||||
|
private val source: BufferedSource,
|
||||||
|
private val parser: ProtoBuf,
|
||||||
|
) {
|
||||||
|
private val metadata = Buffer()
|
||||||
|
private var entryLength = NO_ENTRY
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves to the next entry, which [readEntry] or [skipEntry] then consumes.
|
||||||
|
*
|
||||||
|
* @return false once the input is exhausted.
|
||||||
|
*/
|
||||||
|
fun nextEntry(): Boolean {
|
||||||
|
check(entryLength == NO_ENTRY)
|
||||||
|
while (!source.exhausted()) {
|
||||||
|
val tag = source.readVarint()
|
||||||
|
val fieldNumber = tag ushr 3
|
||||||
|
val wireType = (tag and 0x7).toInt()
|
||||||
|
if (fieldNumber == 0L) throw IOException("Invalid field number 0")
|
||||||
|
|
||||||
|
if (tag == ENTRY_TAG) {
|
||||||
|
entryLength = source.readLength()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.writeVarint(tag)
|
||||||
|
when (wireType) {
|
||||||
|
WIRE_TYPE_VARINT -> metadata.writeVarint(source.readVarint())
|
||||||
|
WIRE_TYPE_FIXED64 -> metadata.write(source, 8)
|
||||||
|
WIRE_TYPE_LENGTH_DELIMITED -> {
|
||||||
|
val length = source.readLength()
|
||||||
|
metadata.writeVarint(length)
|
||||||
|
metadata.write(source, length)
|
||||||
|
}
|
||||||
|
WIRE_TYPE_FIXED32 -> metadata.write(source, 4)
|
||||||
|
else -> throw IOException("Unsupported wire type $wireType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readEntry(): BackupManga = readEntry(BackupManga.serializer())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the entry as [deserializer], which can declare only the fields it needs so the rest are skipped.
|
||||||
|
*/
|
||||||
|
fun <T> readEntry(deserializer: DeserializationStrategy<T>): T {
|
||||||
|
val bytes = source.readByteArray(takeEntryLength())
|
||||||
|
return decode(deserializer, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipEntry() {
|
||||||
|
source.skip(takeEntryLength())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skips the remaining entries and decodes everything else as a [Backup] without entries.
|
||||||
|
*/
|
||||||
|
fun readMetadata(): Backup {
|
||||||
|
if (entryLength != NO_ENTRY) skipEntry()
|
||||||
|
while (nextEntry()) skipEntry()
|
||||||
|
return decode(Backup.serializer(), metadata.readByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun takeEntryLength(): Long {
|
||||||
|
check(entryLength != NO_ENTRY)
|
||||||
|
return entryLength.also { entryLength = NO_ENTRY }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> decode(deserializer: DeserializationStrategy<T>, bytes: ByteArray): T {
|
||||||
|
return try {
|
||||||
|
parser.decodeFromByteArray(deserializer, bytes)
|
||||||
|
} catch (e: SerializationException) {
|
||||||
|
throw IOException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BufferedSource.readVarint(): Long {
|
||||||
|
var result = 0L
|
||||||
|
for (shift in 0 until Long.SIZE_BITS step 7) {
|
||||||
|
val byte = readByte().toInt()
|
||||||
|
result = result or ((byte and 0x7F).toLong() shl shift)
|
||||||
|
if ((byte and 0x80) == 0) return result
|
||||||
|
}
|
||||||
|
throw IOException("Malformed varint")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BufferedSource.readLength(): Long {
|
||||||
|
val length = readVarint()
|
||||||
|
if (length !in 0..Int.MAX_VALUE) throw IOException("Invalid length $length")
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BufferedSink.writeVarint(value: Long) {
|
||||||
|
var remaining = value
|
||||||
|
while ((remaining and 0x7FL.inv()) != 0L) {
|
||||||
|
writeByte((remaining and 0x7F).toInt() or 0x80)
|
||||||
|
remaining = remaining ushr 7
|
||||||
|
}
|
||||||
|
writeByte(remaining.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val WIRE_TYPE_VARINT = 0
|
||||||
|
private const val WIRE_TYPE_FIXED64 = 1
|
||||||
|
private const val WIRE_TYPE_LENGTH_DELIMITED = 2
|
||||||
|
private const val WIRE_TYPE_FIXED32 = 5
|
||||||
|
|
||||||
|
// Backup.backupManga is field 1, and each entry is length-delimited
|
||||||
|
private const val ENTRY_TAG = 1L shl 3 or WIRE_TYPE_LENGTH_DELIMITED.toLong()
|
||||||
|
|
||||||
|
private const val NO_ENTRY = -1L
|
||||||
@@ -8,6 +8,7 @@ import dev.zacsweers.metro.AssistedFactory
|
|||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
|
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
|
||||||
|
import eu.kanade.tachiyomi.data.backup.BackupWriter
|
||||||
import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
|
import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
|
||||||
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator
|
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator
|
||||||
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
|
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
|
||||||
@@ -20,6 +21,8 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
|||||||
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
|
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
|
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.serialization.protobuf.ProtoBuf
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
@@ -85,27 +88,31 @@ class BackupCreator(
|
|||||||
val nonFavoriteManga = if (options.readEntries) mangaRepository.getReadMangaNotInLibrary() else emptyList()
|
val nonFavoriteManga = if (options.readEntries) mangaRepository.getReadMangaNotInLibrary() else emptyList()
|
||||||
val backupManga = backupMangas(getFavorites.await() + nonFavoriteManga, options)
|
val backupManga = backupMangas(getFavorites.await() + nonFavoriteManga, options)
|
||||||
|
|
||||||
val backup = Backup(
|
|
||||||
backupManga = backupManga,
|
|
||||||
backupCategories = backupCategories(options),
|
|
||||||
backupSources = backupSources(backupManga),
|
|
||||||
backupPreferences = backupAppPreferences(options),
|
|
||||||
backupExtensionStores = backupExtensionStores(options),
|
|
||||||
backupSourcePreferences = backupSourcePreferences(options),
|
|
||||||
)
|
|
||||||
|
|
||||||
val byteArray = parser.encodeToByteArray(Backup.serializer(), backup)
|
|
||||||
if (byteArray.isEmpty()) {
|
|
||||||
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
|
|
||||||
}
|
|
||||||
|
|
||||||
file.openOutputStream()
|
file.openOutputStream()
|
||||||
.also {
|
.also {
|
||||||
// Force overwrite old file
|
// Force overwrite old file
|
||||||
(it as? FileOutputStream)?.channel?.truncate(0)
|
(it as? FileOutputStream)?.channel?.truncate(0)
|
||||||
}
|
}
|
||||||
.sink().gzip().buffer().use {
|
.sink().gzip().buffer().use {
|
||||||
it.write(byteArray)
|
val writer = BackupWriter(it, parser)
|
||||||
|
val sourceIds = mutableSetOf<Long>()
|
||||||
|
backupManga.collect { manga ->
|
||||||
|
writer.writeEntry(manga)
|
||||||
|
sourceIds += manga.source
|
||||||
|
}
|
||||||
|
writer.writeMetadata(
|
||||||
|
Backup(
|
||||||
|
backupManga = emptyList(),
|
||||||
|
backupCategories = backupCategories(options),
|
||||||
|
backupSources = backupSources(sourceIds),
|
||||||
|
backupPreferences = backupAppPreferences(options),
|
||||||
|
backupExtensionStores = backupExtensionStores(options),
|
||||||
|
backupSourcePreferences = backupSourcePreferences(options),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (writer.isEmpty) {
|
||||||
|
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val fileUri = file.uri
|
val fileUri = file.uri
|
||||||
|
|
||||||
@@ -130,14 +137,14 @@ class BackupCreator(
|
|||||||
return categoriesBackupCreator()
|
return categoriesBackupCreator()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
|
private fun backupMangas(mangas: List<Manga>, options: BackupOptions): Flow<BackupManga> {
|
||||||
if (!options.libraryEntries) return emptyList()
|
if (!options.libraryEntries) return emptyFlow()
|
||||||
|
|
||||||
return mangaBackupCreator(mangas, options)
|
return mangaBackupCreator(mangas, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun backupSources(mangas: List<BackupManga>): List<BackupSource> {
|
private suspend fun backupSources(sourceIds: Set<Long>): List<BackupSource> {
|
||||||
return sourcesBackupCreator(mangas)
|
return sourcesBackupCreator(sourceIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
|
private fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
|
||||||
|
|||||||
+50
-39
@@ -7,11 +7,13 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
|||||||
import eu.kanade.tachiyomi.data.backup.models.toBackupChapter
|
import eu.kanade.tachiyomi.data.backup.models.toBackupChapter
|
||||||
import eu.kanade.tachiyomi.data.backup.models.toBackupTracking
|
import eu.kanade.tachiyomi.data.backup.models.toBackupTracking
|
||||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import mihon.core.common.extensions.toByteArray
|
import mihon.core.common.extensions.toByteArray
|
||||||
import mihon.core.common.extensions.toEpochMillisOrZero
|
import mihon.core.common.extensions.toEpochMillisOrZero
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.repository.CategoryRepository
|
||||||
import tachiyomi.domain.chapter.repository.ChapterRepository
|
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||||
import tachiyomi.domain.history.interactor.GetHistory
|
import tachiyomi.domain.history.repository.HistoryRepository
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
import tachiyomi.domain.manga.repository.MangaRepository
|
import tachiyomi.domain.manga.repository.MangaRepository
|
||||||
import tachiyomi.domain.track.repository.TrackRepository
|
import tachiyomi.domain.track.repository.TrackRepository
|
||||||
@@ -20,62 +22,71 @@ import tachiyomi.domain.track.repository.TrackRepository
|
|||||||
class MangaBackupCreator(
|
class MangaBackupCreator(
|
||||||
private val mangaRepository: MangaRepository,
|
private val mangaRepository: MangaRepository,
|
||||||
private val chapterRepository: ChapterRepository,
|
private val chapterRepository: ChapterRepository,
|
||||||
|
private val categoryRepository: CategoryRepository,
|
||||||
|
private val historyRepository: HistoryRepository,
|
||||||
private val trackRepository: TrackRepository,
|
private val trackRepository: TrackRepository,
|
||||||
private val getCategories: GetCategories,
|
|
||||||
private val getHistory: GetHistory,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend operator fun invoke(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
|
/**
|
||||||
return mangas.map {
|
* Builds the entries a chunk at a time, so only one chunk's chapters are held at once.
|
||||||
backupManga(it, options)
|
*/
|
||||||
|
operator fun invoke(mangas: List<Manga>, options: BackupOptions): Flow<BackupManga> = flow {
|
||||||
|
mangas.chunked(CHUNK_SIZE).forEach { chunk ->
|
||||||
|
backupMangas(chunk, options).forEach { emit(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun backupManga(manga: Manga, options: BackupOptions): BackupManga {
|
private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
|
||||||
// Entry for this manga
|
val mangaIds = mangas.map { it.id }
|
||||||
val mangaObject = manga.toBackupManga()
|
|
||||||
|
|
||||||
mangaObject.excludedScanlators = mangaRepository.getExcludedScanlators(manga.id).toList()
|
val excludedScanlatorsByMangaId = mangaRepository.getExcludedScanlatorsByMangaIds(mangaIds)
|
||||||
|
// History is backed up by chapter URL, so it needs the chapters too
|
||||||
if (options.chapters) {
|
val chaptersByMangaId = if (options.chapters || options.history) {
|
||||||
// Backup all the chapters
|
chapterRepository.getChaptersByMangaIds(mangaIds)
|
||||||
chapterRepository.getChapterByMangaId(manga.id, applyScanlatorFilter = false)
|
} else {
|
||||||
.map { it.toBackupChapter() }
|
emptyMap()
|
||||||
.takeUnless { it.isEmpty() }
|
|
||||||
?.let { mangaObject.chapters = it }
|
|
||||||
}
|
}
|
||||||
|
val categoriesByMangaId = if (options.categories) {
|
||||||
|
categoryRepository.getCategoriesByMangaIds(mangaIds)
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
val tracksByMangaId = if (options.tracking) trackRepository.getTracksByMangaIds(mangaIds) else emptyMap()
|
||||||
|
val historyByMangaId = if (options.history) historyRepository.getHistoryByMangaIds(mangaIds) else emptyMap()
|
||||||
|
|
||||||
if (options.categories) {
|
return mangas.map { manga ->
|
||||||
// Backup categories for this manga
|
val mangaObject = manga.toBackupManga()
|
||||||
val categoriesForManga = getCategories.await(manga.id)
|
val chapters = chaptersByMangaId[manga.id].orEmpty()
|
||||||
if (categoriesForManga.isNotEmpty()) {
|
|
||||||
mangaObject.categories = categoriesForManga.map { it.order }
|
mangaObject.excludedScanlators = excludedScanlatorsByMangaId[manga.id].orEmpty()
|
||||||
|
|
||||||
|
if (options.chapters) {
|
||||||
|
mangaObject.chapters = chapters.map { it.toBackupChapter() }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (options.tracking) {
|
if (options.categories) {
|
||||||
val tracks = trackRepository.getTracksByMangaId(manga.id).map { it.toBackupTracking() }
|
mangaObject.categories = categoriesByMangaId[manga.id].orEmpty().map { it.order }
|
||||||
if (tracks.isNotEmpty()) {
|
|
||||||
mangaObject.tracking = tracks
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (options.history) {
|
if (options.tracking) {
|
||||||
val historyByMangaId = getHistory.await(manga.id)
|
mangaObject.tracking = tracksByMangaId[manga.id].orEmpty().map { it.toBackupTracking() }
|
||||||
if (historyByMangaId.isNotEmpty()) {
|
}
|
||||||
val chapterUrlsById = chapterRepository.getChapterByMangaId(manga.id).associate { it.id to it.url }
|
|
||||||
val history = historyByMangaId.mapNotNull { history ->
|
if (options.history) {
|
||||||
|
val chapterUrlsById = chapters.associate { it.id to it.url }
|
||||||
|
mangaObject.history = historyByMangaId[manga.id].orEmpty().mapNotNull { history ->
|
||||||
// A chapter removed since its history was read takes that history with it
|
// A chapter removed since its history was read takes that history with it
|
||||||
val url = chapterUrlsById[history.chapterId] ?: return@mapNotNull null
|
val url = chapterUrlsById[history.chapterId] ?: return@mapNotNull null
|
||||||
BackupHistory(url, history.readAt.toEpochMillisOrZero(), history.readDuration)
|
BackupHistory(url, history.readAt.toEpochMillisOrZero(), history.readDuration)
|
||||||
}
|
}
|
||||||
if (history.isNotEmpty()) {
|
|
||||||
mangaObject.history = history
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return mangaObject
|
mangaObject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CHUNK_SIZE = 100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-6
@@ -1,7 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.backup.create.creators
|
package eu.kanade.tachiyomi.data.backup.create.creators
|
||||||
|
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import tachiyomi.domain.source.service.SourceManager
|
import tachiyomi.domain.source.service.SourceManager
|
||||||
@@ -11,11 +10,8 @@ class SourcesBackupCreator(
|
|||||||
private val sourceManager: SourceManager,
|
private val sourceManager: SourceManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend operator fun invoke(mangas: List<BackupManga>): List<BackupSource> {
|
suspend operator fun invoke(sourceIds: Set<Long>): List<BackupSource> {
|
||||||
return mangas
|
return sourceIds.map { sourceManager.getOrStub(it).toBackupSource() }
|
||||||
.map(BackupManga::source)
|
|
||||||
.distinct()
|
|
||||||
.map { sourceManager.getOrStub(it).toBackupSource() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import eu.kanade.tachiyomi.data.backup.BackupDecoder
|
|||||||
import eu.kanade.tachiyomi.data.backup.BackupNotifier
|
import eu.kanade.tachiyomi.data.backup.BackupNotifier
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
|
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
|
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
|
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
|
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
|
||||||
@@ -23,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.flow.chunked
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
@@ -97,7 +97,7 @@ class BackupRestorer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun restoreFromFile(uri: Uri, options: RestoreOptions) {
|
private suspend fun restoreFromFile(uri: Uri, options: RestoreOptions) {
|
||||||
val backup = backupDecoder.decode(uri)
|
val (entryCount, backup) = backupDecoder.decodeMetadata(uri)
|
||||||
|
|
||||||
// Store source mapping for error messages
|
// Store source mapping for error messages
|
||||||
val backupMaps = backup.backupSources
|
val backupMaps = backup.backupSources
|
||||||
@@ -105,7 +105,7 @@ class BackupRestorer(
|
|||||||
|
|
||||||
if (options.libraryEntries) {
|
if (options.libraryEntries) {
|
||||||
restoreSourceNames(backupMaps)
|
restoreSourceNames(backupMaps)
|
||||||
restoreAmount += backup.backupManga.size
|
restoreAmount += entryCount
|
||||||
}
|
}
|
||||||
if (options.categories) {
|
if (options.categories) {
|
||||||
restoreAmount += 1
|
restoreAmount += 1
|
||||||
@@ -138,7 +138,7 @@ class BackupRestorer(
|
|||||||
}
|
}
|
||||||
if (options.libraryEntries) {
|
if (options.libraryEntries) {
|
||||||
restoreManga(
|
restoreManga(
|
||||||
backup.backupManga,
|
uri,
|
||||||
if (options.categories) backup.backupCategories else emptyList(),
|
if (options.categories) backup.backupCategories else emptyList(),
|
||||||
restoreCategoriesJob,
|
restoreCategoriesJob,
|
||||||
)
|
)
|
||||||
@@ -175,14 +175,15 @@ class BackupRestorer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.restoreManga(
|
private fun CoroutineScope.restoreManga(
|
||||||
backupMangas: List<BackupManga>,
|
uri: Uri,
|
||||||
backupCategories: List<BackupCategory>,
|
backupCategories: List<BackupCategory>,
|
||||||
categoriesRestoreJob: Job?,
|
categoriesRestoreJob: Job?,
|
||||||
) = launch {
|
) = launch {
|
||||||
categoriesRestoreJob?.join()
|
categoriesRestoreJob?.join()
|
||||||
mangaRestorer.sortByNew(backupMangas)
|
|
||||||
|
backupDecoder.decodeEntries(uri)
|
||||||
.chunked(100)
|
.chunked(100)
|
||||||
.forEach { chunk ->
|
.collect { chunk ->
|
||||||
val restoredAsBatch = try {
|
val restoredAsBatch = try {
|
||||||
ensureActive()
|
ensureActive()
|
||||||
mangaRestorer.restore(chunk, backupCategories)
|
mangaRestorer.restore(chunk, backupCategories)
|
||||||
|
|||||||
@@ -23,13 +23,6 @@ class MangaRestorer(
|
|||||||
private val now = Clock.System.now().toLocalDateTime(timeZone)
|
private val now = Clock.System.now().toLocalDateTime(timeZone)
|
||||||
private val currentFetchWindow = fetchInterval.getWindow(now.date, timeZone)
|
private val currentFetchWindow = fetchInterval.getWindow(now.date, timeZone)
|
||||||
|
|
||||||
suspend fun sortByNew(backupMangas: List<BackupManga>): List<BackupManga> {
|
|
||||||
val urlsBySource = restoreRepository.getMangaUrlsBySourceId()
|
|
||||||
|
|
||||||
return backupMangas
|
|
||||||
.sortedBy { it.url in urlsBySource[it.source].orEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores [backupMangas] all together, so either every one of them is restored or none is.
|
* Restores [backupMangas] all together, so either every one of them is restored or none is.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import dev.zacsweers.metro.AppScope
|
|||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import dev.zacsweers.metro.SingleIn
|
import dev.zacsweers.metro.SingleIn
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.DownloadStream
|
|
||||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||||
import eu.kanade.tachiyomi.util.storage.saveTo
|
import eu.kanade.tachiyomi.util.storage.saveTo
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -151,11 +150,10 @@ class ChapterCache(
|
|||||||
*
|
*
|
||||||
* @param imageUrl url of image.
|
* @param imageUrl url of image.
|
||||||
* @param response http response from page.
|
* @param response http response from page.
|
||||||
* @param live also receives the bytes as they arrive.
|
|
||||||
* @throws IOException image error.
|
* @throws IOException image error.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun putImageToCache(imageUrl: String, response: Response, live: DownloadStream? = null) {
|
fun putImageToCache(imageUrl: String, response: Response) {
|
||||||
// Initialize editor (edits the values for an entry).
|
// Initialize editor (edits the values for an entry).
|
||||||
var editor: DiskLruCache.Editor? = null
|
var editor: DiskLruCache.Editor? = null
|
||||||
|
|
||||||
@@ -165,22 +163,7 @@ class ChapterCache(
|
|||||||
editor = diskCache.edit(key) ?: return
|
editor = diskCache.edit(key) ?: return
|
||||||
|
|
||||||
// Get OutputStream and write image with Okio.
|
// Get OutputStream and write image with Okio.
|
||||||
if (live == null) {
|
response.body.source().saveTo(editor.newOutputStream(0))
|
||||||
response.body.source().saveTo(editor.newOutputStream(0))
|
|
||||||
} else {
|
|
||||||
live.reserve(response.body.contentLength())
|
|
||||||
response.body.source().use { source ->
|
|
||||||
editor.newOutputStream(0).use { out ->
|
|
||||||
val buffer = ByteArray(1 shl 16)
|
|
||||||
while (true) {
|
|
||||||
val n = source.read(buffer)
|
|
||||||
if (n < 0) break
|
|
||||||
out.write(buffer, 0, n)
|
|
||||||
live.append(buffer, 0, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diskCache.flush()
|
diskCache.flush()
|
||||||
editor.commit()
|
editor.commit()
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ package eu.kanade.tachiyomi.data.coil
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import androidx.core.graphics.createBitmap
|
|
||||||
import androidx.core.graphics.scale
|
|
||||||
import ca.mpreg.imagedecoder.ImageDecoder
|
|
||||||
import coil3.Canvas
|
import coil3.Canvas
|
||||||
import coil3.Image
|
import coil3.Image
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
@@ -15,39 +12,20 @@ import coil3.decode.Decoder
|
|||||||
import coil3.decode.ImageSource
|
import coil3.decode.ImageSource
|
||||||
import coil3.fetch.SourceFetchResult
|
import coil3.fetch.SourceFetchResult
|
||||||
import coil3.request.Options
|
import coil3.request.Options
|
||||||
|
import coil3.request.bitmapConfig
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import okio.BufferedSource
|
import okio.BufferedSource
|
||||||
import tachiyomi.core.common.util.system.ImageUtil
|
import tachiyomi.core.common.util.system.ImageUtil
|
||||||
|
import tachiyomi.decoder.ImageDecoder
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [Decoder] that uses [ImageDecoder] to decode image formats not supported
|
* A [Decoder] that uses built-in [ImageDecoder] to decode images that is not supported by the system.
|
||||||
* by the Android system decoder (AVIF, JXL, HEIF, etc.).
|
|
||||||
*/
|
*/
|
||||||
class ImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
|
class ImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps a raw [ImageDecoder.Frame] as a Coil [Image] for callers that want
|
|
||||||
* direct access to the RGBA [java.nio.ByteBuffer] (e.g. the new-decoder path).
|
|
||||||
*/
|
|
||||||
class DecodeResultImage(
|
|
||||||
val frame: ImageDecoder.Frame,
|
|
||||||
val isHdr: Boolean,
|
|
||||||
val hdrHeadroom: Float,
|
|
||||||
val gainmap: ImageDecoder.Gainmap?,
|
|
||||||
) : Image {
|
|
||||||
val image: java.nio.ByteBuffer get() = frame.image
|
|
||||||
|
|
||||||
// Taken now: a caller may close the frame once it has the pixels.
|
|
||||||
override val size: Long = frame.image.capacity().toLong()
|
|
||||||
override val width: Int get() = frame.width
|
|
||||||
override val height: Int get() = frame.height
|
|
||||||
override val shareable: Boolean get() = true
|
|
||||||
override fun draw(canvas: Canvas) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A page cut into immutable strips of about [STRIP_BYTES] each. The renderer uploads an
|
* A page cut into immutable strips of about [STRIP_BYTES] each. The renderer uploads an
|
||||||
* immutable bitmap only when it is drawn, so only the strips on screen reach the GPU instead of
|
* immutable bitmap only when it is drawn, so only the strips on screen reach the GPU instead of
|
||||||
@@ -64,32 +42,18 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
|||||||
override suspend fun decode(): DecodeResult {
|
override suspend fun decode(): DecodeResult {
|
||||||
if (options.webtoonStrips) return decodeStrips()
|
if (options.webtoonStrips) return decodeStrips()
|
||||||
|
|
||||||
val res = resources.source().use {
|
val decoder = resources.sourceOrNull()?.use {
|
||||||
ImageDecoder.open(it.inputStream()).use { dec ->
|
ImageDecoder.newInstance(it.inputStream(), options.cropBorders, displayProfile)
|
||||||
DecodeResultImage(
|
|
||||||
dec.decodeNext(),
|
|
||||||
dec.isHdr,
|
|
||||||
dec.hdrHeadroom,
|
|
||||||
if (dec.hdrKind == ImageDecoder.HdrKind.GAINMAP) dec.getGainmap() else null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val srcWidth = res.width
|
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
|
||||||
val srcHeight = res.height
|
|
||||||
|
|
||||||
// newDecoder path: caller wants the raw DecodeResult (e.g. for custom rendering).
|
val srcWidth = decoder.width
|
||||||
// Hand it back as-is; sampling is the caller's responsibility.
|
val srcHeight = decoder.height
|
||||||
if (options.newDecoder) {
|
|
||||||
return DecodeResult(
|
|
||||||
image = res,
|
|
||||||
isSampled = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal path: produce a Bitmap scaled to the requested output size.
|
|
||||||
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
|
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
|
||||||
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
|
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
|
||||||
|
|
||||||
val sampleSize = DecodeUtils.calculateInSampleSize(
|
val sampleSize = DecodeUtils.calculateInSampleSize(
|
||||||
srcWidth = srcWidth,
|
srcWidth = srcWidth,
|
||||||
srcHeight = srcHeight,
|
srcHeight = srcHeight,
|
||||||
@@ -98,25 +62,17 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
|||||||
scale = options.scale,
|
scale = options.scale,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Copy RGBA pixels from the native buffer into a full-resolution bitmap.
|
var bitmap = decoder.decode(sampleSize = sampleSize)
|
||||||
// We must do this while `res` (and its native memory) is still alive.
|
decoder.recycle()
|
||||||
// HDR frames are half-float RGBA.
|
|
||||||
val config = if (res.isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
|
|
||||||
val fullBitmap = createBitmap(srcWidth, srcHeight, config)
|
|
||||||
res.image.rewind()
|
|
||||||
fullBitmap.copyPixelsFromBuffer(res.image)
|
|
||||||
res.frame.close()
|
|
||||||
|
|
||||||
// Downsample if needed. sampleSize is a power-of-two factor; the target
|
check(bitmap != null) { "Failed to decode image" }
|
||||||
// dimensions are src / sampleSize, matching BitmapFactory inSampleSize behaviour.
|
|
||||||
val bitmap = if (sampleSize > 1) {
|
if (options.bitmapConfig == Bitmap.Config.HARDWARE && ImageUtil.canUseHardwareBitmap(bitmap)) {
|
||||||
val scaledWidth = (srcWidth / sampleSize).coerceAtLeast(1)
|
val hwBitmap = bitmap.copy(Bitmap.Config.HARDWARE, false)
|
||||||
val scaledHeight = (srcHeight / sampleSize).coerceAtLeast(1)
|
if (hwBitmap != null) {
|
||||||
val scaled = fullBitmap.scale(scaledWidth, scaledHeight)
|
bitmap.recycle()
|
||||||
fullBitmap.recycle()
|
bitmap = hwBitmap
|
||||||
scaled
|
}
|
||||||
} else {
|
|
||||||
fullBitmap
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return DecodeResult(
|
return DecodeResult(
|
||||||
@@ -126,66 +82,62 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copies bands of rows straight from the native frame into strips, so the page never exists as
|
* Decodes a webtoon page and cuts it into strips, freeing the decoded page once they're copied.
|
||||||
* one Java bitmap on top of the frame.
|
|
||||||
*
|
*
|
||||||
* Strips keep the resolution a power-of-two sample size leaves, like the other paths, so zooming
|
* Strips keep the resolution a power-of-two sample size leaves, like the other paths, so zooming
|
||||||
* in still shows the page's detail, unless that would make the page take more than
|
* in still shows the page's detail, unless that would make the page take more than
|
||||||
* [MAX_PAGE_BYTES]. They are never narrower than the view, so the page stays sharp unzoomed.
|
* [MAX_PAGE_BYTES]. They are never narrower than the view, so the page stays sharp unzoomed.
|
||||||
*/
|
*/
|
||||||
private suspend fun decodeStrips(): DecodeResult = stripDecodes.withPermit {
|
private suspend fun decodeStrips(): DecodeResult = stripDecodes.withPermit {
|
||||||
val (frame, isHdr) = resources.source().use {
|
val decoder = resources.sourceOrNull()?.use {
|
||||||
ImageDecoder.open(it.inputStream()).use { dec -> dec.decodeNext() to dec.isHdr }
|
ImageDecoder.newInstance(it.inputStream(), options.cropBorders, displayProfile)
|
||||||
}
|
}
|
||||||
frame.use {
|
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
|
||||||
val config = if (isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
|
|
||||||
val bytesPerPixel = if (isHdr) 8 else 4
|
|
||||||
val rowBytes = frame.width * bytesPerPixel
|
|
||||||
val viewWidth = options.size.widthPx(options.scale) { frame.width }
|
|
||||||
val sampleSize = DecodeUtils.calculateInSampleSize(
|
|
||||||
srcWidth = frame.width,
|
|
||||||
srcHeight = frame.height,
|
|
||||||
dstWidth = viewWidth,
|
|
||||||
dstHeight = options.size.heightPx(options.scale) { frame.height },
|
|
||||||
scale = options.scale,
|
|
||||||
)
|
|
||||||
val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * frame.width / frame.height).toInt()
|
|
||||||
val stripWidth = maxOf(viewWidth, min(frame.width / sampleSize, budgetWidth))
|
|
||||||
// Older GPUs can't draw a texture wider than this
|
|
||||||
.coerceAtMost(MAX_STRIP_WIDTH)
|
|
||||||
.coerceIn(1, frame.width)
|
|
||||||
val scale = stripWidth.toFloat() / frame.width
|
|
||||||
|
|
||||||
// Strips of about the same size in bytes, so each takes about as long to upload
|
val srcWidth = decoder.width
|
||||||
val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1)
|
val viewWidth = options.size.widthPx(options.scale) { srcWidth }
|
||||||
val bandRows = (stripRows / scale).toInt().coerceIn(1, frame.height)
|
val sampleSize = DecodeUtils.calculateInSampleSize(
|
||||||
val matrix = Matrix().apply { setScale(scale, scale) }
|
srcWidth = decoder.width,
|
||||||
val band = createBitmap(frame.width, bandRows, config)
|
srcHeight = decoder.height,
|
||||||
val strips = (0..<frame.height step bandRows).map { top ->
|
dstWidth = viewWidth,
|
||||||
val rows = min(bandRows, frame.height - top)
|
dstHeight = options.size.heightPx(options.scale) { decoder.height },
|
||||||
if (rows != band.height) band.reconfigure(frame.width, rows, config)
|
scale = options.scale,
|
||||||
band.copyPixelsFromBuffer(
|
)
|
||||||
frame.image.duplicate().apply {
|
val page = decoder.decode(sampleSize = sampleSize)
|
||||||
position(top * rowBytes)
|
decoder.recycle()
|
||||||
limit((top + rows) * rowBytes)
|
check(page != null) { "Failed to decode image" }
|
||||||
},
|
|
||||||
)
|
val config = page.config ?: Bitmap.Config.ARGB_8888
|
||||||
val strip = if (stripWidth < frame.width) {
|
val bytesPerPixel = page.rowBytes / page.width
|
||||||
Bitmap.createBitmap(band, 0, 0, frame.width, rows, matrix, true)
|
val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * page.width / page.height).toInt()
|
||||||
} else {
|
val stripWidth = maxOf(viewWidth, min(page.width, budgetWidth))
|
||||||
band
|
// Older GPUs can't draw a texture wider than this
|
||||||
}
|
.coerceAtMost(MAX_STRIP_WIDTH)
|
||||||
// createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one
|
.coerceIn(1, page.width)
|
||||||
strip.copy(config, false).also { if (strip !== band) strip.recycle() }
|
val scale = stripWidth.toFloat() / page.width
|
||||||
|
|
||||||
|
// Strips of about the same size in bytes, so each takes about as long to upload
|
||||||
|
val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1)
|
||||||
|
val bandRows = (stripRows / scale).toInt().coerceIn(1, page.height)
|
||||||
|
val matrix = Matrix().apply { setScale(scale, scale) }
|
||||||
|
val strips = (0..<page.height step bandRows).map { top ->
|
||||||
|
val rows = min(bandRows, page.height - top)
|
||||||
|
val band = if (stripWidth < page.width) {
|
||||||
|
Bitmap.createBitmap(page, 0, top, page.width, rows, matrix, true)
|
||||||
|
} else {
|
||||||
|
Bitmap.createBitmap(page, 0, top, page.width, rows)
|
||||||
}
|
}
|
||||||
band.recycle()
|
// createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one
|
||||||
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < frame.width)
|
band.copy(config, false).also { if (band !== page) band.recycle() }
|
||||||
}
|
}
|
||||||
|
page.recycle()
|
||||||
|
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < srcWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
class Factory : Decoder.Factory {
|
class Factory : Decoder.Factory {
|
||||||
|
|
||||||
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
|
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
|
||||||
return if (options.newDecoder || options.webtoonStrips || isApplicable(result.source.source())) {
|
return if (options.webtoonStrips || isApplicable(result.source.source())) {
|
||||||
ImageDecoder(result.source, options)
|
ImageDecoder(result.source, options)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -197,12 +149,7 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
|||||||
ImageUtil.findImageType(it)
|
ImageUtil.findImageType(it)
|
||||||
}
|
}
|
||||||
return when (type) {
|
return when (type) {
|
||||||
ImageUtil.ImageType.AVIF,
|
ImageUtil.ImageType.AVIF, ImageUtil.ImageType.JXL, ImageUtil.ImageType.HEIF -> true
|
||||||
ImageUtil.ImageType.JXL,
|
|
||||||
ImageUtil.ImageType.HEIF,
|
|
||||||
ImageUtil.ImageType.JP2,
|
|
||||||
-> true
|
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +158,10 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
|||||||
|
|
||||||
override fun hashCode() = javaClass.hashCode()
|
override fun hashCode() = javaClass.hashCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
var displayProfile: ByteArray? = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val STRIP_BYTES = 4 * 1024 * 1024
|
private const val STRIP_BYTES = 4 * 1024 * 1024
|
||||||
@@ -219,5 +170,5 @@ private const val MAX_PAGE_BYTES = 128 * 1024 * 1024
|
|||||||
|
|
||||||
private const val MAX_STRIP_WIDTH = 4096
|
private const val MAX_STRIP_WIDTH = 4096
|
||||||
|
|
||||||
// Each decode holds the whole native frame until its strips are copied, and the reader loads pages ahead in parallel
|
// Each decode holds the whole page until its strips are copied, and the reader loads pages ahead in parallel
|
||||||
private val stripDecodes = Semaphore(1)
|
private val stripDecodes = Semaphore(1)
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ class MangaCoverFetcher(
|
|||||||
get() = diskCacheKeyLazy.value
|
get() = diskCacheKeyLazy.value
|
||||||
|
|
||||||
override suspend fun fetch(): FetchResult {
|
override suspend fun fetch(): FetchResult {
|
||||||
// Use custom cover if exists
|
// Use custom cover if exists, which only library manga can have
|
||||||
val useCustomCover = options.extras.getOrDefault(USE_CUSTOM_COVER_KEY)
|
val useCustomCover = options.extras.getOrDefault(USE_CUSTOM_COVER_KEY)
|
||||||
if (useCustomCover) {
|
if (useCustomCover && isLibraryManga) {
|
||||||
val customCoverFile = customCoverFileLazy.value
|
val customCoverFile = customCoverFileLazy.value
|
||||||
if (customCoverFile.exists()) {
|
if (customCoverFile.exists()) {
|
||||||
return fileLoader(customCoverFile)
|
return fileLoader(customCoverFile)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import tachiyomi.domain.manga.model.Manga as DomainManga
|
|||||||
|
|
||||||
class MangaKeyer : Keyer<DomainManga> {
|
class MangaKeyer : Keyer<DomainManga> {
|
||||||
override fun key(data: DomainManga, options: Options): String {
|
override fun key(data: DomainManga, options: Options): String {
|
||||||
return if (data.hasCustomCover()) {
|
return if (data.favorite && data.hasCustomCover()) {
|
||||||
"${data.id};${data.coverLastModified}"
|
"${data.id};${data.coverLastModified}"
|
||||||
} else {
|
} else {
|
||||||
"${data.thumbnailUrl};${data.coverLastModified}"
|
"${data.thumbnailUrl};${data.coverLastModified}"
|
||||||
@@ -21,7 +21,7 @@ class MangaCoverKeyer(
|
|||||||
private val coverCache: CoverCache,
|
private val coverCache: CoverCache,
|
||||||
) : Keyer<MangaCover> {
|
) : Keyer<MangaCover> {
|
||||||
override fun key(data: MangaCover, options: Options): String {
|
override fun key(data: MangaCover, options: Options): String {
|
||||||
return if (coverCache.hasCustomCover(data.mangaId)) {
|
return if (data.isMangaFavorite && coverCache.hasCustomCover(data.mangaId)) {
|
||||||
"${data.mangaId};${data.lastModified}"
|
"${data.mangaId};${data.lastModified}"
|
||||||
} else {
|
} else {
|
||||||
"${data.url};${data.lastModified}"
|
"${data.url};${data.lastModified}"
|
||||||
|
|||||||
@@ -42,11 +42,3 @@ val Options.webtoonStrips: Boolean
|
|||||||
get() = getExtra(webtoonStripsKey)
|
get() = getExtra(webtoonStripsKey)
|
||||||
|
|
||||||
private val webtoonStripsKey = Extras.Key(default = false)
|
private val webtoonStripsKey = Extras.Key(default = false)
|
||||||
|
|
||||||
fun ImageRequest.Builder.newDecoder(enable: Boolean) = apply {
|
|
||||||
extras[newDecoderKey] = enable
|
|
||||||
}
|
|
||||||
val Options.newDecoder: Boolean
|
|
||||||
get() = getExtra(newDecoderKey)
|
|
||||||
|
|
||||||
private val newDecoderKey = Extras.Key(default = false)
|
|
||||||
|
|||||||
@@ -186,7 +186,8 @@ class DownloadCache(
|
|||||||
fun getTotalDownloadCount(): Int {
|
fun getTotalDownloadCount(): Int {
|
||||||
renewCache()
|
renewCache()
|
||||||
|
|
||||||
return rootDownloadsDir.sourceDirs.values.sumOf { sourceDir ->
|
// The disk index stores a folder shared by several ids once per id
|
||||||
|
return rootDownloadsDir.sourceDirs.values.distinctBy { it.dir?.uri }.sumOf { sourceDir ->
|
||||||
sourceDir.mangaDirs.values.sumOf { mangaDir ->
|
sourceDir.mangaDirs.values.sumOf { mangaDir ->
|
||||||
mangaDir.chapterDirs.size
|
mangaDir.chapterDirs.size
|
||||||
}
|
}
|
||||||
@@ -390,20 +391,21 @@ class DownloadCache(
|
|||||||
sources = getSources()
|
sources = getSources()
|
||||||
}
|
}
|
||||||
|
|
||||||
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
|
// A stored stub can share its name with an installed source, so a folder can belong to several ids
|
||||||
|
val sourceIdsByDirName = sources.groupBy({ provider.getSourceDirName(it).lowercase() }, { it.id })
|
||||||
|
|
||||||
rootDownloadsDirMutex.withLock {
|
rootDownloadsDirMutex.withLock {
|
||||||
val updatedRootDir = RootDirectory(storageManager.getDownloadsDirectory())
|
val updatedRootDir = RootDirectory(storageManager.getDownloadsDirectory())
|
||||||
|
|
||||||
updatedRootDir.sourceDirs = updatedRootDir.dir?.listFiles().orEmpty()
|
updatedRootDir.sourceDirs = updatedRootDir.dir?.listFiles().orEmpty()
|
||||||
.filter { it.isDirectory && !it.name.isNullOrBlank() }
|
.filter { it.isDirectory && !it.name.isNullOrBlank() }
|
||||||
.mapNotNull { dir ->
|
.flatMap { dir ->
|
||||||
val sourceId = sourceMap[dir.name!!.lowercase()]
|
val sourceDir = SourceDirectory(dir)
|
||||||
sourceId?.let { it to SourceDirectory(dir) }
|
sourceIdsByDirName[dir.name!!.lowercase()].orEmpty().map { it to sourceDir }
|
||||||
}
|
}
|
||||||
.toMap()
|
.toMap()
|
||||||
|
|
||||||
updatedRootDir.sourceDirs.values.map { sourceDir ->
|
updatedRootDir.sourceDirs.values.distinct().map { sourceDir ->
|
||||||
async {
|
async {
|
||||||
sourceDir.mangaDirs = sourceDir.dir?.listFiles().orEmpty()
|
sourceDir.mangaDirs = sourceDir.dir?.listFiles().orEmpty()
|
||||||
.filter { it.isDirectory && !it.name.isNullOrBlank() }
|
.filter { it.isDirectory && !it.name.isNullOrBlank() }
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ class DownloadProvider(
|
|||||||
enableChapterNameHash: Boolean = libraryPreferences.enableChapterNameHash.get(),
|
enableChapterNameHash: Boolean = libraryPreferences.enableChapterNameHash.get(),
|
||||||
): String {
|
): String {
|
||||||
return ChapterDirNameParts(chapterName, chapterScanlator, chapterUrl)
|
return ChapterDirNameParts(chapterName, chapterScanlator, chapterUrl)
|
||||||
.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
|
.dirName(disallowNonAsciiFilenames, enableChapterNameHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,14 +254,18 @@ class DownloadProvider(
|
|||||||
val booleanPairPermutation = listOf(false to false, false to true, true to false, true to true)
|
val booleanPairPermutation = listOf(false to false, false to true, true to false, true to true)
|
||||||
val othersChapterDirNames = booleanPairPermutation
|
val othersChapterDirNames = booleanPairPermutation
|
||||||
.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
|
.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
|
||||||
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
|
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash)
|
||||||
|
}
|
||||||
|
val splitChapterDirNames = booleanPairPermutation
|
||||||
|
.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
|
||||||
|
parts.splitDirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scanlators weren't sanitized for a while. One with a "/" nested its folder, so it was never found anyway.
|
// Scanlators weren't sanitized for a while. One with a "/" nested its folder, so it was never found anyway.
|
||||||
val scanlator = parts.chapterScanlator
|
val scanlator = parts.chapterScanlator
|
||||||
val unsanitizedScanlatorChapterDirNames = if (scanlator != null && '/' !in scanlator) {
|
val unsanitizedScanlatorChapterDirNames = if (scanlator != null && '/' !in scanlator) {
|
||||||
booleanPairPermutation.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
|
booleanPairPermutation.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
|
||||||
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
|
parts.splitDirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
@@ -272,6 +276,7 @@ class DownloadProvider(
|
|||||||
// chapter names)
|
// chapter names)
|
||||||
add(chapterNameV1)
|
add(chapterNameV1)
|
||||||
addAll(othersChapterDirNames)
|
addAll(othersChapterDirNames)
|
||||||
|
addAll(splitChapterDirNames)
|
||||||
addAll(unsanitizedScanlatorChapterDirNames)
|
addAll(unsanitizedScanlatorChapterDirNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,7 +296,6 @@ class DownloadProvider(
|
|||||||
val chapterDirName = parts.dirName(
|
val chapterDirName = parts.dirName(
|
||||||
disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames.get(),
|
disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames.get(),
|
||||||
enableHash = libraryPreferences.enableChapterNameHash.get(),
|
enableHash = libraryPreferences.enableChapterNameHash.get(),
|
||||||
sanitizeScanlator = true,
|
|
||||||
)
|
)
|
||||||
val legacyChapterDirNames = getLegacyChapterDirNames(parts)
|
val legacyChapterDirNames = getLegacyChapterDirNames(parts)
|
||||||
|
|
||||||
@@ -330,9 +334,29 @@ private class ChapterDirNameParts(chapterName: String, val chapterScanlator: Str
|
|||||||
chapterScanlator?.let { DiskUtil.buildValidFilename(it) }
|
chapterScanlator?.let { DiskUtil.buildValidFilename(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val validDirNames = arrayOfNulls<String>(2)
|
||||||
|
|
||||||
private val validChapterNames = arrayOfNulls<String>(2)
|
private val validChapterNames = arrayOfNulls<String>(2)
|
||||||
|
|
||||||
fun dirName(disallowNonAscii: Boolean, enableHash: Boolean, sanitizeScanlator: Boolean): String {
|
fun dirName(disallowNonAscii: Boolean, enableHash: Boolean): String {
|
||||||
|
val index = if (disallowNonAscii) 1 else 0
|
||||||
|
val dirName = validDirNames[index]
|
||||||
|
// The scanlator and the name are made valid together, as every version before 0.21.0 did
|
||||||
|
?: DiskUtil.buildValidFilename(
|
||||||
|
if (chapterScanlator.isNullOrBlank()) chapterName else "${chapterScanlator}_$chapterName",
|
||||||
|
// Subtract 7 bytes for hash and underscore, 4 bytes for .cbz
|
||||||
|
DiskUtil.MAX_FILE_NAME_BYTES - 11,
|
||||||
|
disallowNonAscii,
|
||||||
|
)
|
||||||
|
.also { validDirNames[index] = it }
|
||||||
|
return if (enableHash) dirName + hashSuffix else dirName
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name 0.21.0 and 0.21.1 gave a chapter, which made only the chapter name valid and so differs for names
|
||||||
|
* starting with a space or dot, long names, and non-ASCII scanlators.
|
||||||
|
*/
|
||||||
|
fun splitDirName(disallowNonAscii: Boolean, enableHash: Boolean, sanitizeScanlator: Boolean): String {
|
||||||
return buildString {
|
return buildString {
|
||||||
if (!chapterScanlator.isNullOrBlank()) {
|
if (!chapterScanlator.isNullOrBlank()) {
|
||||||
// A "/" in the scanlator would otherwise nest the chapter in a folder of its own
|
// A "/" in the scanlator would otherwise nest the chapter in a folder of its own
|
||||||
|
|||||||
@@ -551,8 +551,6 @@ class Downloader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// When the page is ready, set page path, progress (just in case) and status
|
// When the page is ready, set page path, progress (just in case) and status
|
||||||
splitTallImageIfNeeded(page, tmpDir)
|
|
||||||
|
|
||||||
page.uri = file.uri
|
page.uri = file.uri
|
||||||
page.progress = 100
|
page.progress = 100
|
||||||
page.status = Page.State.Ready
|
page.status = Page.State.Ready
|
||||||
@@ -645,23 +643,6 @@ class Downloader(
|
|||||||
return ImageUtil.getExtensionFromMimeType(mime) { file.openInputStream() }
|
return ImageUtil.getExtensionFromMimeType(mime) { file.openInputStream() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun splitTallImageIfNeeded(page: Page, tmpDir: UniFile) {
|
|
||||||
if (!downloadPreferences.splitTallImages.get()) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
val filenamePrefix = "%03d".format(Locale.ENGLISH, page.number)
|
|
||||||
val imageFile = tmpDir.listFiles()?.firstOrNull { it.name.orEmpty().startsWith(filenamePrefix) }
|
|
||||||
?: error(context.stringResource(MR.strings.download_notifier_split_page_not_found, page.number))
|
|
||||||
|
|
||||||
// If the original page was previously split, then skip
|
|
||||||
if (imageFile.name.orEmpty().startsWith("${filenamePrefix}__")) return
|
|
||||||
|
|
||||||
ImageUtil.splitTallImage(tmpDir, imageFile, filenamePrefix)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logcat(LogPriority.ERROR, e) { "Failed to split downloaded image" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the download was successful.
|
* Checks if the download was successful.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ data class Download(
|
|||||||
_pagesFlow.value = pages
|
_pagesFlow.value = pages
|
||||||
}
|
}
|
||||||
|
|
||||||
val totalProgress: Int
|
|
||||||
get() = pages?.sumOf(Page::progress) ?: 0
|
|
||||||
|
|
||||||
val downloadedImages: Int
|
val downloadedImages: Int
|
||||||
get() = pages?.count { it.status == Page.State.Ready } ?: 0
|
get() = pages?.count { it.status == Page.State.Ready } ?: 0
|
||||||
|
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
|
|||||||
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
mangaToUpdate.groupBy { it.manga.source }.values
|
mangaToUpdate.groupBy { it.manga.source }.values
|
||||||
|
.sortedByDescending { it.size }
|
||||||
.map { mangaInSource ->
|
.map { mangaInSource ->
|
||||||
async {
|
async {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import okhttp3.Request
|
|||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.math.RoundingMode
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
@@ -77,7 +78,14 @@ class ComickApi(
|
|||||||
put("status", track.status.toApiListStatus())
|
put("status", track.status.toApiListStatus())
|
||||||
if (track.last_chapter_read > 0) {
|
if (track.last_chapter_read > 0) {
|
||||||
putJsonObject("progress") {
|
putJsonObject("progress") {
|
||||||
put("number", track.last_chapter_read)
|
put(
|
||||||
|
"number",
|
||||||
|
track.last_chapter_read
|
||||||
|
.toBigDecimal()
|
||||||
|
.setScale(2, RoundingMode.HALF_UP)
|
||||||
|
.stripTrailingZeros()
|
||||||
|
.toPlainString(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
put("progress", null)
|
put("progress", null)
|
||||||
@@ -140,7 +148,14 @@ class ComickApi(
|
|||||||
put("rating", track.score.toInt().takeIf { it > 0 })
|
put("rating", track.score.toInt().takeIf { it > 0 })
|
||||||
put("status", track.status.toApiListStatus())
|
put("status", track.status.toApiListStatus())
|
||||||
putJsonObject("progress") {
|
putJsonObject("progress") {
|
||||||
put("number", track.last_chapter_read.toString())
|
put(
|
||||||
|
"number",
|
||||||
|
track.last_chapter_read
|
||||||
|
.toBigDecimal()
|
||||||
|
.setScale(2, RoundingMode.HALF_UP)
|
||||||
|
.stripTrailingZeros()
|
||||||
|
.toPlainString(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toString()
|
.toString()
|
||||||
|
|||||||
+11
-1
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.extension.installer
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -57,7 +58,16 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
service.startActivity(userAction)
|
try {
|
||||||
|
service.startActivity(userAction)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// Some devices have the system installer's confirmation screen disabled or removed
|
||||||
|
logcat(LogPriority.WARN, e) { "Nothing can confirm the install" }
|
||||||
|
activeSession?.let { (_, sessionId) ->
|
||||||
|
runCatching { packageInstaller.abandonSession(sessionId) }
|
||||||
|
}
|
||||||
|
continueQueue(InstallStep.Error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
||||||
continueQueue(InstallStep.Idle)
|
continueQueue(InstallStep.Idle)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import mihon.domain.extension.model.ContentWarning
|
|||||||
import mihon.domain.extension.model.ExtensionStore
|
import mihon.domain.extension.model.ExtensionStore
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class that handles the loading of the extensions. Supports two kinds of extensions:
|
* Class that handles the loading of the extensions. Supports two kinds of extensions:
|
||||||
@@ -80,12 +81,12 @@ internal object ExtensionLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION")
|
val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION")
|
||||||
|
val staged = File(target.parentFile, "${target.name}.part")
|
||||||
try {
|
try {
|
||||||
target.delete()
|
file.copyAndSetReadOnlyTo(staged, overwrite = true)
|
||||||
file.copyAndSetReadOnlyTo(target, overwrite = true)
|
if (!staged.renameTo(target)) throw IOException("Unable to replace ${target.name}")
|
||||||
} catch (e: Exception) {
|
} finally {
|
||||||
target.delete()
|
staged.delete()
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
if (currentExtension != null) {
|
if (currentExtension != null) {
|
||||||
ExtensionInstallReceiver.notifyReplaced(context, extension.packageName)
|
ExtensionInstallReceiver.notifyReplaced(context, extension.packageName)
|
||||||
|
|||||||
+2
-5
@@ -239,11 +239,8 @@ class BrowseSourceViewModel(
|
|||||||
fun changeMangaFavorite(manga: Manga) {
|
fun changeMangaFavorite(manga: Manga) {
|
||||||
appScope.launch {
|
appScope.launch {
|
||||||
val update = if (manga.favorite) {
|
val update = if (manga.favorite) {
|
||||||
val coverLastModified = manga.removeCovers(coverCache).coverLastModified
|
manga.removeCovers(coverCache)
|
||||||
MangaUpdate(manga.id) {
|
MangaUpdate(manga.id) { favoriteAt = null }
|
||||||
favoriteAt = null
|
|
||||||
if (coverLastModified != manga.coverLastModified) this.coverLastModified = coverLastModified
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setMangaDefaultChapterFlags.await(manga)
|
setMangaDefaultChapterFlags.await(manga)
|
||||||
addTracks.bindEnhancedTrackers(manga, sourceManager.getOrStub(manga.source))
|
addTracks.bindEnhancedTrackers(manga, sourceManager.getOrStub(manga.source))
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ private fun Int.toTriStateFilter(): TriState {
|
|||||||
Filter.TriState.STATE_IGNORE -> TriState.DISABLED
|
Filter.TriState.STATE_IGNORE -> TriState.DISABLED
|
||||||
Filter.TriState.STATE_INCLUDE -> TriState.ENABLED_IS
|
Filter.TriState.STATE_INCLUDE -> TriState.ENABLED_IS
|
||||||
Filter.TriState.STATE_EXCLUDE -> TriState.ENABLED_NOT
|
Filter.TriState.STATE_EXCLUDE -> TriState.ENABLED_NOT
|
||||||
else -> throw IllegalStateException("Unknown TriState state: $this")
|
else -> TriState.DISABLED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.download
|
|
||||||
|
|
||||||
import android.view.MenuItem
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapter storing a list of downloads.
|
|
||||||
*
|
|
||||||
* @param downloadItemListener Listener called when an item of the list is released.
|
|
||||||
*/
|
|
||||||
class DownloadAdapter(val downloadItemListener: DownloadItemListener) : FlexibleAdapter<AbstractFlexibleItem<*>>(
|
|
||||||
null,
|
|
||||||
downloadItemListener,
|
|
||||||
true,
|
|
||||||
) {
|
|
||||||
|
|
||||||
override fun shouldMove(fromPosition: Int, toPosition: Int): Boolean {
|
|
||||||
// Don't let sub-items changing group
|
|
||||||
return getHeaderOf(getItem(fromPosition)) == getHeaderOf(getItem(toPosition))
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DownloadItemListener {
|
|
||||||
fun onItemReleased(position: Int)
|
|
||||||
fun onMenuItemClick(position: Int, menuItem: MenuItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.download
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.view.View
|
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.viewholders.ExpandableViewHolder
|
|
||||||
import eu.kanade.tachiyomi.databinding.DownloadHeaderBinding
|
|
||||||
|
|
||||||
class DownloadHeaderHolder(view: View, adapter: FlexibleAdapter<*>) : ExpandableViewHolder(view, adapter) {
|
|
||||||
|
|
||||||
private val binding = DownloadHeaderBinding.bind(view)
|
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
|
||||||
fun bind(item: DownloadHeaderItem) {
|
|
||||||
setDragHandleView(binding.reorder)
|
|
||||||
binding.title.text = "${item.name} (${item.size})"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActionStateChanged(position: Int, actionState: Int) {
|
|
||||||
super.onActionStateChanged(position, actionState)
|
|
||||||
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
|
||||||
binding.container.isDragged = true
|
|
||||||
mAdapter.collapseAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemReleased(position: Int) {
|
|
||||||
super.onItemReleased(position)
|
|
||||||
binding.container.isDragged = false
|
|
||||||
mAdapter.expandAll()
|
|
||||||
(mAdapter as DownloadAdapter).downloadItemListener.onItemReleased(position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.download
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem
|
|
||||||
import eu.davidea.flexibleadapter.items.IFlexible
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
|
|
||||||
data class DownloadHeaderItem(
|
|
||||||
val id: Long,
|
|
||||||
val name: String,
|
|
||||||
val size: Int,
|
|
||||||
) : AbstractExpandableHeaderItem<DownloadHeaderHolder, DownloadItem>() {
|
|
||||||
|
|
||||||
override fun getLayoutRes(): Int {
|
|
||||||
return R.layout.download_header
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createViewHolder(
|
|
||||||
view: View,
|
|
||||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
|
||||||
): DownloadHeaderHolder {
|
|
||||||
return DownloadHeaderHolder(view, adapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun bindViewHolder(
|
|
||||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
|
||||||
holder: DownloadHeaderHolder,
|
|
||||||
position: Int,
|
|
||||||
payloads: List<Any?>?,
|
|
||||||
) {
|
|
||||||
holder.bind(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (javaClass != other?.javaClass) return false
|
|
||||||
|
|
||||||
other as DownloadHeaderItem
|
|
||||||
|
|
||||||
if (id != other.id) return false
|
|
||||||
if (name != other.name) return false
|
|
||||||
if (size != other.size) return false
|
|
||||||
if (subItemsCount != other.subItemsCount) return false
|
|
||||||
if (subItems !== other.subItems) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
var result = id.hashCode()
|
|
||||||
result = 31 * result + name.hashCode()
|
|
||||||
result = 31 * result + size
|
|
||||||
result = 31 * result + subItems.hashCode()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
isHidden = false
|
|
||||||
isExpanded = true
|
|
||||||
isSelectable = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.download
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
|
||||||
import eu.davidea.viewholders.FlexibleViewHolder
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
|
||||||
import eu.kanade.tachiyomi.databinding.DownloadItemBinding
|
|
||||||
import eu.kanade.tachiyomi.util.view.popupMenu
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class used to hold the data of a download.
|
|
||||||
* All the elements from the layout file "download_item" are available in this class.
|
|
||||||
*
|
|
||||||
* @param view the inflated view for this holder.
|
|
||||||
* @constructor creates a new download holder.
|
|
||||||
*/
|
|
||||||
class DownloadHolder(private val view: View, val adapter: DownloadAdapter) :
|
|
||||||
FlexibleViewHolder(view, adapter) {
|
|
||||||
|
|
||||||
private val binding = DownloadItemBinding.bind(view)
|
|
||||||
|
|
||||||
init {
|
|
||||||
setDragHandleView(binding.reorder)
|
|
||||||
binding.menu.setOnClickListener { it.post { showPopupMenu(it) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var download: Download
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds this holder with the given category.
|
|
||||||
*
|
|
||||||
* @param category The category to bind.
|
|
||||||
*/
|
|
||||||
fun bind(download: Download) {
|
|
||||||
this.download = download
|
|
||||||
// Update the chapter name.
|
|
||||||
binding.chapterTitle.text = download.chapter.name
|
|
||||||
|
|
||||||
// Update the manga title
|
|
||||||
binding.mangaFullTitle.text = download.manga.title
|
|
||||||
|
|
||||||
// Update the progress bar and the number of downloaded pages
|
|
||||||
val pages = download.pages
|
|
||||||
if (pages == null) {
|
|
||||||
binding.downloadProgress.progress = 0
|
|
||||||
binding.downloadProgress.max = 1
|
|
||||||
binding.downloadProgressText.text = ""
|
|
||||||
} else {
|
|
||||||
binding.downloadProgress.max = pages.size * 100
|
|
||||||
notifyProgress()
|
|
||||||
notifyDownloadedPages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the progress bar of the download.
|
|
||||||
*/
|
|
||||||
fun notifyProgress() {
|
|
||||||
val pages = download.pages ?: return
|
|
||||||
if (binding.downloadProgress.max == 1) {
|
|
||||||
binding.downloadProgress.max = pages.size * 100
|
|
||||||
}
|
|
||||||
binding.downloadProgress.setProgressCompat(download.totalProgress, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the text field of the number of downloaded pages.
|
|
||||||
*/
|
|
||||||
fun notifyDownloadedPages() {
|
|
||||||
val pages = download.pages ?: return
|
|
||||||
binding.downloadProgressText.text = "${download.downloadedImages}/${pages.size}"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemReleased(position: Int) {
|
|
||||||
super.onItemReleased(position)
|
|
||||||
adapter.downloadItemListener.onItemReleased(position)
|
|
||||||
binding.container.isDragged = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActionStateChanged(position: Int, actionState: Int) {
|
|
||||||
super.onActionStateChanged(position, actionState)
|
|
||||||
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
|
||||||
binding.container.isDragged = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showPopupMenu(view: View) {
|
|
||||||
view.popupMenu(
|
|
||||||
menuRes = R.menu.download_single,
|
|
||||||
initMenu = {
|
|
||||||
findItem(R.id.move_to_top).isVisible = bindingAdapterPosition > 1
|
|
||||||
findItem(R.id.move_to_bottom).isVisible =
|
|
||||||
bindingAdapterPosition != adapter.itemCount - 1
|
|
||||||
},
|
|
||||||
onMenuItemClick = {
|
|
||||||
adapter.downloadItemListener.onMenuItemClick(bindingAdapterPosition, this)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.download
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.davidea.flexibleadapter.items.AbstractSectionableItem
|
|
||||||
import eu.davidea.flexibleadapter.items.IFlexible
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
|
||||||
|
|
||||||
class DownloadItem(
|
|
||||||
val download: Download,
|
|
||||||
header: DownloadHeaderItem,
|
|
||||||
) : AbstractSectionableItem<DownloadHolder, DownloadHeaderItem>(header) {
|
|
||||||
|
|
||||||
override fun getLayoutRes(): Int {
|
|
||||||
return R.layout.download_item
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a new view holder for this item.
|
|
||||||
*
|
|
||||||
* @param view The view of this item.
|
|
||||||
* @param adapter The adapter of this item.
|
|
||||||
*/
|
|
||||||
override fun createViewHolder(
|
|
||||||
view: View,
|
|
||||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
|
||||||
): DownloadHolder {
|
|
||||||
return DownloadHolder(view, adapter as DownloadAdapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds the given view holder with this item.
|
|
||||||
*
|
|
||||||
* @param adapter The adapter of this item.
|
|
||||||
* @param holder The holder to bind.
|
|
||||||
* @param position The position of this item in the adapter.
|
|
||||||
* @param payloads List of partial changes.
|
|
||||||
*/
|
|
||||||
override fun bindViewHolder(
|
|
||||||
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
|
||||||
holder: DownloadHolder,
|
|
||||||
position: Int,
|
|
||||||
payloads: MutableList<Any>,
|
|
||||||
) {
|
|
||||||
holder.bind(download)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this item is draggable.
|
|
||||||
*/
|
|
||||||
override fun isDraggable(): Boolean {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (other is DownloadItem) {
|
|
||||||
return download.chapter.id == other.download.chapter.id
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
return download.chapter.id.toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
package eu.kanade.tachiyomi.ui.download
|
package eu.kanade.tachiyomi.ui.download
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -19,7 +17,6 @@ import androidx.compose.runtime.derivedStateOf
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -27,17 +24,11 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Velocity
|
import androidx.compose.ui.unit.Velocity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
import dev.zacsweers.metrox.viewmodel.metroViewModel
|
import dev.zacsweers.metrox.viewmodel.metroViewModel
|
||||||
@@ -45,10 +36,8 @@ import eu.kanade.presentation.components.AppBar
|
|||||||
import eu.kanade.presentation.components.AppBarActions
|
import eu.kanade.presentation.components.AppBarActions
|
||||||
import eu.kanade.presentation.components.DropdownMenu
|
import eu.kanade.presentation.components.DropdownMenu
|
||||||
import eu.kanade.presentation.components.NestedMenuItem
|
import eu.kanade.presentation.components.NestedMenuItem
|
||||||
|
import eu.kanade.presentation.download.DownloadQueueList
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.automirroredrounded.Sort
|
import mihon.icons.materialsymbols.automirroredrounded.Sort
|
||||||
import mihon.icons.materialsymbols.roundedfilled.Pause
|
import mihon.icons.materialsymbols.roundedfilled.Pause
|
||||||
@@ -58,18 +47,16 @@ import tachiyomi.presentation.core.components.Pill
|
|||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import tachiyomi.presentation.core.screens.EmptyScreen
|
import tachiyomi.presentation.core.screens.EmptyScreen
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
object DownloadQueueScreen : Screen() {
|
object DownloadQueueScreen : Screen() {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun Content() {
|
override fun Content() {
|
||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val viewModel = metroViewModel<DownloadQueueViewModel>()
|
val viewModel = metroViewModel<DownloadQueueViewModel>()
|
||||||
val downloadList by viewModel.state.collectAsStateWithLifecycle()
|
val downloadList by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val downloadCount by remember {
|
val downloadCount by remember {
|
||||||
derivedStateOf { downloadList.sumOf { it.subItems.size } }
|
derivedStateOf { downloadList.sumOf { it.downloads.size } }
|
||||||
}
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
@@ -135,7 +122,7 @@ object DownloadQueueScreen : Screen() {
|
|||||||
text = { Text(text = stringResource(MR.strings.action_newest)) },
|
text = { Text(text = stringResource(MR.strings.action_newest)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.reorderQueue(
|
viewModel.reorderQueue(
|
||||||
{ it.download.chapter.dateUpload },
|
{ it.chapter.dateUpload },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
closeMenu()
|
closeMenu()
|
||||||
@@ -145,7 +132,7 @@ object DownloadQueueScreen : Screen() {
|
|||||||
text = { Text(text = stringResource(MR.strings.action_oldest)) },
|
text = { Text(text = stringResource(MR.strings.action_oldest)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.reorderQueue(
|
viewModel.reorderQueue(
|
||||||
{ it.download.chapter.dateUpload },
|
{ it.chapter.dateUpload },
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
closeMenu()
|
closeMenu()
|
||||||
@@ -160,7 +147,7 @@ object DownloadQueueScreen : Screen() {
|
|||||||
text = { Text(text = stringResource(MR.strings.action_asc)) },
|
text = { Text(text = stringResource(MR.strings.action_asc)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.reorderQueue(
|
viewModel.reorderQueue(
|
||||||
{ it.download.chapter.chapterNumber },
|
{ it.chapter.chapterNumber },
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
closeMenu()
|
closeMenu()
|
||||||
@@ -170,7 +157,7 @@ object DownloadQueueScreen : Screen() {
|
|||||||
text = { Text(text = stringResource(MR.strings.action_desc)) },
|
text = { Text(text = stringResource(MR.strings.action_desc)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.reorderQueue(
|
viewModel.reorderQueue(
|
||||||
{ it.download.chapter.chapterNumber },
|
{ it.chapter.chapterNumber },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
closeMenu()
|
closeMenu()
|
||||||
@@ -240,49 +227,17 @@ object DownloadQueueScreen : Screen() {
|
|||||||
return@Scaffold
|
return@Scaffold
|
||||||
}
|
}
|
||||||
|
|
||||||
val density = LocalDensity.current
|
DownloadQueueList(
|
||||||
val layoutDirection = LocalLayoutDirection.current
|
sections = downloadList,
|
||||||
val left = with(density) { contentPadding.calculateLeftPadding(layoutDirection).toPx().roundToInt() }
|
lazyListState = rememberLazyListState(),
|
||||||
val top = with(density) { contentPadding.calculateTopPadding().toPx().roundToInt() }
|
contentPadding = contentPadding,
|
||||||
val right = with(density) { contentPadding.calculateRightPadding(layoutDirection).toPx().roundToInt() }
|
onReorder = viewModel::reorder,
|
||||||
val bottom = with(density) { contentPadding.calculateBottomPadding().toPx().roundToInt() }
|
onMove = viewModel::move,
|
||||||
|
onMoveSeries = viewModel::moveSeries,
|
||||||
Box(modifier = Modifier.nestedScroll(nestedScrollConnection)) {
|
onCancel = { viewModel.cancel(listOf(it)) },
|
||||||
AndroidView(
|
onCancelSeries = viewModel::cancelSeries,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.nestedScroll(nestedScrollConnection),
|
||||||
factory = { context ->
|
)
|
||||||
viewModel.controllerBinding = DownloadListBinding.inflate(LayoutInflater.from(context))
|
|
||||||
viewModel.adapter = DownloadAdapter(viewModel.listener)
|
|
||||||
viewModel.controllerBinding.root.adapter = viewModel.adapter
|
|
||||||
viewModel.adapter?.isHandleDragEnabled = true
|
|
||||||
viewModel.controllerBinding.root.layoutManager = LinearLayoutManager(context)
|
|
||||||
|
|
||||||
ViewCompat.setNestedScrollingEnabled(viewModel.controllerBinding.root, true)
|
|
||||||
|
|
||||||
scope.launch(Dispatchers.Main) {
|
|
||||||
viewModel.getDownloadStatusFlow()
|
|
||||||
.collect(viewModel::onStatusChange)
|
|
||||||
}
|
|
||||||
scope.launch(Dispatchers.Main) {
|
|
||||||
viewModel.getDownloadProgressFlow()
|
|
||||||
.collect(viewModel::onUpdateDownloadedPages)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.controllerBinding.root
|
|
||||||
},
|
|
||||||
update = {
|
|
||||||
viewModel.controllerBinding.root
|
|
||||||
.updatePadding(
|
|
||||||
left = left,
|
|
||||||
top = top,
|
|
||||||
right = right,
|
|
||||||
bottom = bottom,
|
|
||||||
)
|
|
||||||
|
|
||||||
viewModel.adapter?.updateDataSet(downloadList)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
package eu.kanade.tachiyomi.ui.download
|
package eu.kanade.tachiyomi.ui.download
|
||||||
|
|
||||||
import android.view.MenuItem
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesIntoMap
|
import dev.zacsweers.metro.ContributesIntoMap
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import dev.zacsweers.metrox.viewmodel.ViewModelKey
|
import dev.zacsweers.metrox.viewmodel.ViewModelKey
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
import kotlinx.coroutines.flow.WhileSubscribed
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.debounce
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -37,117 +26,18 @@ class DownloadQueueViewModel(
|
|||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val state: StateFlow<List<DownloadHeaderItem>> = downloadManager.queueState
|
val state: StateFlow<List<DownloadQueueSection>> = downloadManager.queueState
|
||||||
.map { downloads ->
|
.map { downloads ->
|
||||||
downloads
|
downloads
|
||||||
.groupBy { it.source }
|
.groupBy { it.source }
|
||||||
.map { entry ->
|
.map { (source, downloads) -> DownloadQueueSection(source.id, source.name, downloads) }
|
||||||
DownloadHeaderItem(entry.key.id, entry.key.name, entry.value.size).apply {
|
|
||||||
addSubItems(0, entry.value.map { DownloadItem(it, this) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
|
||||||
|
|
||||||
lateinit var controllerBinding: DownloadListBinding
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapter containing the active downloads.
|
|
||||||
*/
|
|
||||||
var adapter: DownloadAdapter? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map of jobs for active downloads.
|
|
||||||
*/
|
|
||||||
private val progressJobs = mutableMapOf<Download, Job>()
|
|
||||||
|
|
||||||
val listener = object : DownloadAdapter.DownloadItemListener {
|
|
||||||
/**
|
|
||||||
* Called when an item is released from a drag.
|
|
||||||
*
|
|
||||||
* @param position The position of the released item.
|
|
||||||
*/
|
|
||||||
override fun onItemReleased(position: Int) {
|
|
||||||
val adapter = adapter ?: return
|
|
||||||
val downloads = adapter.headerItems.flatMap { header ->
|
|
||||||
adapter.getSectionItems(header).map { item ->
|
|
||||||
(item as DownloadItem).download
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reorder(downloads)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the menu item of a download is pressed
|
|
||||||
*
|
|
||||||
* @param position The position of the item
|
|
||||||
* @param menuItem The menu Item pressed
|
|
||||||
*/
|
|
||||||
override fun onMenuItemClick(position: Int, menuItem: MenuItem) {
|
|
||||||
val item = adapter?.getItem(position) ?: return
|
|
||||||
if (item is DownloadItem) {
|
|
||||||
when (menuItem.itemId) {
|
|
||||||
R.id.move_to_top, R.id.move_to_bottom -> {
|
|
||||||
val headerItems = adapter?.headerItems ?: return
|
|
||||||
val newDownloads = mutableListOf<Download>()
|
|
||||||
headerItems.forEach { headerItem ->
|
|
||||||
headerItem as DownloadHeaderItem
|
|
||||||
if (headerItem == item.header) {
|
|
||||||
headerItem.removeSubItem(item)
|
|
||||||
if (menuItem.itemId == R.id.move_to_top) {
|
|
||||||
headerItem.addSubItem(0, item)
|
|
||||||
} else {
|
|
||||||
headerItem.addSubItem(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newDownloads.addAll(headerItem.subItems.map { it.download })
|
|
||||||
}
|
|
||||||
reorder(newDownloads)
|
|
||||||
}
|
|
||||||
R.id.move_to_top_series, R.id.move_to_bottom_series -> {
|
|
||||||
val (selectedSeries, otherSeries) = adapter?.currentItems
|
|
||||||
?.filterIsInstance<DownloadItem>()
|
|
||||||
?.map(DownloadItem::download)
|
|
||||||
?.partition { item.download.manga.id == it.manga.id }
|
|
||||||
?: Pair(emptyList(), emptyList())
|
|
||||||
if (menuItem.itemId == R.id.move_to_top_series) {
|
|
||||||
reorder(selectedSeries + otherSeries)
|
|
||||||
} else {
|
|
||||||
reorder(otherSeries + selectedSeries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
R.id.cancel_download -> {
|
|
||||||
cancel(listOf(item.download))
|
|
||||||
}
|
|
||||||
R.id.cancel_series -> {
|
|
||||||
val allDownloadsForSeries = adapter?.currentItems
|
|
||||||
?.filterIsInstance<DownloadItem>()
|
|
||||||
?.filter { item.download.manga.id == it.download.manga.id }
|
|
||||||
?.map(DownloadItem::download)
|
|
||||||
if (!allDownloadsForSeries.isNullOrEmpty()) {
|
|
||||||
cancel(allDownloadsForSeries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
for (job in progressJobs.values) {
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
progressJobs.clear()
|
|
||||||
adapter = null
|
|
||||||
}
|
|
||||||
|
|
||||||
val isDownloaderRunning = downloadManager.isDownloaderRunning
|
val isDownloaderRunning = downloadManager.isDownloaderRunning
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), false)
|
||||||
|
|
||||||
fun getDownloadStatusFlow() = downloadManager.statusFlow()
|
|
||||||
fun getDownloadProgressFlow() = downloadManager.progressFlow()
|
|
||||||
|
|
||||||
fun startDownloads() {
|
fun startDownloads() {
|
||||||
downloadManager.startDownloads()
|
downloadManager.startDownloads()
|
||||||
}
|
}
|
||||||
@@ -168,102 +58,59 @@ class DownloadQueueViewModel(
|
|||||||
viewModelScope.launch { downloadManager.cancelQueuedDownloads(downloads) }
|
viewModelScope.launch { downloadManager.cancelQueuedDownloads(downloads) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <R : Comparable<R>> reorderQueue(selector: (DownloadItem) -> R?, reverse: Boolean = false) {
|
/**
|
||||||
val adapter = adapter ?: return
|
* Moves [download] to the top or bottom of its section.
|
||||||
val newDownloads = mutableListOf<Download>()
|
*/
|
||||||
adapter.headerItems.forEach { headerItem ->
|
fun move(download: Download, toTop: Boolean) {
|
||||||
headerItem as DownloadHeaderItem
|
reorder(
|
||||||
headerItem.subItems = headerItem.subItems.sortedBy(selector).toMutableList().apply {
|
state.value.flatMap { section ->
|
||||||
if (reverse) {
|
if (download in section.downloads) {
|
||||||
reverse()
|
val others = section.downloads - download
|
||||||
|
if (toTop) listOf(download) + others else others + download
|
||||||
|
} else {
|
||||||
|
section.downloads
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
newDownloads.addAll(headerItem.subItems.map { it.download })
|
)
|
||||||
}
|
|
||||||
reorder(newDownloads)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the status of a download changes.
|
* Moves all the downloads of the manga of [download] to the top or bottom of the queue.
|
||||||
*
|
|
||||||
* @param download the download whose status has changed.
|
|
||||||
*/
|
*/
|
||||||
fun onStatusChange(download: Download) {
|
fun moveSeries(download: Download, toTop: Boolean) {
|
||||||
when (download.status) {
|
val (selectedSeries, otherSeries) = state.value
|
||||||
Download.State.DOWNLOADING -> {
|
.flatMap { it.downloads }
|
||||||
launchProgressJob(download)
|
.partition { it.manga.id == download.manga.id }
|
||||||
// Initial update of the downloaded pages
|
reorder(if (toTop) selectedSeries + otherSeries else otherSeries + selectedSeries)
|
||||||
onUpdateDownloadedPages(download)
|
}
|
||||||
}
|
|
||||||
Download.State.DOWNLOADED -> {
|
fun cancelSeries(download: Download) {
|
||||||
cancelProgressJob(download)
|
val allDownloadsForSeries = state.value
|
||||||
onUpdateProgress(download)
|
.flatMap { it.downloads }
|
||||||
onUpdateDownloadedPages(download)
|
.filter { it.manga.id == download.manga.id }
|
||||||
}
|
if (allDownloadsForSeries.isNotEmpty()) {
|
||||||
Download.State.ERROR -> cancelProgressJob(download)
|
cancel(allDownloadsForSeries)
|
||||||
else -> {
|
|
||||||
/* unused */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Observe the progress of a download and notify the view.
|
* Sorts the downloads of every section by [selector].
|
||||||
*
|
|
||||||
* @param download the download to observe its progress.
|
|
||||||
*/
|
*/
|
||||||
private fun launchProgressJob(download: Download) {
|
fun <R : Comparable<R>> reorderQueue(selector: (Download) -> R?, reverse: Boolean = false) {
|
||||||
val job = viewModelScope.launch {
|
reorder(
|
||||||
val pages = download.pagesFlow.filterNotNull().first()
|
state.value.flatMap { section ->
|
||||||
val progressFlows = pages.map(Page::progressFlow)
|
section.downloads.sortedBy(selector).let { if (reverse) it.reversed() else it }
|
||||||
combine(progressFlows, Array<Int>::sum)
|
},
|
||||||
.distinctUntilChanged()
|
)
|
||||||
.debounce(50.milliseconds)
|
|
||||||
.collectLatest {
|
|
||||||
onUpdateProgress(download)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid leaking jobs
|
|
||||||
progressJobs.remove(download)?.cancel()
|
|
||||||
|
|
||||||
progressJobs[download] = job
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsubscribes the given download from the progress subscriptions.
|
|
||||||
*
|
|
||||||
* @param download the download to unsubscribe.
|
|
||||||
*/
|
|
||||||
private fun cancelProgressJob(download: Download) {
|
|
||||||
progressJobs.remove(download)?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the progress of a download changes.
|
|
||||||
*
|
|
||||||
* @param download the download whose progress has changed.
|
|
||||||
*/
|
|
||||||
private fun onUpdateProgress(download: Download) {
|
|
||||||
getHolder(download)?.notifyProgress()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a page of a download is downloaded.
|
|
||||||
*
|
|
||||||
* @param download the download whose page has been downloaded.
|
|
||||||
*/
|
|
||||||
fun onUpdateDownloadedPages(download: Download) {
|
|
||||||
getHolder(download)?.notifyDownloadedPages()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the holder for the given download.
|
|
||||||
*
|
|
||||||
* @param download the download to find.
|
|
||||||
* @return the holder of the download or null if it's not bound.
|
|
||||||
*/
|
|
||||||
private fun getHolder(download: Download): DownloadHolder? {
|
|
||||||
return controllerBinding.root.findViewHolderForItemId(download.chapter.id) as? DownloadHolder
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads of the queue from one source.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class DownloadQueueSection(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val downloads: List<Download>,
|
||||||
|
)
|
||||||
|
|||||||
@@ -414,10 +414,7 @@ class MangaViewModel(
|
|||||||
if (isFavorited) {
|
if (isFavorited) {
|
||||||
// Remove from library
|
// Remove from library
|
||||||
if (updateManga.awaitUpdateFavorite(manga.id, false)) {
|
if (updateManga.awaitUpdateFavorite(manga.id, false)) {
|
||||||
// Remove covers and update last modified in db
|
manga.removeCovers(coverCache)
|
||||||
if (manga.removeCovers(coverCache) != manga) {
|
|
||||||
updateManga.awaitUpdateCoverLastModified(manga.id)
|
|
||||||
}
|
|
||||||
onRemoved()
|
onRemoved()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLocale
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -104,9 +105,15 @@ data class TrackInfoDialogHomeScreen(
|
|||||||
override fun Content() {
|
override fun Content() {
|
||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val locale = LocalLocale.current
|
||||||
val viewModel = assistedMetroViewModel<Model, Model.Factory> { create(mangaId = mangaId, sourceId = sourceId) }
|
val viewModel = assistedMetroViewModel<Model, Model.Factory> { create(mangaId = mangaId, sourceId = sourceId) }
|
||||||
|
|
||||||
val dateFormat = remember { UiPreferences.dateFormat(context.appGraph.uiPreferences.dateFormat.get()) }
|
val dateFormat = remember {
|
||||||
|
UiPreferences.dateFormat(
|
||||||
|
context.appGraph.uiPreferences.dateFormat.get(),
|
||||||
|
locale.platformLocale,
|
||||||
|
)
|
||||||
|
}
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
TrackInfoDialogHome(
|
TrackInfoDialogHome(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.ui.more
|
package eu.kanade.tachiyomi.ui.more
|
||||||
|
|
||||||
import androidx.compose.animation.graphics.res.animatedVectorResource
|
import androidx.compose.animation.graphics.res.animatedVectorResource
|
||||||
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
|
|
||||||
import androidx.compose.animation.graphics.vector.AnimatedImageVector
|
import androidx.compose.animation.graphics.vector.AnimatedImageVector
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -23,6 +22,7 @@ import eu.kanade.core.preference.asState
|
|||||||
import eu.kanade.domain.base.BasePreferences
|
import eu.kanade.domain.base.BasePreferences
|
||||||
import eu.kanade.presentation.more.MoreScreen
|
import eu.kanade.presentation.more.MoreScreen
|
||||||
import eu.kanade.presentation.util.Tab
|
import eu.kanade.presentation.util.Tab
|
||||||
|
import eu.kanade.presentation.util.rememberAnimatedVectorPainter
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
||||||
@@ -45,11 +45,12 @@ data object MoreTab : Tab {
|
|||||||
@Composable
|
@Composable
|
||||||
get() {
|
get() {
|
||||||
val isSelected = LocalTabNavigator.current.current.key == key
|
val isSelected = LocalTabNavigator.current.current.key == key
|
||||||
val image = AnimatedImageVector.animatedVectorResource(R.drawable.animated_more)
|
val enter = AnimatedImageVector.animatedVectorResource(R.drawable.animated_more_enter)
|
||||||
|
val exit = AnimatedImageVector.animatedVectorResource(R.drawable.animated_more_exit)
|
||||||
return TabOptions(
|
return TabOptions(
|
||||||
index = 4u,
|
index = 4u,
|
||||||
title = stringResource(MR.strings.label_more),
|
title = stringResource(MR.strings.label_more),
|
||||||
icon = rememberAnimatedVectorPainter(image, isSelected),
|
icon = rememberAnimatedVectorPainter(enter, exit, isSelected),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ import androidx.core.view.ViewCompat
|
|||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||||
import com.google.android.material.transition.platform.MaterialContainerTransform
|
import com.google.android.material.transition.platform.MaterialContainerTransform
|
||||||
|
import com.hippo.unifile.UniFile
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import eu.kanade.domain.base.BasePreferences
|
import eu.kanade.domain.base.BasePreferences
|
||||||
import eu.kanade.presentation.reader.DisplayRefreshHost
|
import eu.kanade.presentation.reader.DisplayRefreshHost
|
||||||
@@ -56,6 +58,7 @@ import eu.kanade.presentation.reader.appbars.ReaderAppBars
|
|||||||
import eu.kanade.presentation.reader.components.ChapterNavigatorType
|
import eu.kanade.presentation.reader.components.ChapterNavigatorType
|
||||||
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
|
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.coil.ImageDecoder
|
||||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||||
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
|
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
|
||||||
@@ -74,7 +77,6 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
|
|||||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
|
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
|
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer
|
|
||||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
import eu.kanade.tachiyomi.util.system.readerBackgroundColor
|
import eu.kanade.tachiyomi.util.system.readerBackgroundColor
|
||||||
@@ -99,6 +101,7 @@ import tachiyomi.core.common.i18n.stringResource
|
|||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.util.collectAsState
|
import tachiyomi.presentation.core.util.collectAsState
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class ReaderActivity : BaseActivity() {
|
class ReaderActivity : BaseActivity() {
|
||||||
@@ -473,7 +476,7 @@ class ReaderActivity : BaseActivity() {
|
|||||||
onShare = ::shareChapter.takeIf { isHttpSource },
|
onShare = ::shareChapter.takeIf { isHttpSource },
|
||||||
|
|
||||||
chapterNavigatorType = if (!verticalNavigator) {
|
chapterNavigatorType = if (!verticalNavigator) {
|
||||||
if (state.viewer is R2LPagerViewer || (state.viewer as? WebGpuViewer)?.isReversed ?: false) {
|
if (state.viewer is R2LPagerViewer) {
|
||||||
ChapterNavigatorType.HORIZONTAL_RTL
|
ChapterNavigatorType.HORIZONTAL_RTL
|
||||||
} else {
|
} else {
|
||||||
ChapterNavigatorType.HORIZONTAL_LTR
|
ChapterNavigatorType.HORIZONTAL_LTR
|
||||||
@@ -860,6 +863,10 @@ class ReaderActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
|
|
||||||
|
preferences.displayProfile.changes()
|
||||||
|
.onEach { setDisplayProfile(it) }
|
||||||
|
.launchIn(lifecycleScope)
|
||||||
|
|
||||||
readerPreferences.keepScreenOn.changes()
|
readerPreferences.keepScreenOn.changes()
|
||||||
.onEach(::setKeepScreenOn)
|
.onEach(::setKeepScreenOn)
|
||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
@@ -887,6 +894,25 @@ class ReaderActivity : BaseActivity() {
|
|||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the display profile to [path].
|
||||||
|
*/
|
||||||
|
private fun setDisplayProfile(path: String) {
|
||||||
|
val file = UniFile.fromUri(baseContext, path.toUri())
|
||||||
|
if (file != null && file.exists()) {
|
||||||
|
val inputStream = file.openInputStream()
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
inputStream.use { input ->
|
||||||
|
outputStream.use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val data = outputStream.toByteArray()
|
||||||
|
SubsamplingScaleImageView.setDisplayProfile(data)
|
||||||
|
ImageDecoder.displayProfile = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the keep screen on mode according to [enabled].
|
* Sets the keep screen on mode according to [enabled].
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -231,23 +231,14 @@ internal class HttpPageLoader(
|
|||||||
val imageUrl = page.imageUrl!!
|
val imageUrl = page.imageUrl!!
|
||||||
|
|
||||||
if (force || !chapterCache.isImageInCache(imageUrl)) {
|
if (force || !chapterCache.isImageInCache(imageUrl)) {
|
||||||
val live = page.downloadStream
|
|
||||||
page.status = Page.State.DownloadImage
|
page.status = Page.State.DownloadImage
|
||||||
try {
|
val imageResponse = source.getImage(page)
|
||||||
val imageResponse = source.getImage(page)
|
chapterCache.putImageToCache(imageUrl, imageResponse)
|
||||||
chapterCache.putImageToCache(imageUrl, imageResponse, live)
|
|
||||||
live?.finish()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
live?.finish(e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
page.downloadStream = null
|
|
||||||
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
|
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
|
||||||
page.status = Page.State.Ready
|
page.status = Page.State.Ready
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
page.downloadStream = null
|
|
||||||
if (e is CancellationException) {
|
if (e is CancellationException) {
|
||||||
// Chunk chains get cancelled on every page turn, so a cancelled page isn't an error. Put it back in
|
// Chunk chains get cancelled on every page turn, so a cancelled page isn't an error. Put it back in
|
||||||
// the queue, otherwise it's stuck in a state no later pass will pick up.
|
// the queue, otherwise it's stuck in a state no later pass will pick up.
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.reader.model
|
|
||||||
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A download's bytes as they arrive, for one reader. Past [MAX_BYTES], or once that reader is
|
|
||||||
* closed, it stops keeping them; [append] never throws, so the download itself carries on.
|
|
||||||
*/
|
|
||||||
class DownloadStream {
|
|
||||||
private val lock = Object()
|
|
||||||
private var data = ByteArray(1 shl 16)
|
|
||||||
private var size = 0
|
|
||||||
private var done = false
|
|
||||||
private var error: Throwable? = null
|
|
||||||
|
|
||||||
/** Presizes for [bytes], e.g. Content-Length; ignored if implausible. */
|
|
||||||
fun reserve(bytes: Long) {
|
|
||||||
synchronized(lock) {
|
|
||||||
if (error == null && bytes > data.size && bytes <= MAX_RESERVE) {
|
|
||||||
try {
|
|
||||||
data = data.copyOf(bytes.toInt())
|
|
||||||
} catch (e: OutOfMemoryError) {
|
|
||||||
// Only a hint.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun append(src: ByteArray, offset: Int, length: Int) {
|
|
||||||
synchronized(lock) {
|
|
||||||
if (error != null || done) return
|
|
||||||
val needed = size.toLong() + length
|
|
||||||
if (needed > MAX_BYTES) {
|
|
||||||
// Too big to decode while downloading; the whole-file decode takes over.
|
|
||||||
drop("Download too large to stream")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (needed > data.size) {
|
|
||||||
data = try {
|
|
||||||
data.copyOf(maxOf(needed, minOf(data.size.toLong() * 2, MAX_BYTES)).toInt())
|
|
||||||
} catch (e: OutOfMemoryError) {
|
|
||||||
// As too large: the download mustn't fail over this copy.
|
|
||||||
drop("Out of memory buffering the download")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
System.arraycopy(src, offset, data, size, length)
|
|
||||||
size += length
|
|
||||||
lock.notifyAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ends the bytes; readers throw [error] if given. */
|
|
||||||
fun finish(error: Throwable? = null) {
|
|
||||||
synchronized(lock) {
|
|
||||||
done = true
|
|
||||||
if (this.error == null) this.error = error
|
|
||||||
lock.notifyAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** From the start; [Reader.read] blocks for bytes. Closing it drops what's kept. */
|
|
||||||
fun reader(): Reader = Reader()
|
|
||||||
|
|
||||||
// Rather than hold a second copy of the page.
|
|
||||||
private fun drop(why: String) {
|
|
||||||
if (error == null) error = IOException(why)
|
|
||||||
data = ByteArray(0)
|
|
||||||
size = 0
|
|
||||||
lock.notifyAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
// As the decoder's own input limit.
|
|
||||||
const val MAX_BYTES = 1L shl 30
|
|
||||||
const val MAX_RESERVE = 64L shl 20
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class Reader : InputStream() {
|
|
||||||
private var position = 0
|
|
||||||
|
|
||||||
/** All read; throws if the download failed. */
|
|
||||||
val ended: Boolean
|
|
||||||
get() = synchronized(lock) {
|
|
||||||
checkError()
|
|
||||||
done && position >= size
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun read(): Int {
|
|
||||||
val b = ByteArray(1)
|
|
||||||
return if (read(b, 0, 1) < 0) -1 else b[0].toInt() and 0xFF
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun read(b: ByteArray, off: Int, len: Int): Int {
|
|
||||||
synchronized(lock) {
|
|
||||||
if (len == 0) return 0
|
|
||||||
while (position >= size && !done && error == null) lock.wait()
|
|
||||||
checkError()
|
|
||||||
if (position >= size) return -1
|
|
||||||
val n = minOf(len, size - position)
|
|
||||||
System.arraycopy(data, position, b, off, n)
|
|
||||||
position += n
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun available(): Int = synchronized(lock) { if (error != null) 0 else size - position }
|
|
||||||
|
|
||||||
/** Waits up to [timeoutMs] for [min] unread bytes or the end. */
|
|
||||||
fun await(min: Int, timeoutMs: Long) {
|
|
||||||
synchronized(lock) {
|
|
||||||
val until = System.nanoTime() + timeoutMs * 1_000_000
|
|
||||||
while (size - position < min && !done && error == null) {
|
|
||||||
val left = (until - System.nanoTime()) / 1_000_000
|
|
||||||
if (left <= 0) return
|
|
||||||
lock.wait(left)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
synchronized(lock) { drop("No longer read") }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkError() {
|
|
||||||
error?.let { throw IOException("Download failed", it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,4 @@ open class ReaderPage(
|
|||||||
) : Page(index, url, imageUrl, null) {
|
) : Page(index, url, imageUrl, null) {
|
||||||
|
|
||||||
open lateinit var chapter: ReaderChapter
|
open lateinit var chapter: ReaderChapter
|
||||||
|
|
||||||
/** Set by a viewer before the download; filled during [Page.State.DownloadImage]. */
|
|
||||||
@Volatile
|
|
||||||
var downloadStream: DownloadStream? = null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,11 +142,6 @@ class ReaderPreferences(
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|
||||||
val dualPageView: Preference<DualPageView> = preferenceStore.getEnum(
|
|
||||||
"pref_dual_page_view",
|
|
||||||
DualPageView.NEVER,
|
|
||||||
)
|
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Color filter
|
// region Color filter
|
||||||
@@ -204,24 +199,6 @@ class ReaderPreferences(
|
|||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region WebGpu
|
|
||||||
|
|
||||||
val transitionAnimation: Preference<TransitionAnimation> =
|
|
||||||
preferenceStore.getEnum("webgpu_transition_animation", TransitionAnimation.BASIC)
|
|
||||||
|
|
||||||
val transitionAnimationDual: Preference<TransitionAnimation> =
|
|
||||||
preferenceStore.getEnum("webgpu_dual_transition_animation", TransitionAnimation.BASIC)
|
|
||||||
|
|
||||||
val cutoutMode: Preference<CutoutMode> = preferenceStore.getEnum("webgpu_cutout_mode", CutoutMode.AVOID)
|
|
||||||
|
|
||||||
val cutoutModeDual: Preference<CutoutMode> = preferenceStore.getEnum("webgpu_dual_cutout_mode", CutoutMode.IGNORE)
|
|
||||||
|
|
||||||
val continuousMinWidth: Preference<Int> = preferenceStore.getInt("webgpu_continuous_minwidth", 100)
|
|
||||||
|
|
||||||
val continuousGap: Preference<Int> = preferenceStore.getInt("webgpu_continuous_gap", 10)
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
enum class FlashColor {
|
enum class FlashColor {
|
||||||
BLACK,
|
BLACK,
|
||||||
WHITE,
|
WHITE,
|
||||||
@@ -234,14 +211,8 @@ class ReaderPreferences(
|
|||||||
val shouldInvertVertical: Boolean = false,
|
val shouldInvertVertical: Boolean = false,
|
||||||
) {
|
) {
|
||||||
NONE(MR.strings.tapping_inverted_none),
|
NONE(MR.strings.tapping_inverted_none),
|
||||||
HORIZONTAL(
|
HORIZONTAL(MR.strings.tapping_inverted_horizontal, shouldInvertHorizontal = true),
|
||||||
MR.strings.tapping_inverted_horizontal,
|
VERTICAL(MR.strings.tapping_inverted_vertical, shouldInvertVertical = true),
|
||||||
shouldInvertHorizontal = true,
|
|
||||||
),
|
|
||||||
VERTICAL(
|
|
||||||
MR.strings.tapping_inverted_vertical,
|
|
||||||
shouldInvertVertical = true,
|
|
||||||
),
|
|
||||||
BOTH(MR.strings.tapping_inverted_both, shouldInvertHorizontal = true, shouldInvertVertical = true),
|
BOTH(MR.strings.tapping_inverted_both, shouldInvertHorizontal = true, shouldInvertVertical = true),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,43 +223,6 @@ class ReaderPreferences(
|
|||||||
LOWEST(47),
|
LOWEST(47),
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class TransitionAnimation(val titleRes: StringResource) {
|
|
||||||
BASIC(MR.strings.transition_animation_basic),
|
|
||||||
FLIP(MR.strings.transition_animation_flip),
|
|
||||||
FLIP_LEFT(MR.strings.transition_animation_flip_left),
|
|
||||||
FLIP_RIGHT(
|
|
||||||
MR.strings.transition_animation_flip_right,
|
|
||||||
),
|
|
||||||
STACK_LEFT(MR.strings.transition_animation_stack_left),
|
|
||||||
STACK_RIGHT(MR.strings.transition_animation_stack_right),
|
|
||||||
STACK_UP(
|
|
||||||
MR.strings.transition_animation_stack_up,
|
|
||||||
),
|
|
||||||
STACK_DOWN(MR.strings.transition_animation_stack_down),
|
|
||||||
SPHERE(MR.strings.transition_animation_sphere),
|
|
||||||
CUBE_INSIDE(
|
|
||||||
MR.strings.transition_animation_cube_inside,
|
|
||||||
),
|
|
||||||
CUBE_OUTSIDE(MR.strings.transition_animation_cube_outside),
|
|
||||||
FADE(MR.strings.transition_animation_fade),
|
|
||||||
FADE_WHITE(
|
|
||||||
MR.strings.transition_animation_fade_white,
|
|
||||||
),
|
|
||||||
NONE(MR.strings.transition_animation_none),
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class CutoutMode(val titleRes: StringResource) {
|
|
||||||
IGNORE(MR.strings.cutout_mode_ignore),
|
|
||||||
AVOID(MR.strings.cutout_mode_avoid),
|
|
||||||
SHIFT(MR.strings.cutout_mode_shift),
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class DualPageView(val titleRes: StringResource) {
|
|
||||||
NEVER(MR.strings.dual_page_view_never),
|
|
||||||
ALWAYS(MR.strings.dual_page_view_always),
|
|
||||||
WIDE(MR.strings.dual_page_view_wide),
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val WEBTOON_PADDING_MIN = 0
|
const val WEBTOON_PADDING_MIN = 0
|
||||||
const val WEBTOON_PADDING_MAX = 25
|
const val WEBTOON_PADDING_MAX = 25
|
||||||
@@ -313,13 +247,6 @@ class ReaderPreferences(
|
|||||||
MR.strings.scale_type_smart_fit,
|
MR.strings.scale_type_smart_fit,
|
||||||
)
|
)
|
||||||
|
|
||||||
val ImageScaleTypeWebGpuViewer = listOf(
|
|
||||||
MR.strings.scale_type_fit_screen,
|
|
||||||
MR.strings.scale_type_fit_width,
|
|
||||||
MR.strings.scale_type_fit_height,
|
|
||||||
MR.strings.scale_type_original_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
val ZoomStart = listOf(
|
val ZoomStart = listOf(
|
||||||
MR.strings.zoom_start_automatic,
|
MR.strings.zoom_start_automatic,
|
||||||
MR.strings.zoom_start_left,
|
MR.strings.zoom_start_left,
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
|
|||||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer
|
import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
|
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer
|
import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewerContinuous
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
|
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
|
||||||
import mihon.app.di.appGraph
|
|
||||||
import mihon.icons.custommaterialsymbols.CustomMaterialSymbols
|
import mihon.icons.custommaterialsymbols.CustomMaterialSymbols
|
||||||
import mihon.icons.custommaterialsymbols.rounded.ReaderLeftToRight
|
import mihon.icons.custommaterialsymbols.rounded.ReaderLeftToRight
|
||||||
import mihon.icons.custommaterialsymbols.rounded.ReaderLongStrip
|
import mihon.icons.custommaterialsymbols.rounded.ReaderLongStrip
|
||||||
@@ -77,16 +74,6 @@ enum class ReadingMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun toViewer(preference: Int?, activity: ReaderActivity): Viewer {
|
fun toViewer(preference: Int?, activity: ReaderActivity): Viewer {
|
||||||
if (activity.appGraph.basePreferences.highQualityRenderer.get()) {
|
|
||||||
return when (fromPreference(preference)) {
|
|
||||||
LEFT_TO_RIGHT -> WebGpuViewer(activity, isReversed = false, isVertical = false)
|
|
||||||
RIGHT_TO_LEFT -> WebGpuViewer(activity, isReversed = true, isVertical = false)
|
|
||||||
VERTICAL -> WebGpuViewer(activity, isReversed = false, isVertical = true)
|
|
||||||
WEBTOON -> WebGpuViewerContinuous(activity, useGap = false)
|
|
||||||
CONTINUOUS_VERTICAL -> WebGpuViewerContinuous(activity, useGap = true)
|
|
||||||
DEFAULT -> throw IllegalStateException("Preference value must be resolved: $preference")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return when (fromPreference(preference)) {
|
return when (fromPreference(preference)) {
|
||||||
LEFT_TO_RIGHT -> L2RPagerViewer(activity)
|
LEFT_TO_RIGHT -> L2RPagerViewer(activity)
|
||||||
RIGHT_TO_LEFT -> R2LPagerViewer(activity)
|
RIGHT_TO_LEFT -> R2LPagerViewer(activity)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonStripImageView
|
|||||||
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
||||||
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
|
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
|
||||||
import okio.BufferedSource
|
import okio.BufferedSource
|
||||||
|
import tachiyomi.core.common.util.system.ImageUtil
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapper view for showing page image.
|
* A wrapper view for showing page image.
|
||||||
@@ -240,6 +241,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
|||||||
removeView(pageView)
|
removeView(pageView)
|
||||||
|
|
||||||
pageView = SubsamplingScaleImageView(context).apply {
|
pageView = SubsamplingScaleImageView(context).apply {
|
||||||
|
setMaxTileSize(ImageUtil.hardwareBitmapThreshold)
|
||||||
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER)
|
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER)
|
||||||
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
|
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
|
||||||
setMinimumTileDpi(180)
|
setMinimumTileDpi(180)
|
||||||
@@ -300,6 +302,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
|||||||
isVisible = true
|
isVisible = true
|
||||||
}
|
}
|
||||||
is BufferedSource -> {
|
is BufferedSource -> {
|
||||||
|
setHardwareConfig(ImageUtil.canUseHardwareBitmap(data))
|
||||||
setImage(ImageSource.inputStream(data.inputStream()))
|
setImage(ImageSource.inputStream(data.inputStream()))
|
||||||
isVisible = true
|
isVisible = true
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-10
@@ -26,7 +26,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
|||||||
/**
|
/**
|
||||||
* Holds preprocessed items so they don't get removed when changing chapter
|
* Holds preprocessed items so they don't get removed when changing chapter
|
||||||
*/
|
*/
|
||||||
private var preprocessed: MutableMap<Int, InsertPage> = mutableMapOf()
|
private var preprocessed: List<InsertPage> = emptyList()
|
||||||
|
|
||||||
var nextTransition: ChapterTransition.Next? = null
|
var nextTransition: ChapterTransition.Next? = null
|
||||||
private set
|
private set
|
||||||
@@ -68,13 +68,15 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
|||||||
|
|
||||||
val lastPage = pages.last()
|
val lastPage = pages.last()
|
||||||
|
|
||||||
// Insert preprocessed pages into current page list
|
// Insert preprocessed pages into current page list. Pages of the adjacent chapters are split too, and
|
||||||
preprocessed.keys.sortedDescending()
|
// placed by their index, so only this chapter's belong here.
|
||||||
.forEach { key ->
|
preprocessed.filter { it.chapter.chapter.id == chapters.currChapter.chapter.id }
|
||||||
if (lastPage.index == key) {
|
.sortedByDescending { it.index }
|
||||||
insertPageLastPage = preprocessed[key]
|
.forEach { page ->
|
||||||
|
if (lastPage.index == page.index) {
|
||||||
|
insertPageLastPage = page
|
||||||
}
|
}
|
||||||
preprocessed[key]?.let { pages.add(key + 1, it) }
|
pages.add(page.index + 1, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
newItems.addAll(pages)
|
newItems.addAll(pages)
|
||||||
@@ -105,7 +107,8 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
|||||||
newItems.reverse()
|
newItems.reverse()
|
||||||
}
|
}
|
||||||
|
|
||||||
preprocessed = mutableMapOf()
|
val adjacentChapterIds = setOfNotNull(chapters.prevChapter?.chapter?.id, chapters.nextChapter?.chapter?.id)
|
||||||
|
preprocessed = preprocessed.filter { it.chapter.chapter.id in adjacentChapterIds }
|
||||||
items = newItems
|
items = newItems
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
|
|
||||||
@@ -161,9 +164,12 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
|||||||
|
|
||||||
val currentIndex = items.indexOf(currentPage)
|
val currentIndex = items.indexOf(currentPage)
|
||||||
|
|
||||||
// Put aside preprocessed pages for next chapter so they don't get removed when changing chapter
|
// Put aside pages of an adjacent chapter until it becomes the current one, so they are not lost on the change
|
||||||
if (currentPage.chapter.chapter.id != currentChapter?.chapter?.id) {
|
if (currentPage.chapter.chapter.id != currentChapter?.chapter?.id) {
|
||||||
preprocessed[newPage.index] = newPage
|
val chapterId = newPage.chapter.chapter.id
|
||||||
|
if (preprocessed.none { it.chapter.chapter.id == chapterId && it.index == newPage.index }) {
|
||||||
|
preprocessed = preprocessed + newPage
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration used by pager viewers.
|
|
||||||
*/
|
|
||||||
class WebGpuConfig(
|
|
||||||
private val viewer: WebGpuViewer,
|
|
||||||
scope: CoroutineScope,
|
|
||||||
readerPreferences: ReaderPreferences,
|
|
||||||
) : ViewerConfig(readerPreferences, scope) {
|
|
||||||
|
|
||||||
var theme = readerPreferences.readerTheme.get()
|
|
||||||
private set
|
|
||||||
|
|
||||||
var automaticBackground = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
var dualPageSplitChangedListener: ((Boolean) -> Unit)? = null
|
|
||||||
|
|
||||||
var imageScaleType = 1
|
|
||||||
private set
|
|
||||||
|
|
||||||
var imageZoomType = ReaderPageImageView.ZoomStartPosition.LEFT
|
|
||||||
private set
|
|
||||||
|
|
||||||
var imageCropBorders = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
var imageCropBordersWebtoon = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
var navigateToPan = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
var landscapeZoom = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
var transitionAnimation = ReaderPreferences.TransitionAnimation.BASIC
|
|
||||||
private set
|
|
||||||
|
|
||||||
var transitionAnimationDual = ReaderPreferences.TransitionAnimation.BASIC
|
|
||||||
private set
|
|
||||||
|
|
||||||
var cutoutMode = ReaderPreferences.CutoutMode.AVOID
|
|
||||||
private set
|
|
||||||
|
|
||||||
var cutoutModeDual = ReaderPreferences.CutoutMode.AVOID
|
|
||||||
private set
|
|
||||||
|
|
||||||
var dualPageView = ReaderPreferences.DualPageView.NEVER
|
|
||||||
private set
|
|
||||||
|
|
||||||
var continuousMinWidth = 100
|
|
||||||
private set
|
|
||||||
|
|
||||||
var zoomOutDisabled = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
var continuousGap = 10
|
|
||||||
private set
|
|
||||||
|
|
||||||
init {
|
|
||||||
readerPreferences.readerTheme.register(
|
|
||||||
{
|
|
||||||
theme = it
|
|
||||||
automaticBackground = it == 3
|
|
||||||
},
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.imageScaleType.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
|
|
||||||
|
|
||||||
readerPreferences.zoomStart.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
|
|
||||||
|
|
||||||
readerPreferences.cropBorders.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
|
|
||||||
|
|
||||||
readerPreferences.cropBordersWebtoon.register(
|
|
||||||
{ imageCropBordersWebtoon = it },
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.navigateToPan.register({ navigateToPan = it })
|
|
||||||
|
|
||||||
readerPreferences.landscapeZoom.register({ landscapeZoom = it }, { imagePropertyChangedListener?.invoke() })
|
|
||||||
|
|
||||||
readerPreferences.navigationModePager.register({ navigationMode = it }, { updateNavigation(navigationMode) })
|
|
||||||
|
|
||||||
readerPreferences.pagerNavInverted.register({ tappingInverted = it }, { navigator.invertMode = it })
|
|
||||||
readerPreferences.pagerNavInverted.changes().drop(1).onEach { navigationModeChangedListener?.invoke() }
|
|
||||||
.launchIn(scope)
|
|
||||||
|
|
||||||
readerPreferences.dualPageSplitPaged.register(
|
|
||||||
{ dualPageSplit = it },
|
|
||||||
{
|
|
||||||
imagePropertyChangedListener?.invoke()
|
|
||||||
dualPageSplitChangedListener?.invoke(it)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.dualPageInvertPaged.register(
|
|
||||||
{ dualPageInvert = it },
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.dualPageRotateToFit.register(
|
|
||||||
{ dualPageRotateToFit = it },
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.dualPageRotateToFitInvert.register(
|
|
||||||
{ dualPageRotateToFitInvert = it },
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.transitionAnimation.register(
|
|
||||||
{ transitionAnimation = it },
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.transitionAnimationDual.register(
|
|
||||||
{ transitionAnimationDual = it },
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.cutoutMode.register(
|
|
||||||
{ cutoutMode = it },
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.cutoutModeDual.register(
|
|
||||||
{ cutoutModeDual = it },
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.dualPageView.register(
|
|
||||||
{ dualPageView = it },
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.continuousMinWidth.register(
|
|
||||||
{ continuousMinWidth = it },
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.webtoonDisableZoomOut.register(
|
|
||||||
{ zoomOutDisabled = it },
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
|
|
||||||
readerPreferences.continuousGap.register(
|
|
||||||
{ continuousGap = it },
|
|
||||||
{ imagePropertyChangedListener?.invoke() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun zoomTypeFromPreference(value: Int) {
|
|
||||||
imageZoomType = when (value) {
|
|
||||||
// Auto
|
|
||||||
1 -> if (viewer.isReversed) {
|
|
||||||
ReaderPageImageView.ZoomStartPosition.RIGHT
|
|
||||||
} else {
|
|
||||||
ReaderPageImageView.ZoomStartPosition.LEFT
|
|
||||||
}
|
|
||||||
// Left
|
|
||||||
2 -> ReaderPageImageView.ZoomStartPosition.LEFT
|
|
||||||
// Right
|
|
||||||
3 -> ReaderPageImageView.ZoomStartPosition.RIGHT
|
|
||||||
// Center
|
|
||||||
else -> ReaderPageImageView.ZoomStartPosition.CENTER
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override var navigator: ViewerNavigation = defaultNavigation()
|
|
||||||
set(value) {
|
|
||||||
field = value.also { it.invertMode = this.tappingInverted }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun defaultNavigation(): ViewerNavigation {
|
|
||||||
return if (viewer.isVertical) {
|
|
||||||
LNavigation()
|
|
||||||
} else {
|
|
||||||
RightAndLeftNavigation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateNavigation(navigationMode: Int) {
|
|
||||||
navigator = when (navigationMode) {
|
|
||||||
0 -> defaultNavigation()
|
|
||||||
1 -> LNavigation()
|
|
||||||
2 -> KindlishNavigation()
|
|
||||||
3 -> EdgeNavigation()
|
|
||||||
4 -> RightAndLeftNavigation()
|
|
||||||
5 -> DisabledNavigation()
|
|
||||||
else -> defaultNavigation()
|
|
||||||
}
|
|
||||||
navigationModeChangedListener?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
-102
@@ -1,102 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
|
|
||||||
|
|
||||||
import ca.mpreg.webgpuviewer.ImageViewContinuous
|
|
||||||
import ca.mpreg.webgpuviewer.viewer.ImagePage
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.math.max
|
|
||||||
|
|
||||||
class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = false) :
|
|
||||||
WebGpuViewer(activity, isReversed = false, isVertical = true, pager = ImageViewContinuous(activity)) {
|
|
||||||
|
|
||||||
override val isContinuous: Boolean get() = true
|
|
||||||
|
|
||||||
// How many pages the viewport shows depends on the zoom, and a page on screen has to be
|
|
||||||
// decoded rather than merely reserved - so the window follows what the last frame reached.
|
|
||||||
override val preloadAhead get() = max(3, state.pagesBelow)
|
|
||||||
override val preloadBehind get() = max(2, state.pagesAbove)
|
|
||||||
|
|
||||||
override val cacheSize get() = 2 + 2 * max(4, state.pagesAbove + state.pagesBelow)
|
|
||||||
|
|
||||||
private val state get() = (pager as ImageViewContinuous).state
|
|
||||||
|
|
||||||
init {
|
|
||||||
state.backgroundColor = readerBackgroundColor()
|
|
||||||
|
|
||||||
state.onViewport = { readThrough ->
|
|
||||||
val pageChanged = readThrough !== lastReadThrough
|
|
||||||
if (pageChanged) {
|
|
||||||
lastReadThrough = readThrough
|
|
||||||
readThrough?.let { select(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val page0 = state.getPage(0)
|
|
||||||
val edge = (page0 is TransitionPage && page0.prevChapter == null) ||
|
|
||||||
(readThrough is TransitionPage && readThrough.nextChapter == null)
|
|
||||||
|
|
||||||
val first = wasAtEdge == null
|
|
||||||
val edgeChanged = edge != wasAtEdge
|
|
||||||
wasAtEdge = edge
|
|
||||||
if (edge) {
|
|
||||||
if (edgeChanged) scope.launch { activity.showMenu() }
|
|
||||||
} else if (!first && (edgeChanged || pageChanged) && !activity.isScrollingThroughPages) {
|
|
||||||
scope.launch { activity.hideMenu() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Null until the first frame, so opening mid-document neither shows nor hides. */
|
|
||||||
@Volatile
|
|
||||||
private var wasAtEdge: Boolean? = null
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var lastReadThrough: ImagePage? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark [imagePage] read - its bottom has cleared the viewport, which is later than
|
|
||||||
* onPageChange's "top arrived" and the reason this mode selects from here: scrolling up to
|
|
||||||
* reveal the page above would otherwise walk progress back past pages still on screen.
|
|
||||||
*/
|
|
||||||
private fun select(imagePage: ImagePage) {
|
|
||||||
val page = readerPageFor(imagePage) ?: return
|
|
||||||
scope.launch { activity.onPageSelected(page) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readerPageFor(imagePage: ImagePage): ReaderPage? {
|
|
||||||
// Off the page itself: a transition page is never currentPage (the scroll walk stops
|
|
||||||
// before it), so it is the first thing a preload evicts.
|
|
||||||
if (imagePage is TransitionPage) return imagePage.prevChapter?.pages?.lastOrNull()
|
|
||||||
|
|
||||||
// Only an image page needs the lookup - nothing on it names its ReaderPage. Evicted
|
|
||||||
// means scrolled well clear of, so there is no progress left to report.
|
|
||||||
return (viewerPageFor(imagePage) as? ViewerReaderPage)?.page
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun destroy() {
|
|
||||||
state.onViewport = null
|
|
||||||
lastReadThrough = null
|
|
||||||
wasAtEdge = null
|
|
||||||
super.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scrollByHalfPage(direction: Int) {
|
|
||||||
state.animateScroll(direction * state.height / 2f)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun moveRight() = scrollByHalfPage(1)
|
|
||||||
|
|
||||||
override fun moveLeft() = scrollByHalfPage(-1)
|
|
||||||
|
|
||||||
override fun moveToPage(page: ReaderPage) {
|
|
||||||
super.moveToPage(page)
|
|
||||||
wasAtEdge = null
|
|
||||||
lastReadThrough = null
|
|
||||||
// Also for a jump to the page already showing, which turns nothing to slide in.
|
|
||||||
state.resetScroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun animateTurn(direction: Int, fromSpread: ImagePage) {
|
|
||||||
state.animateSlideIn(direction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-1
@@ -243,7 +243,9 @@ class WebtoonRecyclerView @JvmOverloads constructor(
|
|||||||
inner class GestureListener : GestureDetectorWithLongTap.Listener() {
|
inner class GestureListener : GestureDetectorWithLongTap.Listener() {
|
||||||
|
|
||||||
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
|
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
|
||||||
if (!tapDuringManualScroll) {
|
// A tap is confirmed after a delay, by when the reader can be gone, and scrolling a detached list
|
||||||
|
// lays out pages that can't compose
|
||||||
|
if (!tapDuringManualScroll && isAttachedToWindow) {
|
||||||
tapListener?.invoke(ev)
|
tapListener?.invoke(ev)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
|
|||||||
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
||||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
import eu.kanade.tachiyomi.util.system.toShareIntent
|
import eu.kanade.tachiyomi.util.system.toShareIntent
|
||||||
|
import eu.kanade.tachiyomi.util.system.toWebViewHeaders
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import eu.kanade.tachiyomi.util.view.setComposeContent
|
import eu.kanade.tachiyomi.util.view.setComposeContent
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -71,7 +72,7 @@ class WebViewActivity : BaseActivity() {
|
|||||||
val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource
|
val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource
|
||||||
value = withContext(Dispatchers.IO) {
|
value = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
source?.headers?.toMultimap()?.mapValues { it.value.getOrNull(0) ?: "" }.orEmpty()
|
source?.headers?.toWebViewHeaders().orEmpty()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e) { "Failed to build headers" }
|
logcat(LogPriority.ERROR, e) { "Failed to build headers" }
|
||||||
emptyMap()
|
emptyMap()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
|
|||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
import eu.kanade.tachiyomi.util.system.toShareIntent
|
import eu.kanade.tachiyomi.util.system.toShareIntent
|
||||||
|
import eu.kanade.tachiyomi.util.system.toWebViewHeaders
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -47,7 +48,7 @@ class WebViewViewModel(
|
|||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val source = sourceId?.let { sourceManager.get(it) as? HttpSource }
|
val source = sourceId?.let { sourceManager.get(it) as? HttpSource }
|
||||||
headers.value = try {
|
headers.value = try {
|
||||||
source?.headers?.toMultimap()?.mapValues { it.value.getOrNull(0) ?: "" }.orEmpty()
|
source?.headers?.toWebViewHeaders().orEmpty()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e) { "Failed to build headers" }
|
logcat(LogPriority.ERROR, e) { "Failed to build headers" }
|
||||||
emptyMap()
|
emptyMap()
|
||||||
|
|||||||
@@ -7,15 +7,10 @@ import tachiyomi.domain.manga.model.Manga
|
|||||||
import tachiyomi.source.local.image.LocalCoverManager
|
import tachiyomi.source.local.image.LocalCoverManager
|
||||||
import tachiyomi.source.local.isLocal
|
import tachiyomi.source.local.isLocal
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import kotlin.time.Clock
|
|
||||||
|
|
||||||
fun Manga.removeCovers(coverCache: CoverCache): Manga {
|
fun Manga.removeCovers(coverCache: CoverCache) {
|
||||||
if (isLocal()) return this
|
if (isLocal()) return
|
||||||
return if (coverCache.deleteFromCache(this, true) > 0) {
|
coverCache.deleteFromCache(this, true)
|
||||||
copy(coverLastModified = Clock.System.now())
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun Manga.editCover(
|
suspend fun Manga.editCover(
|
||||||
|
|||||||
@@ -13,21 +13,17 @@ import tachiyomi.core.common.i18n.pluralStringResource
|
|||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
fun LocalDateTime.toDateTimestampString(dateTimeFormatter: DateTimeFormatter): String {
|
fun LocalDateTime.toDateTimestampString(dateTimeFormatter: DateTimeFormatter): String {
|
||||||
val javaLocalDateTime = this.toJavaLocalDateTime()
|
val javaLocalDateTime = this.toJavaLocalDateTime()
|
||||||
val date = dateTimeFormatter.format(javaLocalDateTime)
|
return dateTimeFormatter.format(javaLocalDateTime)
|
||||||
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).format(javaLocalDateTime)
|
|
||||||
return "$date $time"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Instant.toTimestampString(): String {
|
fun Instant.toTimestampString(timeFormatter: DateTimeFormatter): String {
|
||||||
return DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
return timeFormatter.format(toLocalDateTime(TimeZone.currentSystemDefault()).toJavaLocalDateTime())
|
||||||
.format(toLocalDateTime(TimeZone.currentSystemDefault()).toJavaLocalDateTime())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Long.convertEpochMillisZone(
|
fun Long.convertEpochMillisZone(
|
||||||
@@ -55,7 +51,7 @@ fun Instant.toJavaLocalDate(): java.time.LocalDate {
|
|||||||
fun LocalDate.toRelativeString(
|
fun LocalDate.toRelativeString(
|
||||||
context: Context,
|
context: Context,
|
||||||
relative: Boolean = true,
|
relative: Boolean = true,
|
||||||
dateFormat: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT),
|
dateFormat: DateTimeFormatter,
|
||||||
): String {
|
): String {
|
||||||
if (!relative) {
|
if (!relative) {
|
||||||
return dateFormat.format(this.toJavaLocalDate())
|
return dateFormat.format(this.toJavaLocalDate())
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import eu.kanade.tachiyomi.util.lang.truncateCenter
|
|||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import rikka.shizuku.ShizukuProvider
|
import rikka.shizuku.ShizukuProvider
|
||||||
|
import rikka.sui.Sui
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
@@ -168,12 +169,13 @@ fun Context.isPackageInstalled(packageName: String): Boolean {
|
|||||||
val Context.hasMiuiPackageInstaller get() = isPackageInstalled("com.miui.packageinstaller")
|
val Context.hasMiuiPackageInstaller get() = isPackageInstalled("com.miui.packageinstaller")
|
||||||
|
|
||||||
val Context.isShizukuInstalled: Boolean
|
val Context.isShizukuInstalled: Boolean
|
||||||
get() = try {
|
get() = Sui.isSui() ||
|
||||||
packageManager.getPermissionInfo(ShizukuProvider.PERMISSION, 0)
|
try {
|
||||||
true
|
packageManager.getPermissionInfo(ShizukuProvider.PERMISSION, 0)
|
||||||
} catch (e: PackageManager.NameNotFoundException) {
|
true
|
||||||
false
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
}
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fun Context.launchRequestPackageInstallsPermission() {
|
fun Context.launchRequestPackageInstallsPermission() {
|
||||||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||||
|
|||||||
@@ -4,15 +4,9 @@ package eu.kanade.tachiyomi.util.view
|
|||||||
|
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.view.Gravity
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.annotation.MenuRes
|
|
||||||
import androidx.appcompat.R
|
|
||||||
import androidx.appcompat.widget.PopupMenu
|
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -58,33 +52,6 @@ fun ComposeView.setComposeContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows a popup menu on top of this view.
|
|
||||||
*
|
|
||||||
* @param menuRes menu items to inflate the menu with.
|
|
||||||
* @param initMenu function to execute when the menu after is inflated.
|
|
||||||
* @param onMenuItemClick function to execute when a menu item is clicked.
|
|
||||||
*/
|
|
||||||
inline fun View.popupMenu(
|
|
||||||
@MenuRes menuRes: Int,
|
|
||||||
noinline initMenu: (Menu.() -> Unit)? = null,
|
|
||||||
noinline onMenuItemClick: MenuItem.() -> Unit,
|
|
||||||
): PopupMenu {
|
|
||||||
val popup = PopupMenu(context, this, Gravity.NO_GRAVITY, R.attr.actionOverflowMenuStyle, 0)
|
|
||||||
popup.menuInflater.inflate(menuRes, popup.menu)
|
|
||||||
|
|
||||||
if (initMenu != null) {
|
|
||||||
popup.menu.initMenu()
|
|
||||||
}
|
|
||||||
popup.setOnMenuItemClickListener {
|
|
||||||
it.onMenuItemClick()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
popup.show()
|
|
||||||
return popup
|
|
||||||
}
|
|
||||||
|
|
||||||
fun View?.isVisibleOnScreen(): Boolean {
|
fun View?.isVisibleOnScreen(): Boolean {
|
||||||
if (this == null) {
|
if (this == null) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<selector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
<item android:alpha="0.08" android:color="?attr/colorPrimary" app:state_dragged="true" />
|
|
||||||
<item android:alpha="0.08" android:color="?attr/colorPrimary" android:state_activated="true" />
|
|
||||||
<item android:color="@android:color/transparent" />
|
|
||||||
</selector>
|
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt">
|
||||||
|
<aapt:attr name="android:drawable">
|
||||||
|
<vector
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="1080"
|
||||||
|
android:viewportHeight="1080">
|
||||||
|
<path
|
||||||
|
android:name="dot_left"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M270.46 450.51 C220.99,450.51 180.52,490.99 180.52,540.46 C180.52,589.93 220.99,630.41 270.46,630.41 C319.93,630.41 360.41,589.93 360.41,540.46 C360.41,490.99 319.93,450.51 270.46,450.51c" />
|
||||||
|
<path
|
||||||
|
android:name="dot_middle"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M540.31 450.51 C490.84,450.51 450.36,490.99 450.36,540.46 C450.36,589.93 490.84,630.41 540.31,630.41 C589.78,630.41 630.26,589.93 630.26,540.46 C630.26,490.99 589.78,450.51 540.31,450.51c" />
|
||||||
|
<path
|
||||||
|
android:name="dot_right"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M810.16 450.51 C760.68,450.51 720.21,490.99 720.21,540.46 C720.21,589.93 760.68,630.41 810.16,630.41 C859.63,630.41 900.1,589.93 900.1,540.46 C900.1,490.99 859.63,450.51 810.16,450.51c" />
|
||||||
|
</vector>
|
||||||
|
</aapt:attr>
|
||||||
|
<target android:name="dot_left">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<set android:ordering="together">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="90"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:startOffset="0"
|
||||||
|
android:valueFrom="M270.46 450.51 C220.99,450.51 180.52,490.99 180.52,540.46 C180.52,589.93 220.99,630.41 270.46,630.41 C319.93,630.41 360.41,589.93 360.41,540.46 C360.41,490.99 319.93,450.51 270.46,450.51c"
|
||||||
|
android:valueTo="M270.46 550.51 C220.99,550.51 180.52,590.99 180.52,640.46 C180.52,689.93 220.99,730.41 270.46,730.41 C319.93,730.41 360.41,689.93 360.41,640.46 C360.41,590.99 319.93,550.51 270.46,550.51c"
|
||||||
|
android:valueType="pathType">
|
||||||
|
<aapt:attr name="android:interpolator">
|
||||||
|
<pathInterpolator android:pathData="M 0.0,0.0 c0.5,0 0.5,1 1.0,1.0" />
|
||||||
|
</aapt:attr>
|
||||||
|
</objectAnimator>
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="110"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:startOffset="90"
|
||||||
|
android:valueFrom="M270.46 550.51 C220.99,550.51 180.52,590.99 180.52,640.46 C180.52,689.93 220.99,730.41 270.46,730.41 C319.93,730.41 360.41,689.93 360.41,640.46 C360.41,590.99 319.93,550.51 270.46,550.51c"
|
||||||
|
android:valueTo="M270.46 422.51 C220.99,422.51 180.52,462.99 180.52,512.46 C180.52,561.93 220.99,602.41 270.46,602.41 C319.93,602.41 360.41,561.93 360.41,512.46 C360.41,462.99 319.93,422.51 270.46,422.51c"
|
||||||
|
android:valueType="pathType">
|
||||||
|
<aapt:attr name="android:interpolator">
|
||||||
|
<pathInterpolator android:pathData="M 0.0,0.0 c0.5,0 0.5,1 1.0,1.0" />
|
||||||
|
</aapt:attr>
|
||||||
|
</objectAnimator>
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="60"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:startOffset="200"
|
||||||
|
android:valueFrom="M270.46 422.51 C220.99,422.51 180.52,462.99 180.52,512.46 C180.52,561.93 220.99,602.41 270.46,602.41 C319.93,602.41 360.41,561.93 360.41,512.46 C360.41,462.99 319.93,422.51 270.46,422.51c"
|
||||||
|
android:valueTo="M270.46 450.51 C220.99,450.51 180.52,490.99 180.52,540.46 C180.52,589.93 220.99,630.41 270.46,630.41 C319.93,630.41 360.41,589.93 360.41,540.46 C360.41,490.99 319.93,450.51 270.46,450.51c"
|
||||||
|
android:valueType="pathType">
|
||||||
|
<aapt:attr name="android:interpolator">
|
||||||
|
<pathInterpolator android:pathData="M 0.0,0.0 c0.5,0 0.5,1 1.0,1.0" />
|
||||||
|
</aapt:attr>
|
||||||
|
</objectAnimator>
|
||||||
|
</set>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
<target android:name="dot_middle">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<set android:ordering="together">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="40"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:startOffset="0"
|
||||||
|
android:valueFrom="M540.31 450.51 C490.84,450.51 450.36,490.99 450.36,540.46 C450.36,589.93 490.84,630.41 540.31,630.41 C589.78,630.41 630.26,589.93 630.26,540.46 C630.26,490.99 589.78,450.51 540.31,450.51c"
|
||||||
|
android:valueTo="M540.31 450.51 C490.84,450.51 450.36,490.99 450.36,540.46 C450.36,589.93 490.84,630.41 540.31,630.41 C589.78,630.41 630.26,589.93 630.26,540.46 C630.26,490.99 589.78,450.51 540.31,450.51c"
|
||||||
|
android:valueType="pathType">
|
||||||
|
<aapt:attr name="android:interpolator">
|
||||||
|
<pathInterpolator android:pathData="M 0.0,0.0 c0.5,0 0.5,1 1.0,1.0" />
|
||||||
|
</aapt:attr>
|
||||||
|
</objectAnimator>
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="90"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:startOffset="40"
|
||||||
|
android:valueFrom="M540.31 450.51 C490.84,450.51 450.36,490.99 450.36,540.46 C450.36,589.93 490.84,630.41 540.31,630.41 C589.78,630.41 630.26,589.93 630.26,540.46 C630.26,490.99 589.78,450.51 540.31,450.51c"
|
||||||
|
android:valueTo="M540.31 550.51 C490.84,550.51 450.36,590.99 450.36,640.46 C450.36,689.93 490.84,730.41 540.31,730.41 C589.78,730.41 630.26,689.93 630.26,640.46 C630.26,590.99 589.78,550.51 540.31,550.51c"
|
||||||
|
android:valueType="pathType">
|
||||||
|
<aapt:attr name="android:interpolator">
|
||||||
|
<pathInterpolator android:pathData="M 0.0,0.0 c0.5,0 0.5,1 1.0,1.0" />
|
||||||
|
</aapt:attr>
|
||||||
|
</objectAnimator>
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="110"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:startOffset="130"
|
||||||
|
android:valueFrom="M540.31 550.51 C490.84,550.51 450.36,590.99 450.36,640.46 C450.36,689.93 490.84,730.41 540.31,730.41 C589.78,730.41 630.26,689.93 630.26,640.46 C630.26,590.99 589.78,550.51 540.31,550.51c"
|
||||||
|
android:valueTo="M540.31 422.51 C490.84,422.51 450.36,462.99 450.36,512.46 C450.36,561.93 490.84,602.41 540.31,602.41 C589.78,602.41 630.26,561.93 630.26,512.46 C630.26,462.99 589.78,422.51 540.31,422.51c"
|
||||||
|
android:valueType="pathType">
|
||||||
|
<aapt:attr name="android:interpolator">
|
||||||
|
<pathInterpolator android:pathData="M 0.0,0.0 c0.5,0 0.5,1 1.0,1.0" />
|
||||||
|
</aapt:attr>
|
||||||
|
</objectAnimator>
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="60"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:startOffset="240"
|
||||||
|
android:valueFrom="M540.31 422.51 C490.84,422.51 450.36,462.99 450.36,512.46 C450.36,561.93 490.84,602.41 540.31,602.41 C589.78,602.41 630.26,561.93 630.26,512.46 C630.26,462.99 589.78,422.51 540.31,422.51c"
|
||||||
|
android:valueTo="M540.31 450.51 C490.84,450.51 450.36,490.99 450.36,540.46 C450.36,589.93 490.84,630.41 540.31,630.41 C589.78,630.41 630.26,589.93 630.26,540.46 C630.26,490.99 589.78,450.51 540.31,450.51c"
|
||||||
|
android:valueType="pathType">
|
||||||
|
<aapt:attr name="android:interpolator">
|
||||||
|
<pathInterpolator android:pathData="M 0.0,0.0 c0.5,0 0.5,1 1.0,1.0" />
|
||||||
|
</aapt:attr>
|
||||||
|
</objectAnimator>
|
||||||
|
</set>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
<target android:name="dot_right">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<set android:ordering="together">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="80"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:startOffset="0"
|
||||||
|
android:valueFrom="M810.16 450.51 C760.68,450.51 720.21,490.99 720.21,540.46 C720.21,589.93 760.68,630.41 810.16,630.41 C859.63,630.41 900.1,589.93 900.1,540.46 C900.1,490.99 859.63,450.51 810.16,450.51c"
|
||||||
|
android:valueTo="M810.16 450.51 C760.68,450.51 720.21,490.99 720.21,540.46 C720.21,589.93 760.68,630.41 810.16,630.41 C859.63,630.41 900.1,589.93 900.1,540.46 C900.1,490.99 859.63,450.51 810.16,450.51c"
|
||||||
|
android:valueType="pathType">
|
||||||
|
<aapt:attr name="android:interpolator">
|
||||||
|
<pathInterpolator android:pathData="M 0.0,0.0 c0.5,0 0.5,1 1.0,1.0" />
|
||||||
|
</aapt:attr>
|
||||||
|
</objectAnimator>
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="90"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:startOffset="80"
|
||||||
|
android:valueFrom="M810.16 450.51 C760.68,450.51 720.21,490.99 720.21,540.46 C720.21,589.93 760.68,630.41 810.16,630.41 C859.63,630.41 900.1,589.93 900.1,540.46 C900.1,490.99 859.63,450.51 810.16,450.51c"
|
||||||
|
android:valueTo="M810.16 550.51 C760.68,550.51 720.21,590.99 720.21,640.46 C720.21,689.93 760.68,730.41 810.16,730.41 C859.63,730.41 900.1,689.93 900.1,640.46 C900.1,590.99 859.63,550.51 810.16,550.51c"
|
||||||
|
android:valueType="pathType">
|
||||||
|
<aapt:attr name="android:interpolator">
|
||||||
|
<pathInterpolator android:pathData="M 0.0,0.0 c0.5,0 0.5,1 1.0,1.0" />
|
||||||
|
</aapt:attr>
|
||||||
|
</objectAnimator>
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="110"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:startOffset="170"
|
||||||
|
android:valueFrom="M810.16 550.51 C760.68,550.51 720.21,590.99 720.21,640.46 C720.21,689.93 760.68,730.41 810.16,730.41 C859.63,730.41 900.1,689.93 900.1,640.46 C900.1,590.99 859.63,550.51 810.16,550.51c"
|
||||||
|
android:valueTo="M810.16 422.51 C760.68,422.51 720.21,462.99 720.21,512.46 C720.21,561.93 760.68,602.41 810.16,602.41 C859.63,602.41 900.1,561.93 900.1,512.46 C900.1,462.99 859.63,422.51 810.16,422.51c"
|
||||||
|
android:valueType="pathType">
|
||||||
|
<aapt:attr name="android:interpolator">
|
||||||
|
<pathInterpolator android:pathData="M 0.0,0.0 c0.5,0 0.5,1 1.0,1.0" />
|
||||||
|
</aapt:attr>
|
||||||
|
</objectAnimator>
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="60"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:startOffset="280"
|
||||||
|
android:valueFrom="M810.16 422.51 C760.68,422.51 720.21,462.99 720.21,512.46 C720.21,561.93 760.68,602.41 810.16,602.41 C859.63,602.41 900.1,561.93 900.1,512.46 C900.1,462.99 859.63,422.51 810.16,422.51c"
|
||||||
|
android:valueTo="M810.16 450.51 C760.68,450.51 720.21,490.99 720.21,540.46 C720.21,589.93 760.68,630.41 810.16,630.41 C859.63,630.41 900.1,589.93 900.1,540.46 C900.1,490.99 859.63,450.51 810.16,450.51c"
|
||||||
|
android:valueType="pathType">
|
||||||
|
<aapt:attr name="android:interpolator">
|
||||||
|
<pathInterpolator android:pathData="M 0.0,0.0 c0.5,0 0.5,1 1.0,1.0" />
|
||||||
|
</aapt:attr>
|
||||||
|
</objectAnimator>
|
||||||
|
</set>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
</animated-vector>
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:id="@+id/container"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
app:cardBackgroundColor="?android:attr/colorBackground"
|
|
||||||
app:cardElevation="0dp"
|
|
||||||
app:cardForegroundColor="@color/draggable_card_foreground">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/title"
|
|
||||||
style="@style/TextAppearance.Tachiyomi.SectionHeader"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:paddingHorizontal="16dp"
|
|
||||||
android:paddingVertical="8dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:layout_gravity="center_vertical"
|
|
||||||
tools:text="Title" />
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/reorder"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="start"
|
|
||||||
android:layout_marginEnd="4dp"
|
|
||||||
android:paddingHorizontal="10dp"
|
|
||||||
android:paddingVertical="8dp"
|
|
||||||
android:scaleType="center"
|
|
||||||
app:srcCompat="@drawable/rounded_drag_handle"
|
|
||||||
app:tint="?android:attr/textColorHint"
|
|
||||||
tools:ignore="ContentDescription" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:id="@+id/container"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:cardBackgroundColor="?android:attr/colorBackground"
|
|
||||||
app:cardElevation="0dp"
|
|
||||||
app:cardForegroundColor="@color/draggable_card_foreground">
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:paddingVertical="4dp">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/reorder"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_alignParentStart="true"
|
|
||||||
android:layout_gravity="start"
|
|
||||||
android:padding="16dp"
|
|
||||||
android:scaleType="center"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:srcCompat="@drawable/rounded_drag_handle"
|
|
||||||
app:tint="?android:attr/textColorHint"
|
|
||||||
tools:ignore="ContentDescription" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/manga_full_title"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="8dp"
|
|
||||||
android:layout_toEndOf="@id/reorder"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
|
||||||
app:layout_constraintEnd_toStartOf="@+id/download_progress_text"
|
|
||||||
app:layout_constraintStart_toEndOf="@+id/reorder"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
tools:text="Manga title" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/chapter_title"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="2dp"
|
|
||||||
android:layout_toEndOf="@id/reorder"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
|
||||||
android:textSize="12sp"
|
|
||||||
app:layout_constraintEnd_toStartOf="@+id/menu"
|
|
||||||
app:layout_constraintStart_toStartOf="@+id/manga_full_title"
|
|
||||||
app:layout_constraintTop_toBottomOf="@+id/manga_full_title"
|
|
||||||
tools:text="Chapter Title" />
|
|
||||||
|
|
||||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
|
||||||
android:id="@+id/download_progress"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:layout_marginBottom="4dp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toStartOf="@+id/menu"
|
|
||||||
app:layout_constraintStart_toEndOf="@+id/reorder"
|
|
||||||
app:layout_constraintTop_toBottomOf="@+id/chapter_title" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/download_progress_text"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_toEndOf="@id/manga_full_title"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
|
||||||
android:textColor="?android:textColorSecondary"
|
|
||||||
android:textSize="12sp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/manga_full_title"
|
|
||||||
app:layout_constraintEnd_toStartOf="@+id/menu"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/manga_full_title"
|
|
||||||
tools:text="0/10" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/menu"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="4dp"
|
|
||||||
android:layout_toEndOf="@id/download_progress_text"
|
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
|
||||||
android:contentDescription="@string/action_menu"
|
|
||||||
android:paddingHorizontal="10dp"
|
|
||||||
android:paddingVertical="16dp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:srcCompat="@drawable/rounded_more_vert"
|
|
||||||
app:tint="?attr/colorOnBackground" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:clipToPadding="false"
|
|
||||||
tools:listitem="@layout/download_item" />
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@+id/move_to_top"
|
|
||||||
android:title="@string/action_move_to_top" />
|
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@+id/move_to_top_series"
|
|
||||||
android:title="@string/action_move_to_top_all_for_series" />
|
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@+id/move_to_bottom"
|
|
||||||
android:title="@string/action_move_to_bottom" />
|
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@+id/move_to_bottom_series"
|
|
||||||
android:title="@string/action_move_to_bottom_all_for_series" />
|
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@+id/cancel_download"
|
|
||||||
android:title="@string/action_cancel" />
|
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@+id/cancel_series"
|
|
||||||
android:title="@string/cancel_all_for_series" />
|
|
||||||
|
|
||||||
</menu>
|
|
||||||
@@ -31,10 +31,6 @@
|
|||||||
|
|
||||||
<style name="TextAppearance.Tachiyomi.Preference.Secondary" parent="TextAppearance.Material3.BodySmall" />
|
<style name="TextAppearance.Tachiyomi.Preference.Secondary" parent="TextAppearance.Material3.BodySmall" />
|
||||||
|
|
||||||
<style name="TextAppearance.Tachiyomi.SectionHeader" parent="TextAppearance.Material3.LabelLarge">
|
|
||||||
<item name="android:textColor">?android:attr/textColorSecondary</item>
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
<!--=======================-->
|
<!--=======================-->
|
||||||
<!--Widgets.MaterialDivider-->
|
<!--Widgets.MaterialDivider-->
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.BackupTracking
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
|
import kotlinx.serialization.protobuf.ProtoNumber
|
||||||
|
import okio.Buffer
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import org.junit.jupiter.api.parallel.Execution
|
||||||
|
import org.junit.jupiter.api.parallel.ExecutionMode
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
@Execution(ExecutionMode.CONCURRENT)
|
||||||
|
class BackupStreamTest {
|
||||||
|
|
||||||
|
private val parser = ProtoBuf
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `writer output is the plain encoding`() {
|
||||||
|
val backup = backup()
|
||||||
|
|
||||||
|
val bytes = write(backup)
|
||||||
|
|
||||||
|
bytes shouldBe encode(backup)
|
||||||
|
encode(parser.decodeFromByteArray(Backup.serializer(), bytes)) shouldBe encode(backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reader reads the plain encoding`() {
|
||||||
|
val backup = backup()
|
||||||
|
|
||||||
|
val (entries, metadata) = read(encode(backup))
|
||||||
|
|
||||||
|
entries.map(::encode) shouldBe backup.backupManga.map(::encode)
|
||||||
|
encode(metadata) shouldBe encode(backup.copy(backupManga = emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reader counts entries without decoding them`() {
|
||||||
|
val backup = backup()
|
||||||
|
val reader = BackupReader(Buffer().write(encode(backup)), parser)
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
while (reader.nextEntry()) {
|
||||||
|
reader.skipEntry()
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
count shouldBe backup.backupManga.size
|
||||||
|
encode(reader.readMetadata()) shouldBe encode(backup.copy(backupManga = emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reader decodes only the fields an entry class declares`() {
|
||||||
|
val backup = backup()
|
||||||
|
val reader = BackupReader(Buffer().write(encode(backup)), parser)
|
||||||
|
|
||||||
|
val entries = buildList {
|
||||||
|
while (reader.nextEntry()) add(reader.readEntry(PartialManga.serializer()))
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.map { Triple(it.source, it.url, it.tracking.map(::encode)) } shouldBe backup.backupManga.map {
|
||||||
|
Triple(it.source, it.url, it.tracking.map(::encode))
|
||||||
|
}
|
||||||
|
encode(reader.readMetadata()) shouldBe encode(backup.copy(backupManga = emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reader takes fields in any order`() {
|
||||||
|
val backup = backup()
|
||||||
|
val (first, second, third) = backup.backupManga
|
||||||
|
val input = Buffer()
|
||||||
|
.write(encode(Backup(emptyList(), backupSources = backup.backupSources)))
|
||||||
|
.write(encode(Backup(listOf(first))))
|
||||||
|
// Fields Backup doesn't know, one of each remaining wire type
|
||||||
|
.writeByte(3 shl 3 or 0).writeByte(0x96).writeByte(0x01)
|
||||||
|
.writeByte(4 shl 3 or 1).writeLongLe(42)
|
||||||
|
.write(encode(Backup(listOf(second), backupCategories = backup.backupCategories)))
|
||||||
|
.writeByte(5 shl 3 or 5).writeIntLe(42)
|
||||||
|
.write(encode(Backup(emptyList(), backupPreferences = backup.backupPreferences)))
|
||||||
|
.write(encode(Backup(listOf(third), backupExtensionStores = backup.backupExtensionStores)))
|
||||||
|
.write(encode(Backup(emptyList(), backupSourcePreferences = backup.backupSourcePreferences)))
|
||||||
|
.readByteArray()
|
||||||
|
|
||||||
|
val (entries, metadata) = read(input)
|
||||||
|
|
||||||
|
entries.map(::encode) shouldBe backup.backupManga.map(::encode)
|
||||||
|
encode(metadata) shouldBe encode(backup.copy(backupManga = emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty backup`() {
|
||||||
|
val sink = Buffer()
|
||||||
|
val writer = BackupWriter(sink, parser)
|
||||||
|
writer.writeMetadata(Backup(emptyList()))
|
||||||
|
|
||||||
|
writer.isEmpty shouldBe true
|
||||||
|
sink.size shouldBe 0L
|
||||||
|
|
||||||
|
val (entries, metadata) = read(ByteArray(0))
|
||||||
|
entries shouldBe emptyList()
|
||||||
|
encode(metadata) shouldBe ByteArray(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `truncated input throws`() {
|
||||||
|
val bytes = encode(backup())
|
||||||
|
|
||||||
|
// Cut inside the last entry, inside the metadata, and right after a tag
|
||||||
|
val entryEnd = encode(Backup(backup().backupManga)).size
|
||||||
|
listOf(entryEnd - 1, bytes.size - 1, entryEnd + 1).forEach { size ->
|
||||||
|
assertThrows<IOException> { read(bytes.copyOf(size)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage input throws`() {
|
||||||
|
listOf(
|
||||||
|
// Wire type 7 doesn't exist
|
||||||
|
byteArrayOf(0x0F),
|
||||||
|
// Field number 0
|
||||||
|
byteArrayOf(0x02, 0x00),
|
||||||
|
// A varint that never ends
|
||||||
|
ByteArray(11) { 0xFF.toByte() },
|
||||||
|
// An entry longer than the input
|
||||||
|
byteArrayOf(0x0A, 0x10, 0x01),
|
||||||
|
// An entry that isn't a BackupManga
|
||||||
|
byteArrayOf(0x0A, 0x02, 0x0F, 0x00),
|
||||||
|
).forEach { input ->
|
||||||
|
assertThrows<IOException> { read(input) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun write(backup: Backup): ByteArray {
|
||||||
|
val sink = Buffer()
|
||||||
|
val writer = BackupWriter(sink, parser)
|
||||||
|
backup.backupManga.forEach(writer::writeEntry)
|
||||||
|
writer.writeMetadata(backup.copy(backupManga = emptyList()))
|
||||||
|
writer.isEmpty shouldBe false
|
||||||
|
return sink.readByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun read(bytes: ByteArray): Pair<List<BackupManga>, Backup> {
|
||||||
|
val reader = BackupReader(Buffer().write(bytes), parser)
|
||||||
|
val entries = buildList {
|
||||||
|
while (reader.nextEntry()) add(reader.readEntry())
|
||||||
|
}
|
||||||
|
return entries to reader.readMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encode(backup: Backup) = parser.encodeToByteArray(Backup.serializer(), backup)
|
||||||
|
|
||||||
|
private fun encode(manga: BackupManga) = parser.encodeToByteArray(BackupManga.serializer(), manga)
|
||||||
|
|
||||||
|
private fun encode(tracking: BackupTracking) = parser.encodeToByteArray(BackupTracking.serializer(), tracking)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private class PartialManga(
|
||||||
|
@ProtoNumber(1) val source: Long,
|
||||||
|
@ProtoNumber(2) val url: String,
|
||||||
|
@ProtoNumber(18) val tracking: List<BackupTracking> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun backup() = Backup(
|
||||||
|
backupManga = listOf(
|
||||||
|
BackupManga(
|
||||||
|
source = 1L,
|
||||||
|
url = "/manga/1",
|
||||||
|
title = "First",
|
||||||
|
genre = listOf("Action", "Drama"),
|
||||||
|
chapters = listOf(
|
||||||
|
BackupChapter(url = "/chapter/1", name = "Chapter 1", read = true, chapterNumber = 1f),
|
||||||
|
BackupChapter(url = "/chapter/2", name = "Chapter 2", bookmark = true, lastPageRead = 4),
|
||||||
|
),
|
||||||
|
categories = listOf(0L, 1L),
|
||||||
|
tracking = listOf(BackupTracking(syncId = 2, libraryId = 3L, title = "First", mediaId = 7L)),
|
||||||
|
history = listOf(BackupHistory(url = "/chapter/1", lastRead = 1_700_000_000_000L, readDuration = 60)),
|
||||||
|
excludedScanlators = listOf("Group"),
|
||||||
|
),
|
||||||
|
BackupManga(source = 2L, url = "/manga/2", title = "Second", favorite = false),
|
||||||
|
BackupManga(
|
||||||
|
source = 1L,
|
||||||
|
url = "/manga/3",
|
||||||
|
title = "Third",
|
||||||
|
description = "x".repeat(300),
|
||||||
|
chapters = List(200) { BackupChapter(url = "/chapter/3/$it", name = "Chapter $it") },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backupCategories = listOf(
|
||||||
|
BackupCategory(name = "Reading", order = 0),
|
||||||
|
BackupCategory(name = "Done", order = 1),
|
||||||
|
),
|
||||||
|
backupSources = listOf(BackupSource(name = "One", sourceId = 1L), BackupSource(name = "Two", sourceId = 2L)),
|
||||||
|
backupPreferences = listOf(
|
||||||
|
BackupPreference("flag", BooleanPreferenceValue(true)),
|
||||||
|
BackupPreference("tags", StringSetPreferenceValue(setOf("a", "b"))),
|
||||||
|
),
|
||||||
|
backupSourcePreferences = listOf(
|
||||||
|
BackupSourcePreferences("source_1", listOf(BackupPreference("lang", StringPreferenceValue("en")))),
|
||||||
|
),
|
||||||
|
backupExtensionStores = listOf(
|
||||||
|
BackupExtensionStore(
|
||||||
|
indexUrl = "https://example.org/index.min.json",
|
||||||
|
name = "Store",
|
||||||
|
badgeLabel = null,
|
||||||
|
signingKey = "key",
|
||||||
|
contactWebsite = "https://example.org",
|
||||||
|
contactDiscord = null,
|
||||||
|
isLegacy = false,
|
||||||
|
extensionListUrl = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.util.lang.Hash.md5
|
||||||
|
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||||
|
import io.kotest.matchers.collections.shouldContain
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.Arguments
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource
|
||||||
|
import tachiyomi.core.common.preference.InMemoryPreferenceStore
|
||||||
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
|
|
||||||
|
class DownloadProviderTest {
|
||||||
|
|
||||||
|
private fun provider(disallowNonAscii: Boolean): DownloadProvider {
|
||||||
|
val preferences = LibraryPreferences(InMemoryPreferenceStore())
|
||||||
|
preferences.disallowNonAsciiFilenames.set(disallowNonAscii)
|
||||||
|
preferences.enableChapterNameHash.set(true)
|
||||||
|
return DownloadProvider(mockk(relaxed = true), mockk(relaxed = true), preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dirNameBefore0210(name: String, scanlator: String?, url: String, disallowNonAscii: Boolean): String {
|
||||||
|
val dirName = if (scanlator.isNullOrBlank()) name else "${scanlator}_$name"
|
||||||
|
return DiskUtil.buildValidFilename(dirName, DiskUtil.MAX_FILE_NAME_BYTES - 11, disallowNonAscii) +
|
||||||
|
"_" + md5(url).take(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dirName0210(name: String, scanlator: String?, url: String, disallowNonAscii: Boolean): String {
|
||||||
|
return DiskUtil.buildValidFilename(scanlator!!) + "_" +
|
||||||
|
DiskUtil.buildValidFilename(name, DiskUtil.MAX_FILE_NAME_BYTES - 11, disallowNonAscii) +
|
||||||
|
"_" + md5(url).take(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("chapters")
|
||||||
|
fun `finds chapters downloaded before 0_21_0 and names new ones the same`(
|
||||||
|
name: String,
|
||||||
|
scanlator: String?,
|
||||||
|
disallowNonAscii: Boolean,
|
||||||
|
) {
|
||||||
|
val provider = provider(disallowNonAscii)
|
||||||
|
val expected = dirNameBefore0210(name, scanlator, URL, disallowNonAscii)
|
||||||
|
|
||||||
|
provider.getValidChapterDirNames(name, scanlator, URL) shouldContain expected
|
||||||
|
provider.getChapterDirName(name, scanlator, URL) shouldBe expected
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("chapters")
|
||||||
|
fun `finds chapters downloaded by 0_21_0 and 0_21_1`(name: String, scanlator: String?, disallowNonAscii: Boolean) {
|
||||||
|
if (scanlator.isNullOrBlank()) return
|
||||||
|
|
||||||
|
provider(disallowNonAscii).getValidChapterDirNames(name, scanlator, URL) shouldContain
|
||||||
|
dirName0210(name, scanlator, URL, disallowNonAscii)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val URL = "/chapter/1"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun chapters() = listOf(
|
||||||
|
Arguments.of("Chapter 1", "Group", false),
|
||||||
|
Arguments.of(" Chapter 1", "Group", false),
|
||||||
|
Arguments.of(".Chapter 1", "Group", false),
|
||||||
|
Arguments.of("Chapter 1", "Group ", false),
|
||||||
|
Arguments.of("Chapter 1", "A/B ", false),
|
||||||
|
Arguments.of("Chapter ".repeat(40), "Group", false),
|
||||||
|
Arguments.of("Chapter 1", "翻訳グループ", true),
|
||||||
|
Arguments.of(" Chapter 1", null, false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,10 @@ class NetworkHelper(
|
|||||||
if (preferences.verboseLogging.get()) {
|
if (preferences.verboseLogging.get()) {
|
||||||
val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
|
val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
|
||||||
level = HttpLoggingInterceptor.Level.HEADERS
|
level = HttpLoggingInterceptor.Level.HEADERS
|
||||||
|
if (preferences.redactSensitiveHeaders.get()) {
|
||||||
|
redactHeader("Authorization")
|
||||||
|
redactHeader("Auth")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
builder.addNetworkInterceptor(httpLoggingInterceptor)
|
builder.addNetworkInterceptor(httpLoggingInterceptor)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ class NetworkPreferences(
|
|||||||
@IsDebugBuild isDebugBuild: Boolean,
|
@IsDebugBuild isDebugBuild: Boolean,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
val redactSensitiveHeaders: Preference<Boolean> = preferenceStore.getBoolean(
|
||||||
|
"redact_sensitive_headers",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
val verboseLogging: Preference<Boolean> = preferenceStore.getBoolean(
|
val verboseLogging: Preference<Boolean> = preferenceStore.getBoolean(
|
||||||
"verbose_logging",
|
"verbose_logging",
|
||||||
isDebugBuild,
|
isDebugBuild,
|
||||||
|
|||||||
+133
-45
@@ -10,13 +10,16 @@ import android.webkit.WebView
|
|||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.webkit.ScriptHandler
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
import eu.kanade.tachiyomi.network.AndroidCookieJar
|
import eu.kanade.tachiyomi.network.AndroidCookieJar
|
||||||
import eu.kanade.tachiyomi.util.system.isOutdated
|
import eu.kanade.tachiyomi.util.system.isOutdated
|
||||||
|
import eu.kanade.tachiyomi.util.system.toWebViewHeaders
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import okhttp3.Cookie
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
@@ -24,7 +27,11 @@ import tachiyomi.core.common.i18n.stringResource
|
|||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||||
|
import kotlin.concurrent.read
|
||||||
|
import kotlin.concurrent.write
|
||||||
|
|
||||||
class CloudflareInterceptor(
|
class CloudflareInterceptor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
@@ -35,6 +42,18 @@ class CloudflareInterceptor(
|
|||||||
|
|
||||||
private val executor = ContextCompat.getMainExecutor(context)
|
private val executor = ContextCompat.getMainExecutor(context)
|
||||||
|
|
||||||
|
// Requests to a host share its lock while a challenge is solved under it alone, so concurrent requests wait for
|
||||||
|
// one WebView instead of each opening their own
|
||||||
|
private val hostLocks = ConcurrentHashMap<String, ReentrantReadWriteLock>()
|
||||||
|
|
||||||
|
private val listenerScript = """
|
||||||
|
addEventListener("message", ({data}) => {
|
||||||
|
if (data?.source === "cloudflare-challenge") {
|
||||||
|
mihon?.postMessage(data.event);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
override fun shouldIntercept(response: Response): Boolean {
|
override fun shouldIntercept(response: Response): Boolean {
|
||||||
// Check if Cloudflare anti-bot is on
|
// Check if Cloudflare anti-bot is on
|
||||||
// Checking the cf-mitigated header is the official way to detect a Cloudflare challenge:
|
// Checking the cf-mitigated header is the official way to detect a Cloudflare challenge:
|
||||||
@@ -42,19 +61,50 @@ class CloudflareInterceptor(
|
|||||||
return response.header("cf-mitigated") == "challenge" && response.header("Server") in SERVER_CHECK
|
return response.header("cf-mitigated") == "challenge" && response.header("Server") in SERVER_CHECK
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun intercept(
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
chain: Interceptor.Chain,
|
val request = chain.request()
|
||||||
request: Request,
|
val lock = hostLocks.computeIfAbsent(request.url.host) { ReentrantReadWriteLock() }
|
||||||
response: Response,
|
|
||||||
): Response {
|
val (response, clearance) = lock.read {
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
if (!shouldIntercept(response)) return response
|
||||||
|
response to clearance(request.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// write releases the read hold of a request made from within another to the same host, like an extension
|
||||||
|
// refreshing a token, which would otherwise wait on itself
|
||||||
|
lock.write {
|
||||||
|
// Another request may have solved the challenge while this one waited
|
||||||
|
if (isCleared(request.url, clearance)) {
|
||||||
|
response.close()
|
||||||
|
} else {
|
||||||
|
if (!prepareWebView()) return response
|
||||||
|
solve(request, response, clearance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response {
|
||||||
|
solve(request, response, clearance(request.url))
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearance(url: HttpUrl): String? {
|
||||||
|
return cookieManager.get(url).firstOrNull { it.name == "cf_clearance" }?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCleared(url: HttpUrl, previousClearance: String?): Boolean {
|
||||||
|
val clearance = clearance(url)
|
||||||
|
return !clearance.isNullOrBlank() && clearance != previousClearance
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun solve(request: Request, response: Response, clearance: String?) {
|
||||||
try {
|
try {
|
||||||
response.close()
|
response.close()
|
||||||
cookieManager.remove(request.url, COOKIE_NAMES, 0)
|
cookieManager.remove(request.url, COOKIE_NAMES, 0)
|
||||||
val oldCookie = cookieManager.get(request.url)
|
resolveWithWebView(request, clearance)
|
||||||
.firstOrNull { it.name == "cf_clearance" }
|
|
||||||
resolveWithWebView(request, oldCookie)
|
|
||||||
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
}
|
||||||
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
||||||
// we don't crash the entire app
|
// we don't crash the entire app
|
||||||
@@ -66,65 +116,84 @@ class CloudflareInterceptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
private fun resolveWithWebView(originalRequest: Request, oldCookie: Cookie?) {
|
private fun resolveWithWebView(originalRequest: Request, originalClearance: String?) {
|
||||||
// We need to lock this thread until the WebView finds the challenge solution url, because
|
// We need to lock this thread until the WebView finds the challenge solution url, because
|
||||||
// OkHttp doesn't support asynchronous interceptors.
|
// OkHttp doesn't support asynchronous interceptors.
|
||||||
val latch = CountDownLatch(1)
|
val latch = CountDownLatch(1)
|
||||||
|
|
||||||
var webview: WebView? = null
|
var webview: WebView? = null
|
||||||
|
var listenerScriptHandler: ScriptHandler? = null
|
||||||
|
val useIsolatedWorld = WebViewFeature.isFeatureSupported(WebViewFeature.JS_INJECTION_IN_FRAME_AND_WORLD)
|
||||||
|
|
||||||
var challengeFound = false
|
var challengeFound = false
|
||||||
var cloudflareBypassed = false
|
var cloudflareBypassed = false
|
||||||
|
var challengeFailed = false
|
||||||
var isWebViewOutdated = false
|
var isWebViewOutdated = false
|
||||||
var isRenderProcessGone = false
|
var isRenderProcessGone = false
|
||||||
|
|
||||||
val origRequestUrl = originalRequest.url.toString()
|
val origRequestUrl = originalRequest.url.toString()
|
||||||
val headers = parseHeaders(originalRequest.headers)
|
// A WebView can only load a page with GET, and loading the url of another method usually reaches an
|
||||||
|
// endpoint that answers GET without a challenge to solve. The clearance covers the whole site, so it's
|
||||||
|
// earned on the site's root page instead.
|
||||||
|
val challengeUrl = if (originalRequest.method == "GET") {
|
||||||
|
origRequestUrl
|
||||||
|
} else {
|
||||||
|
originalRequest.url.newBuilder().encodedPath("/").query(null).fragment(null).build().toString()
|
||||||
|
}
|
||||||
|
val headers = originalRequest.headers.toWebViewHeaders()
|
||||||
|
|
||||||
executor.execute {
|
executor.execute {
|
||||||
webview = createWebView(originalRequest)
|
webview = createWebView(originalRequest)
|
||||||
|
|
||||||
webview.addJavascriptInterface(
|
fun onChallengeEvent(event: String) {
|
||||||
object {
|
when (event) {
|
||||||
@Suppress("unused")
|
// The challenge cannot be solved non-interactively, abort.
|
||||||
@JavascriptInterface
|
"interactiveBegin" -> latch.countDown()
|
||||||
fun interactiveDetected() {
|
"fail" -> {
|
||||||
// The challenge cannot be solved non-interactively, abort.
|
challengeFailed = true
|
||||||
latch.countDown()
|
latch.countDown()
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"mihon",
|
}
|
||||||
)
|
|
||||||
|
if (useIsolatedWorld) {
|
||||||
|
// An isolated world keeps the bridge out of the page's reach
|
||||||
|
val world = WebViewCompat.getExecutionWorld(webview, "mihon")
|
||||||
|
val origins = setOf(originalRequest.url.origin())
|
||||||
|
WebViewCompat.addWebMessageListener(webview, "mihon", origins, world) { _, message, _, isMainFrame, _ ->
|
||||||
|
if (isMainFrame) message.data?.let(::onChallengeEvent)
|
||||||
|
}
|
||||||
|
listenerScriptHandler = WebViewCompat.addJavaScriptOnEvent(
|
||||||
|
webview,
|
||||||
|
listenerScript,
|
||||||
|
WebViewCompat.INJECTION_EVENT_DOCUMENT_START,
|
||||||
|
origins,
|
||||||
|
world,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
webview.addJavascriptInterface(
|
||||||
|
object {
|
||||||
|
@Suppress("unused")
|
||||||
|
@JavascriptInterface
|
||||||
|
fun postMessage(event: String) = onChallengeEvent(event)
|
||||||
|
},
|
||||||
|
"mihon",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
webview.webViewClient = object : WebViewClient() {
|
webview.webViewClient = object : WebViewClient() {
|
||||||
override fun onPageFinished(view: WebView, url: String) {
|
override fun onPageFinished(view: WebView, url: String) {
|
||||||
fun isCloudFlareBypassed(): Boolean {
|
if (!challengeFailed && isCleared(originalRequest.url, originalClearance)) {
|
||||||
return cookieManager.get(origRequestUrl.toHttpUrl())
|
|
||||||
.firstOrNull { it.name == "cf_clearance" }
|
|
||||||
.let { it != null && it != oldCookie }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCloudFlareBypassed()) {
|
|
||||||
cloudflareBypassed = true
|
cloudflareBypassed = true
|
||||||
latch.countDown()
|
latch.countDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url == origRequestUrl) {
|
if (url == challengeUrl) {
|
||||||
if (!challengeFound) {
|
if (!challengeFound) {
|
||||||
// The first request didn't return the challenge, abort.
|
// The first request didn't return the challenge, abort.
|
||||||
latch.countDown()
|
latch.countDown()
|
||||||
} else {
|
} else if (!useIsolatedWorld) {
|
||||||
// Listen for an interactiveBegin event
|
view.evaluateJavascript(listenerScript, null)
|
||||||
view.evaluateJavascript(
|
|
||||||
"""
|
|
||||||
addEventListener("message", ({data}) => {
|
|
||||||
if (data?.source === "cloudflare-challenge" && data?.event === "interactiveBegin") {
|
|
||||||
mihon.interactiveDetected();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
""".trimIndent(),
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,7 +226,7 @@ class CloudflareInterceptor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
webview.loadUrl(origRequestUrl, headers)
|
webview.loadUrl(challengeUrl, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
latch.awaitFor30Seconds()
|
latch.awaitFor30Seconds()
|
||||||
@@ -169,13 +238,27 @@ class CloudflareInterceptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
webview?.run {
|
webview?.run {
|
||||||
if (!isRenderProcessGone) stopLoading()
|
if (!isRenderProcessGone) {
|
||||||
|
if (useIsolatedWorld) {
|
||||||
|
WebViewCompat.removeWebMessageListener(
|
||||||
|
this,
|
||||||
|
WebViewCompat.getExecutionWorld(this, "mihon"),
|
||||||
|
"mihon",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
removeJavascriptInterface("mihon")
|
||||||
|
}
|
||||||
|
listenerScriptHandler?.remove()
|
||||||
|
stopLoading()
|
||||||
|
}
|
||||||
destroy()
|
destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throw exception if we failed to bypass Cloudflare
|
// Throw exception if we failed to bypass Cloudflare
|
||||||
if (!cloudflareBypassed) {
|
if (!cloudflareBypassed) {
|
||||||
|
// A clearance from a failed challenge would only be rejected again
|
||||||
|
cookieManager.remove(originalRequest.url, COOKIE_NAMES, 0)
|
||||||
// Prompt user to update WebView if it seems too outdated
|
// Prompt user to update WebView if it seems too outdated
|
||||||
if (isWebViewOutdated) {
|
if (isWebViewOutdated) {
|
||||||
context.toast(MR.strings.information_webview_outdated, Toast.LENGTH_LONG)
|
context.toast(MR.strings.information_webview_outdated, Toast.LENGTH_LONG)
|
||||||
@@ -186,6 +269,11 @@ class CloudflareInterceptor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An origin rule without a port only matches the scheme's default one
|
||||||
|
private fun HttpUrl.origin(): String {
|
||||||
|
return if (port == HttpUrl.defaultPort(scheme)) "$scheme://$host" else "$scheme://$host:$port"
|
||||||
|
}
|
||||||
|
|
||||||
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
|
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
|
||||||
private val COOKIE_NAMES = listOf("cf_clearance")
|
private val COOKIE_NAMES = listOf("cf_clearance")
|
||||||
|
|
||||||
|
|||||||
+12
-29
@@ -13,12 +13,10 @@ import eu.kanade.tachiyomi.util.system.toast
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -59,25 +57,23 @@ abstract class WebViewInterceptor(
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!WebViewUtil.supportsWebView(context)) {
|
if (!prepareWebView()) return response
|
||||||
scope.launch(Dispatchers.Main) {
|
|
||||||
context.toast(MR.strings.information_webview_required, Toast.LENGTH_LONG)
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
initWebView
|
|
||||||
|
|
||||||
return intercept(chain, request, response)
|
return intercept(chain, request, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseHeaders(headers: Headers): Map<String, String> {
|
/**
|
||||||
return headers
|
* Returns whether a WebView can be used, initializing it if so.
|
||||||
// Keeping unsafe header makes webview throw [net::ERR_INVALID_ARGUMENT]
|
*/
|
||||||
.filter { (name, value) ->
|
protected fun prepareWebView(): Boolean {
|
||||||
isRequestHeaderSafe(name, value)
|
if (!WebViewUtil.supportsWebView(context)) {
|
||||||
|
scope.launch(Dispatchers.Main) {
|
||||||
|
context.toast(MR.strings.information_webview_required, Toast.LENGTH_LONG)
|
||||||
}
|
}
|
||||||
.groupBy(keySelector = { (name, _) -> name }) { (_, value) -> value }
|
return false
|
||||||
.mapValues { it.value.getOrNull(0).orEmpty() }
|
}
|
||||||
|
initWebView
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun CountDownLatch.awaitFor30Seconds() {
|
fun CountDownLatch.awaitFor30Seconds() {
|
||||||
@@ -92,16 +88,3 @@ abstract class WebViewInterceptor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Based on [IsRequestHeaderSafe] in
|
|
||||||
// https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/cpp/header_util.cc
|
|
||||||
private fun isRequestHeaderSafe(_name: String, _value: String): Boolean {
|
|
||||||
val name = _name.lowercase(Locale.ENGLISH)
|
|
||||||
val value = _value.lowercase(Locale.ENGLISH)
|
|
||||||
if (name in unsafeHeaderNames || name.startsWith("proxy-")) return false
|
|
||||||
if (name == "connection" && value == "upgrade") return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
private val unsafeHeaderNames = listOf(
|
|
||||||
"content-length", "host", "trailer", "te", "upgrade", "cookie2", "keep-alive", "transfer-encoding", "set-cookie",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package eu.kanade.tachiyomi.util.system
|
||||||
|
|
||||||
|
import javax.microedition.khronos.egl.EGL10
|
||||||
|
import javax.microedition.khronos.egl.EGLConfig
|
||||||
|
import javax.microedition.khronos.egl.EGLContext
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
object GLUtil {
|
||||||
|
val DEVICE_TEXTURE_LIMIT: Int by lazy {
|
||||||
|
// Get EGL Display
|
||||||
|
val egl = EGLContext.getEGL() as EGL10
|
||||||
|
val display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY)
|
||||||
|
|
||||||
|
// Initialise
|
||||||
|
val version = IntArray(2)
|
||||||
|
egl.eglInitialize(display, version)
|
||||||
|
|
||||||
|
// Query total number of configurations
|
||||||
|
val totalConfigurations = IntArray(1)
|
||||||
|
egl.eglGetConfigs(display, null, 0, totalConfigurations)
|
||||||
|
|
||||||
|
// Query actual list configurations
|
||||||
|
val configurationsList = arrayOfNulls<EGLConfig>(totalConfigurations[0])
|
||||||
|
egl.eglGetConfigs(display, configurationsList, totalConfigurations[0], totalConfigurations)
|
||||||
|
|
||||||
|
val textureSize = IntArray(1)
|
||||||
|
var maximumTextureSize = 0
|
||||||
|
|
||||||
|
// Iterate through all the configurations to located the maximum texture size
|
||||||
|
for (i in 0..<totalConfigurations[0]) {
|
||||||
|
// Only need to check for width since opengl textures are always squared
|
||||||
|
egl.eglGetConfigAttrib(display, configurationsList[i], EGL10.EGL_MAX_PBUFFER_WIDTH, textureSize)
|
||||||
|
|
||||||
|
// Keep track of the maximum texture size
|
||||||
|
if (maximumTextureSize < textureSize[0]) maximumTextureSize = textureSize[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release
|
||||||
|
egl.eglTerminate(display)
|
||||||
|
|
||||||
|
// Return largest texture size found (after making it a multiplier of [Multiplier]), or default
|
||||||
|
max(maximumTextureSize, SAFE_TEXTURE_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
const val SAFE_TEXTURE_LIMIT: Int = 2048
|
||||||
|
|
||||||
|
val CUSTOM_TEXTURE_LIMIT_OPTIONS: List<Int> by lazy {
|
||||||
|
val steps = DEVICE_TEXTURE_LIMIT / MULTIPLIER
|
||||||
|
buildList(steps) {
|
||||||
|
add(DEVICE_TEXTURE_LIMIT)
|
||||||
|
for (step in steps downTo 2) {
|
||||||
|
val value = step * MULTIPLIER
|
||||||
|
if (value >= DEVICE_TEXTURE_LIMIT) continue
|
||||||
|
add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MULTIPLIER: Int = 1024
|
||||||
@@ -11,7 +11,9 @@ import androidx.webkit.WebSettingsCompat
|
|||||||
import androidx.webkit.WebViewFeature
|
import androidx.webkit.WebViewFeature
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
|
import okhttp3.Headers
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
object WebViewUtil {
|
object WebViewUtil {
|
||||||
@@ -144,6 +146,31 @@ fun WebView.setUserAgent(userAgent: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Headers.toWebViewHeaders(): Map<String, String> {
|
||||||
|
return this
|
||||||
|
// Keeping unsafe header makes webview throw [net::ERR_INVALID_ARGUMENT]
|
||||||
|
.filter { (name, value) ->
|
||||||
|
isRequestHeaderSafe(name, value)
|
||||||
|
}
|
||||||
|
.groupBy(keySelector = { (name, _) -> name }) { (_, value) -> value }
|
||||||
|
.mapValues { it.value.getOrNull(0).orEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Based on [IsRequestHeaderSafe] in
|
||||||
|
// https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/cpp/header_util.cc
|
||||||
|
private fun isRequestHeaderSafe(_name: String, _value: String): Boolean {
|
||||||
|
val name = _name.lowercase(Locale.ENGLISH)
|
||||||
|
val value = _value.lowercase(Locale.ENGLISH)
|
||||||
|
if (name in unsafeHeaderNames || name.startsWith("proxy-")) return false
|
||||||
|
if (name == "connection" && value == "upgrade") return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
private val unsafeHeaderNames = listOf(
|
||||||
|
"content-length", "host", "trailer", "te", "upgrade", "cookie2", "keep-alive", "transfer-encoding", "set-cookie",
|
||||||
|
// Renderer is killed if it doesn't match the request initiator
|
||||||
|
"origin",
|
||||||
|
)
|
||||||
|
|
||||||
private const val WEBVIEW_BRAND = "Android WebView"
|
private const val WEBVIEW_BRAND = "Android WebView"
|
||||||
private const val CHROMIUM_BRAND = "Chromium"
|
private const val CHROMIUM_BRAND = "Chromium"
|
||||||
private const val CHROME_BRAND = "Google Chrome"
|
private const val CHROME_BRAND = "Google Chrome"
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ import androidx.core.graphics.drawable.toDrawable
|
|||||||
import androidx.core.graphics.get
|
import androidx.core.graphics.get
|
||||||
import androidx.core.graphics.green
|
import androidx.core.graphics.green
|
||||||
import androidx.core.graphics.red
|
import androidx.core.graphics.red
|
||||||
import ca.mpreg.imagedecoder.ImageDecoder
|
|
||||||
import com.hippo.unifile.UniFile
|
import com.hippo.unifile.UniFile
|
||||||
|
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import okio.BufferedSource
|
import okio.BufferedSource
|
||||||
|
import tachiyomi.decoder.Format
|
||||||
|
import tachiyomi.decoder.ImageDecoder
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -47,20 +49,17 @@ object ImageUtil {
|
|||||||
|
|
||||||
fun findImageType(stream: InputStream): ImageType? {
|
fun findImageType(stream: InputStream): ImageType? {
|
||||||
return try {
|
return try {
|
||||||
val format = ImageDecoder.open(stream).use { dec -> dec.format }
|
when (getImageType(stream)?.format) {
|
||||||
when (format) {
|
Format.Avif -> ImageType.AVIF
|
||||||
ImageDecoder.Format.JPEG -> ImageType.JPEG
|
Format.Gif -> ImageType.GIF
|
||||||
ImageDecoder.Format.PNG -> ImageType.PNG
|
Format.Heif -> ImageType.HEIF
|
||||||
ImageDecoder.Format.WEBP -> ImageType.WEBP
|
Format.Jpeg -> ImageType.JPEG
|
||||||
ImageDecoder.Format.GIF -> ImageType.GIF
|
Format.Jxl -> ImageType.JXL
|
||||||
ImageDecoder.Format.AVIF -> ImageType.AVIF
|
Format.Png -> ImageType.PNG
|
||||||
ImageDecoder.Format.HEIF -> ImageType.HEIF
|
Format.Webp -> ImageType.WEBP
|
||||||
ImageDecoder.Format.JXL -> ImageType.JXL
|
|
||||||
ImageDecoder.Format.JP2 -> ImageType.JP2
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR) { "findImageType: ${e.message}" }
|
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,22 +71,38 @@ object ImageUtil {
|
|||||||
|
|
||||||
fun isAnimatedAndSupported(source: BufferedSource): Boolean {
|
fun isAnimatedAndSupported(source: BufferedSource): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val type = findImageType(source.peek().inputStream()) ?: return false
|
val type = getImageType(source.peek().inputStream()) ?: return false
|
||||||
when (type) {
|
// https://coil-kt.github.io/coil/getting_started/#supported-image-formats
|
||||||
ImageType.GIF -> true
|
when (type.format) {
|
||||||
ImageType.WEBP, ImageType.HEIF -> {
|
Format.Gif -> true
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false
|
// Animated WebP on Android 9+
|
||||||
ImageDecoder.open(source.peek().inputStream()).use { dec -> dec.pages > 1 }
|
Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||||
}
|
// Animated Heif on Android 11+
|
||||||
|
Format.Heif -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR) { "isAnimatedAndSupported: ${e.message}" }
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getImageType(stream: InputStream): tachiyomi.decoder.ImageType? {
|
||||||
|
val bytes = ByteArray(32)
|
||||||
|
|
||||||
|
val length = if (stream.markSupported()) {
|
||||||
|
stream.mark(bytes.size)
|
||||||
|
stream.read(bytes, 0, bytes.size).also { stream.reset() }
|
||||||
|
} else {
|
||||||
|
stream.read(bytes, 0, bytes.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length == -1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImageDecoder.findType(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
enum class ImageType(val mime: String, val extension: String) {
|
enum class ImageType(val mime: String, val extension: String) {
|
||||||
AVIF("image/avif", "avif"),
|
AVIF("image/avif", "avif"),
|
||||||
GIF("image/gif", "gif"),
|
GIF("image/gif", "gif"),
|
||||||
@@ -96,8 +111,6 @@ object ImageUtil {
|
|||||||
JXL("image/jxl", "jxl"),
|
JXL("image/jxl", "jxl"),
|
||||||
PNG("image/png", "png"),
|
PNG("image/png", "png"),
|
||||||
WEBP("image/webp", "webp"),
|
WEBP("image/webp", "webp"),
|
||||||
JP2("image/jp2", "jp2"),
|
|
||||||
JPX("image/jpx", "jpx"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -185,138 +198,30 @@ object ImageUtil {
|
|||||||
LEFT,
|
LEFT,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun canUseHardwareBitmap(bitmap: Bitmap): Boolean {
|
||||||
* Check whether the image is considered a tall image.
|
return canUseHardwareBitmap(bitmap.width, bitmap.height)
|
||||||
*
|
|
||||||
* @return true if the height:width ratio is greater than 3.
|
|
||||||
*/
|
|
||||||
private fun isTallImage(imageSource: BufferedSource): Boolean {
|
|
||||||
val options = extractImageOptions(imageSource)
|
|
||||||
return TallImageSplitCalculator.shouldSplit(
|
|
||||||
imageWidth = options.outWidth,
|
|
||||||
imageHeight = options.outHeight,
|
|
||||||
optimalImageHeight = optimalImageHeight,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun canUseHardwareBitmap(imageSource: BufferedSource): Boolean {
|
||||||
* Splits tall images to improve performance of reader
|
return with(extractImageOptions(imageSource)) {
|
||||||
*/
|
canUseHardwareBitmap(outWidth, outHeight)
|
||||||
fun splitTallImage(tmpDir: UniFile, imageFile: UniFile, filenamePrefix: String): Boolean {
|
|
||||||
val imageSource = imageFile.openInputStream().use { Buffer().readFrom(it) }
|
|
||||||
if (isAnimatedAndSupported(imageSource) || !isTallImage(imageSource)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
val bitmapRegionDecoder = getBitmapRegionDecoder(imageSource.peek().inputStream())
|
|
||||||
if (bitmapRegionDecoder == null) {
|
|
||||||
logcat { "Failed to create new instance of BitmapRegionDecoder" }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val options = extractImageOptions(imageSource).apply {
|
|
||||||
inJustDecodeBounds = false
|
|
||||||
}
|
|
||||||
val splitDataList = options.splitData
|
|
||||||
|
|
||||||
return try {
|
|
||||||
splitDataList.forEach { splitData ->
|
|
||||||
val splitImageName = splitImageName(filenamePrefix, splitData.index)
|
|
||||||
// Remove pre-existing split if exists (this split shouldn't exist under normal circumstances)
|
|
||||||
tmpDir.findFile(splitImageName)?.delete()
|
|
||||||
|
|
||||||
val splitFile = tmpDir.createFile(splitImageName)!!
|
|
||||||
|
|
||||||
val region = Rect(0, splitData.topOffset, splitData.splitWidth, splitData.bottomOffset)
|
|
||||||
|
|
||||||
splitFile.openOutputStream().use { outputStream ->
|
|
||||||
val splitBitmap = bitmapRegionDecoder.decodeRegion(region, options)
|
|
||||||
splitBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
|
|
||||||
splitBitmap.recycle()
|
|
||||||
}
|
|
||||||
logcat {
|
|
||||||
"Success: Split #${splitData.index + 1} with topOffset=${splitData.topOffset} " +
|
|
||||||
"height=${splitData.splitHeight} bottomOffset=${splitData.bottomOffset}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
imageFile.delete()
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Image splits were not successfully saved so delete them and keep the original image
|
|
||||||
splitDataList
|
|
||||||
.map { splitImageName(filenamePrefix, it.index) }
|
|
||||||
.forEach { tmpDir.findFile(it)?.delete() }
|
|
||||||
logcat(LogPriority.ERROR, e)
|
|
||||||
false
|
|
||||||
} finally {
|
|
||||||
bitmapRegionDecoder.recycle()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun splitImageName(filenamePrefix: String, index: Int) = "${filenamePrefix}__${"%03d".format(
|
var hardwareBitmapThreshold: Int = GLUtil.SAFE_TEXTURE_LIMIT
|
||||||
Locale.ENGLISH,
|
|
||||||
index + 1,
|
|
||||||
)}.jpg"
|
|
||||||
|
|
||||||
private val BitmapFactory.Options.splitData
|
private fun canUseHardwareBitmap(width: Int, height: Int): Boolean {
|
||||||
get(): List<SplitData> {
|
if (HARDWARE_BITMAP_UNSUPPORTED) return false
|
||||||
val imageHeight = outHeight
|
return maxOf(width, height) <= hardwareBitmapThreshold
|
||||||
val imageWidth = outWidth
|
|
||||||
|
|
||||||
val partCount = TallImageSplitCalculator.calculatePartCount(imageHeight, optimalImageHeight)
|
|
||||||
val optimalSplitHeight = imageHeight / partCount
|
|
||||||
|
|
||||||
logcat {
|
|
||||||
"Generating SplitData for image (height: $imageHeight): " +
|
|
||||||
"$partCount parts @ ${optimalSplitHeight}px height per part"
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildList {
|
|
||||||
val range = 0..<partCount
|
|
||||||
for (index in range) {
|
|
||||||
// Only continue if the list is empty or there is image remaining
|
|
||||||
if (isNotEmpty() && imageHeight <= last().bottomOffset) break
|
|
||||||
|
|
||||||
val topOffset = index * optimalSplitHeight
|
|
||||||
var splitHeight = min(optimalSplitHeight, imageHeight - topOffset)
|
|
||||||
|
|
||||||
if (index == range.last) {
|
|
||||||
val remainingHeight = imageHeight - (topOffset + splitHeight)
|
|
||||||
splitHeight += remainingHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
add(SplitData(index, topOffset, splitHeight, imageWidth))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SplitData(
|
|
||||||
val index: Int,
|
|
||||||
val topOffset: Int,
|
|
||||||
val splitHeight: Int,
|
|
||||||
val splitWidth: Int,
|
|
||||||
) {
|
|
||||||
val bottomOffset = topOffset + splitHeight
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Algorithm for determining what background to accompany a comic/manga page
|
* Algorithm for determining what background to accompany a comic/manga page
|
||||||
*/
|
*/
|
||||||
fun chooseBackground(context: Context, imageStream: InputStream): Drawable {
|
fun chooseBackground(context: Context, imageStream: InputStream): Drawable {
|
||||||
val image = try {
|
val decoder = ImageDecoder.newInstance(imageStream)
|
||||||
ImageDecoder.open(imageStream).use { it.decodeNext() to it.isHdr }.let { (res, hdr) ->
|
val image = decoder?.decode()
|
||||||
res.use {
|
decoder?.recycle()
|
||||||
val config = if (hdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
|
|
||||||
createBitmap(res.width, res.height, config).also { bitmap ->
|
|
||||||
res.image.rewind()
|
|
||||||
bitmap.copyPixelsFromBuffer(res.image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logcat(LogPriority.ERROR) { "chooseBackground: ${e.message}" }
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (image == null) return Color.WHITE.toDrawable()
|
if (image == null) return Color.WHITE.toDrawable()
|
||||||
return try {
|
return try {
|
||||||
@@ -554,17 +459,118 @@ object ImageUtil {
|
|||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getBitmapRegionDecoder(imageStream: InputStream): BitmapRegionDecoder? {
|
/**
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
* Taken from Coil
|
||||||
BitmapRegionDecoder.newInstance(imageStream)
|
* (https://github.com/coil-kt/coil/blob/1674d3516f061aeacbe749a435b1924f9648fd41/coil-core/src/androidMain/kotlin/coil3/util/hardwareBitmaps.kt)
|
||||||
} else {
|
* ---
|
||||||
@Suppress("DEPRECATION")
|
* Maintains a list of devices with broken/incomplete/unstable hardware bitmap implementations.
|
||||||
BitmapRegionDecoder.newInstance(imageStream, false)
|
*
|
||||||
|
* Model names are retrieved from
|
||||||
|
* [Google's official device list](https://support.google.com/googleplay/answer/1727131?hl=en).
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
val HARDWARE_BITMAP_UNSUPPORTED = when (Build.VERSION.SDK_INT) {
|
||||||
|
26 -> run {
|
||||||
|
val model = Build.MODEL ?: return@run false
|
||||||
|
|
||||||
|
// Samsung Galaxy (ALL)
|
||||||
|
if (model.removePrefix("SAMSUNG-").startsWith("SM-")) return@run true
|
||||||
|
|
||||||
|
val device = Build.DEVICE ?: return@run false
|
||||||
|
|
||||||
|
return@run device in arrayOf(
|
||||||
|
"nora", "nora_8917", "nora_8917_n", // Moto E5
|
||||||
|
"james", "rjames_f", "rjames_go", "pettyl", // Moto E5 Play
|
||||||
|
"hannah", "ahannah", "rhannah", // Moto E5 Plus
|
||||||
|
|
||||||
|
"ali", "ali_n", // Moto G6
|
||||||
|
"aljeter", "aljeter_n", "jeter", // Moto G6 Play
|
||||||
|
"evert", "evert_n", "evert_nt", // Moto G6 Plus
|
||||||
|
|
||||||
|
"G3112", "G3116", "G3121", "G3123", "G3125", // Xperia XA1
|
||||||
|
"G3412", "G3416", "G3421", "G3423", "G3426", // Xperia XA1 Plus
|
||||||
|
"G3212", "G3221", "G3223", "G3226", // Xperia XA1 Ultra
|
||||||
|
|
||||||
|
"BV6800Pro", // BlackView BV6800Pro
|
||||||
|
"CatS41", // Cat S41
|
||||||
|
"Hi9Pro", // CHUWI Hi9 Pro
|
||||||
|
"manning", // Lenovo K8 Note
|
||||||
|
"N5702L", // NUU Mobile G3
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
27 -> run {
|
||||||
|
val device = Build.DEVICE ?: return@run false
|
||||||
|
|
||||||
|
return@run device in arrayOf(
|
||||||
|
"mcv1s", // LG Tribute Empire
|
||||||
|
"mcv3", // LG K11
|
||||||
|
"mcv5a", // LG Q7
|
||||||
|
"mcv7a", // LG Stylo 4
|
||||||
|
|
||||||
|
"A30ATMO", // T-Mobile REVVL 2
|
||||||
|
"A70AXLTMO", // T-Mobile REVVL 2 PLUS
|
||||||
|
|
||||||
|
"A3A_8_4G_TMO", // Alcatel 9027W
|
||||||
|
"Edison_CKT", // Alcatel ONYX
|
||||||
|
"EDISON_TF", // Alcatel TCL XL2
|
||||||
|
"FERMI_TF", // Alcatel A501DL
|
||||||
|
"U50A_ATT", // Alcatel TETRA
|
||||||
|
"U50A_PLUS_ATT", // Alcatel 5059R
|
||||||
|
"U50A_PLUS_TF", // Alcatel TCL LX
|
||||||
|
"U50APLUSTMO", // Alcatel 5059Z
|
||||||
|
"U5A_PLUS_4G", // Alcatel 1X
|
||||||
|
|
||||||
|
"RCT6513W87DK5e", // RCA Galileo Pro
|
||||||
|
"RCT6873W42BMF9A", // RCA Voyager
|
||||||
|
"RCT6A03W13", // RCA 10 Viking
|
||||||
|
"RCT6B03W12", // RCA Atlas 10 Pro
|
||||||
|
"RCT6B03W13", // RCA Atlas 10 Pro+
|
||||||
|
"RCT6T06E13", // RCA Artemis 10
|
||||||
|
|
||||||
|
"A3_Pro", // Umidigi A3 Pro
|
||||||
|
"One", // Umidigi One
|
||||||
|
"One_Max", // Umidigi One Max
|
||||||
|
"One_Pro", // Umidigi One Pro
|
||||||
|
"Z2", // Umidigi Z2
|
||||||
|
"Z2_PRO", // Umidigi Z2 Pro
|
||||||
|
|
||||||
|
"Armor_3", // Ulefone Armor 3
|
||||||
|
"Armor_6", // Ulefone Armor 6
|
||||||
|
|
||||||
|
"Blackview", // Blackview BV6000
|
||||||
|
"BV9500", // Blackview BV9500
|
||||||
|
"BV9500Pro", // Blackview BV9500Pro
|
||||||
|
|
||||||
|
"A6L-C", // Nuu A6L-C
|
||||||
|
"N5002LA", // Nuu A7L
|
||||||
|
"N5501LA", // Nuu A5L
|
||||||
|
|
||||||
|
"Power_2_Pro", // Leagoo Power 2 Pro
|
||||||
|
"Power_5", // Leagoo Power 5
|
||||||
|
"Z9", // Leagoo Z9
|
||||||
|
|
||||||
|
"V0310WW", // Blu VIVO VI+
|
||||||
|
"V0330WW", // Blu VIVO XI
|
||||||
|
|
||||||
|
"A3", // BenQ A3
|
||||||
|
"ASUS_X018_4", // Asus ZenFone Max Plus M1 (ZB570TL)
|
||||||
|
"C210AE", // Wiko Life
|
||||||
|
"fireball", // DROID Incredible 4G LTE
|
||||||
|
"ILA_X1", // iLA X1
|
||||||
|
"Infinix-X605_sprout", // Infinix NOTE 5 Stylus
|
||||||
|
"j7maxlte", // Samsung Galaxy J7 Max
|
||||||
|
"KING_KONG_3", // Cubot King Kong 3
|
||||||
|
"M10500", // Packard Bell M10500
|
||||||
|
"S70", // Altice ALTICE S70
|
||||||
|
"S80Lite", // Doogee S80Lite
|
||||||
|
"SGINO6", // SGiNO 6
|
||||||
|
"st18c10bnn", // Barnes and Noble BNTV650
|
||||||
|
"TECNO-CA8", // Tecno CAMON X Pro,
|
||||||
|
"SHIFT6m", // SHIFT 6m
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
private val optimalImageHeight = getDisplayMaxHeightInPx * 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val getDisplayMaxHeightInPx: Int
|
|
||||||
get() = Resources.getSystem().displayMetrics.let { max(it.heightPixels, it.widthPixels) }
|
|
||||||
|
|||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
package tachiyomi.core.common.util.system
|
|
||||||
|
|
||||||
internal object TallImageSplitCalculator {
|
|
||||||
|
|
||||||
fun calculatePartCount(imageHeight: Int, optimalImageHeight: Int): Int {
|
|
||||||
require(imageHeight > 0) { "imageHeight must be positive" }
|
|
||||||
require(optimalImageHeight > 0) { "optimalImageHeight must be positive" }
|
|
||||||
// -1 so it doesn't try to split when imageHeight = optimalImageHeight
|
|
||||||
return (imageHeight - 1) / optimalImageHeight + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shouldSplit(imageWidth: Int, imageHeight: Int, optimalImageHeight: Int): Boolean {
|
|
||||||
require(imageWidth > 0) { "imageWidth must be positive" }
|
|
||||||
return imageHeight > imageWidth * 3 &&
|
|
||||||
calculatePartCount(imageHeight, optimalImageHeight) > 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
package tachiyomi.core.common.util.system
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
class TallImageSplitCalculatorTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `does not split when aspect ratio is tall but computed split count is one`() {
|
|
||||||
assertFalse(
|
|
||||||
TallImageSplitCalculator.shouldSplit(
|
|
||||||
imageWidth = 1024,
|
|
||||||
imageHeight = 4385,
|
|
||||||
optimalImageHeight = 4386,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `splits when aspect ratio is tall and computed split count is greater than one`() {
|
|
||||||
assertTrue(
|
|
||||||
TallImageSplitCalculator.shouldSplit(
|
|
||||||
imageWidth = 1024,
|
|
||||||
imageHeight = 4385,
|
|
||||||
optimalImageHeight = 4384,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `calculate part count rounds boundary correctly`() {
|
|
||||||
assertEquals(1, TallImageSplitCalculator.calculatePartCount(imageHeight = 4384, optimalImageHeight = 4384))
|
|
||||||
assertEquals(2, TallImageSplitCalculator.calculatePartCount(imageHeight = 4385, optimalImageHeight = 4384))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,14 +34,6 @@ class RestoreRepositoryImpl(
|
|||||||
private val trackRepository: TrackRepository,
|
private val trackRepository: TrackRepository,
|
||||||
) : RestoreRepository {
|
) : RestoreRepository {
|
||||||
|
|
||||||
override suspend fun getMangaUrlsBySourceId(): Map<Long, Set<String>> {
|
|
||||||
return database.mangaQueries
|
|
||||||
.getAllMangaSourceAndUrl()
|
|
||||||
.awaitAsList()
|
|
||||||
.groupBy({ it.source_id }, { it.remote_url })
|
|
||||||
.mapValues { it.value.toSet() }
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun restoreManga(entries: List<RestoredManga>, update: suspend (Manga) -> MangaUpdate) {
|
override suspend fun restoreManga(entries: List<RestoredManga>, update: suspend (Manga) -> MangaUpdate) {
|
||||||
database.transaction {
|
database.transaction {
|
||||||
entries.forEach { entry ->
|
entries.forEach { entry ->
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ class CategoryRepositoryImpl(
|
|||||||
.subscribeToList()
|
.subscribeToList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getCategoriesByMangaIds(mangaIds: List<Long>): Map<Long, List<Category>> {
|
||||||
|
return database.categoryQueries
|
||||||
|
.getCategoriesByMangaIds(mangaIds) { mangaId, id, name, order, flags ->
|
||||||
|
mangaId to mapCategory(id, name, order, flags)
|
||||||
|
}
|
||||||
|
.awaitAsList()
|
||||||
|
.groupBy({ it.first }, { it.second })
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun insert(category: NewCategory) {
|
override suspend fun insert(category: NewCategory) {
|
||||||
database.categoryQueries.insert(name = category.name, flags = category.flags)
|
database.categoryQueries.insert(name = category.name, flags = category.flags)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,13 @@ class ChapterRepositoryImpl(
|
|||||||
.awaitAsList()
|
.awaitAsList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getChaptersByMangaIds(mangaIds: List<Long>): Map<Long, List<Chapter>> {
|
||||||
|
return database.chapterQueries
|
||||||
|
.getChaptersByMangaIds(mangaIds, ::mapChapter)
|
||||||
|
.awaitAsList()
|
||||||
|
.groupBy { it.mangaId }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary {
|
override suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary {
|
||||||
return database.chapterQueries
|
return database.chapterQueries
|
||||||
.getChapterSummaryByMangaId(mangaId) { total, latestChapterNumber ->
|
.getChapterSummaryByMangaId(mangaId) { total, latestChapterNumber ->
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ class HistoryRepositoryImpl(
|
|||||||
.awaitAsList()
|
.awaitAsList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getHistoryByMangaIds(mangaIds: List<Long>): Map<Long, List<History>> {
|
||||||
|
return database.historyQueries
|
||||||
|
.getHistoryByMangaIds(mangaIds) { mangaId, id, chapterId, readAt, readDuration ->
|
||||||
|
mangaId to HistoryMapper.mapHistory(id, chapterId, readAt, readDuration)
|
||||||
|
}
|
||||||
|
.awaitAsList()
|
||||||
|
.groupBy({ it.first }, { it.second })
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun resetHistory(historyId: Long) {
|
override suspend fun resetHistory(historyId: Long) {
|
||||||
try {
|
try {
|
||||||
database.historyQueries.resetHistoryById(historyId)
|
database.historyQueries.resetHistoryById(historyId)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user