Compare commits

...
50 Commits
Author SHA1 Message Date
AntsyLich feadad5e1e Release v0.21.3 2026-10-26 03:33:28 +06:00
AntsyLich fbcbe40714 Keep tab badges visible when the tab label is too long
Fixes #4165
Fixes #3423
Closes #3424

(cherry picked from commit b19c8abeba)
2026-10-26 03:33:28 +06:00
AntsyLich ab1f749289 Keep the installed extension when replacing it fails
Co-authored-by: hamenot <hamenot@users.noreply.github.com>
(cherry picked from commit cdc7d69c92)
2026-10-26 03:33:28 +06:00
AntsyLich f59dabd4d7 Decode only needed backup fields and restore entries in one pass
Closes #4168

(cherry picked from commit 3ed83a8d32)
2026-10-26 03:33:28 +06:00
AntsyLich e8f2c3f988 Fix Sui not being detected as a Shizuku provider
Closes #3785

(cherry picked from commit bd2ada2e82)
2026-10-26 03:33:28 +06:00
Mend Renovate ca8ffacd10 Update dependency org.conscrypt:conscrypt-android to v2.8.0 (#4173)
(cherry picked from commit 4310a224d4)
2026-10-26 02:45:30 +06:00
Mend Renovate 036a69c089 Update dependency com.google.firebase:firebase-bom to v35.1.0 (#4172)
(cherry picked from commit ab5f6585bc)
2026-10-26 02:45:30 +06:00
Mend Renovate 335c50c92e Update dependency androidx.webkit:webkit to v1.18.0 (#4171)
(cherry picked from commit e48fc8711f)
2026-10-26 02:45:30 +06:00
Mend Renovate 9e3f4c0fec Update okhttp to v5.5.1 (#4170)
(cherry picked from commit c8634ed089)
2026-10-26 02:45:30 +06:00
Mend Renovate 0cfc078b00 Update dependency androidx.compose:compose-bom-beta to v2026.10.01 (#4169)
(cherry picked from commit 8b8ee26af3)
2026-10-26 02:45:30 +06:00
AntsyLich 52c348f32b Move the download queue list to Compose
(cherry picked from commit d001e6f210)
2026-10-26 02:45:30 +06:00
AntsyLich 42474cd46e Fix the More tab icon's exit animation
(cherry picked from commit 2ca6961e1c)
2026-10-26 02:45:17 +06:00
MajorTanya 8780f1ca8a Redact some sensitive headers in logcat (#4156)
(cherry picked from commit 0864ce0f46)
2026-10-26 02:45:17 +06:00
AntsyLich b95886a00d Add missing changelog entries
(cherry picked from commit 4493166985)
2026-10-26 02:45:17 +06:00
AntsyLich e5b44d4d34 Update the sources with the most entries first in library updates
(cherry picked from commit b8070cdc9c)
2026-10-26 02:45:16 +06:00
AntsyLich 7e930f1afb Update dependency com.gradleup.tapmoc:tapmoc-gradle-plugin to v0.5.0
Closes #4133

Co-authored-by: Mend Renovate <bot@renovateapp.com>
(cherry picked from commit 5432ae74be)
2026-10-26 02:45:16 +06:00
AntsyLich 9158e4c9e6 Update markdown to v0.46.0
Closes #4134

Co-authored-by: Mend Renovate <bot@renovateapp.com>
(cherry picked from commit 90f6c8dcff)
2026-10-26 02:45:16 +06:00
AntsyLich 5f7dc2b81f Fix covers reloading when removing manga from library
(cherry picked from commit a424282840)
2026-10-26 02:45:16 +06:00
AntsyLich 2a5581df98 Remove the option to split tall images when downloading
The long strip reader decodes every page in strips of about 4MB since b585613, so splitting at
download time no longer helps it. The split also re-encoded the pieces as JPEG, turning PNG and WebP
pages lossy, and sized them for the screen of the device that downloaded the chapter.

(cherry picked from commit 8b9faa383b)
2026-10-26 02:45:16 +06:00
Mend Renovate ba70dd31e9 Update Gradle to v9.8.2 (#4148)
(cherry picked from commit 4750748dd1)
2026-10-26 02:44:25 +06:00
Mend Renovate f23a2c374d Update kotlinx.serialization to v1.12.0 (#4149)
(cherry picked from commit 6492feebe8)
2026-10-26 02:44:25 +06:00
Mend Renovate 1ed4c5a171 Update aboutLibraries to v15.3.0 (#4129)
(cherry picked from commit 1ff3a47e02)
2026-10-26 02:44:25 +06:00
Mend Renovate 7ea60402e5 Update actions/setup-java action to v6.1.0 (#4130)
(cherry picked from commit 18534bf477)
2026-10-26 02:44:25 +06:00
hosted-weblate[bot] a202c33707 Translations update from Hosted Weblate (#4135)
* Translated using Weblate (Basque)

Currently translated at 82.6% (19 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/eu/

* Translated using Weblate (Esperanto)

Currently translated at 100.0% (23 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/eo/

* Translated using Weblate (Esperanto)

Currently translated at 100.0% (973 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Russian)

Currently translated at 100.0% (973 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Japanese)

Currently translated at 96.5% (939 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (973 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ca/

* Translated using Weblate (German)

Currently translated at 99.0% (964 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (German)

Currently translated at 99.6% (970 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (970 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/

* Translated using Weblate (Czech)

Currently translated at 100.0% (973 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/

* Translated using Weblate (Arabic)

Currently translated at 93.5% (910 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ar/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.1% (965 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (Arabic)

Currently translated at 93.5% (910 of 973 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ar/

---------

Co-authored-by: Bartolumiu <bartolumiu@tr25.es>
Co-authored-by: Szafranek13 <szafranek13@proton.me>
Co-authored-by: Dexroneum <dexroneum@users.noreply.hosted.weblate.org>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: Eduard Ereza Martínez <ereza@users.noreply.hosted.weblate.org>
Co-authored-by: MajorTanya <majortanya@users.noreply.hosted.weblate.org>
Co-authored-by: ZenVinny <zenvinny@users.noreply.hosted.weblate.org>
Co-authored-by: Adam Havránek <helak@users.noreply.hosted.weblate.org>
Co-authored-by: Mohamed kh <mohamedkhamekhami@gmail.com>
Co-authored-by: ZerOriSama <zerori@users.noreply.hosted.weblate.org>
(cherry picked from commit 2e72b50f52)
2026-10-26 02:44:25 +06:00
AntsyLich a805922392 Fix downloads not being found for sources sharing a stored source's name
(cherry picked from commit deeb9b2584)
2026-10-26 02:44:25 +06:00
AntsyLich 4df6a4705b Only send source headers to the source in WebView
(cherry picked from commit a01867dc3e)
2026-10-26 02:44:25 +06:00
AntsyLich f43a0f5d9d Fix WebView renderer being killed by sources setting an Origin header
(cherry picked from commit 5f63c983da)
2026-10-26 02:44:25 +06:00
Mend Renovate bffef9cb05 Update dependency com.google.firebase:firebase-bom to v35.0.2 (#4127)
(cherry picked from commit 83e8f917dc)
2026-10-26 02:44:25 +06:00
Mend Renovate 3f7f2a67c4 Update dependency io.kotest:kotest-assertions-core to v6.2.6 (#4128)
(cherry picked from commit 3058849b52)
2026-10-26 02:44:24 +06:00
MajorTanya eb0f35a7a5 Add Time Format setting (#4118)
Analogous to the Date Format setting.

Offers 4 modes:
- "Follow system" - based on the phone's time settings
  (which usually offer 12h/24h as a setting that may be toggled
  regardless of system language defaults)
  - this can be changed with an app switch which is why some callers
  are keyed on the 24-hour state.
- "Follow language" - uses the app language's default formats
  - e.g. English: 12-hour, German: 24-hour, etc.
- "24-hour": Forces 24-hour display
- "12-hour": Forces 12-hour display

(cherry picked from commit 970b12f1bd)
2026-10-26 02:44:24 +06:00
hosted-weblate[bot] f09e43efb2 Translations update from Hosted Weblate (#4095)
* Translated using Weblate (Amharic)

Currently translated at 27.5% (267 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/am/

* Translated using Weblate (Arabic)

Currently translated at 91.4% (885 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ar/

* Translated using Weblate (Belarusian)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/be/

* Translated using Weblate (Belarusian)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/be/

* Translated using Weblate (Bulgarian)

Currently translated at 75.5% (731 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Bulgarian)

Currently translated at 75.5% (731 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Bengali)

Currently translated at 84.6% (819 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bn/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ca/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ca/

* Translated using Weblate (Cebuano)

Currently translated at 44.2% (428 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ceb/

* Translated using Weblate (Czech)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/

* Translated using Weblate (Czech)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/

* Translated using Weblate (Chuvash)

Currently translated at 64.4% (624 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cv/

* Translated using Weblate (Danish)

Currently translated at 32.1% (311 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/da/

* Translated using Weblate (German)

Currently translated at 97.8% (947 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (German)

Currently translated at 97.8% (947 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (Greek)

Currently translated at 92.7% (898 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/el/

* Translated using Weblate (Esperanto)

Currently translated at 93.5% (906 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Spanish)

Currently translated at 92.7% (898 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/

* Translated using Weblate (Basque)

Currently translated at 62.0% (601 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eu/

* Translated using Weblate (Persian)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fa/

* Translated using Weblate (Persian)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fa/

* Translated using Weblate (Finnish)

Currently translated at 66.4% (643 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fi/

* Translated using Weblate (Filipino)

Currently translated at 98.5% (954 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/

* Translated using Weblate (Filipino)

Currently translated at 98.5% (954 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/

* Translated using Weblate (Galician)

Currently translated at 84.1% (815 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/gl/

* Translated using Weblate (Hebrew)

Currently translated at 77.5% (751 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/he/

* Translated using Weblate (Hindi)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hi/

* Translated using Weblate (Hindi)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hi/

* Translated using Weblate (Croatian)

Currently translated at 91.4% (885 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hr/

* Translated using Weblate (Hungarian)

Currently translated at 83.8% (812 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Indonesian)

Currently translated at 92.1% (892 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/id/

* Translated using Weblate (Italian)

Currently translated at 91.3% (884 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/it/

* Translated using Weblate (Japanese)

Currently translated at 93.4% (905 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Javanese)

Currently translated at 34.5% (334 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/jv/

* Translated using Weblate (Georgian)

Currently translated at 42.5% (412 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ka/

* Translated using Weblate (Kazakh)

Currently translated at 70.1% (679 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/kk/

* Translated using Weblate (Khmer (Central))

Currently translated at 21.3% (207 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/km/

* Translated using Weblate (Kannada)

Currently translated at 49.8% (483 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/kn/

* Translated using Weblate (Korean)

Currently translated at 94.1% (911 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ko/

* Translated using Weblate (Lithuanian)

Currently translated at 67.9% (658 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/lt/

* Translated using Weblate (Latvian)

Currently translated at 74.1% (718 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/lv/

* Translated using Weblate (Marathi)

Currently translated at 21.7% (211 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/mr/

* Translated using Weblate (Malay)

Currently translated at 93.4% (905 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ms/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 77.5% (751 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/nb/

* Translated using Weblate (Nepali)

Currently translated at 88.9% (861 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ne/

* Translated using Weblate (Dutch)

Currently translated at 92.8% (899 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/nl/

* Translated using Weblate (Norwegian Nynorsk)

Currently translated at 26.8% (260 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/nn/

* Translated using Weblate (Polish)

Currently translated at 83.5% (809 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pl/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (965 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (965 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (965 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/

* Translated using Weblate (Portuguese)

Currently translated at 91.4% (885 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt/

* Translated using Weblate (Romanian)

Currently translated at 90.9% (880 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ro/

* Translated using Weblate (Russian)

Currently translated at 98.4% (953 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Russian)

Currently translated at 98.4% (953 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Sanskrit)

Currently translated at 57.2% (554 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sa/

* Translated using Weblate (Yakut)

Currently translated at 41.1% (398 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sah/

* Translated using Weblate (Sardinian)

Currently translated at 85.7% (830 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sc/

* Translated using Weblate (Kurdish (Southern))

Currently translated at 24.0% (233 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sdh/

* Translated using Weblate (Slovak)

Currently translated at 73.4% (711 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sk/

* Translated using Weblate (Slovak)

Currently translated at 73.4% (711 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sk/

* Translated using Weblate (Albanian)

Currently translated at 69.4% (672 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sq/

* Translated using Weblate (Serbian)

Currently translated at 77.6% (752 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sr/

* Translated using Weblate (Serbian)

Currently translated at 77.6% (752 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sr/

* Translated using Weblate (Swedish)

Currently translated at 92.6% (897 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sv/

* Translated using Weblate (Telugu)

Currently translated at 20.1% (195 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/te/

* Translated using Weblate (Thai)

Currently translated at 83.8% (812 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/th/

* Translated using Weblate (Turkish)

Currently translated at 93.4% (905 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/

* Translated using Weblate (Ukrainian)

Currently translated at 84.6% (819 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/uk/

* Translated using Weblate (Uzbek)

Currently translated at 35.5% (344 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/uz/

* Translated using Weblate (Vietnamese)

Currently translated at 93.4% (905 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/vi/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 98.8% (957 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 98.8% (957 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 95.7% (927 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 95.7% (927 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

* Translated using Weblate (Amharic)

Currently translated at 13.0% (3 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/am/

* Translated using Weblate (Bulgarian)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/bg/

* Translated using Weblate (Bengali)

Currently translated at 86.9% (20 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/bn/

* Translated using Weblate (Cebuano)

Currently translated at 26.0% (6 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ceb/

* Translated using Weblate (Chuvash)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/cv/

* Translated using Weblate (Danish)

Currently translated at 17.3% (4 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/da/

* Translated using Weblate (Esperanto)

Currently translated at 91.3% (21 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/eo/

* Translated using Weblate (Basque)

Currently translated at 73.9% (17 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/eu/

* Translated using Weblate (Finnish)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/fi/

* Translated using Weblate (Galician)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/gl/

* Translated using Weblate (Hindi)

Currently translated at 91.3% (21 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/hi/

* Translated using Weblate (Hungarian)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/hu/

* Translated using Weblate (Italian)

Currently translated at 91.3% (21 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/it/

* Translated using Weblate (Javanese)

Currently translated at 91.3% (21 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/jv/

* Translated using Weblate (Georgian)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ka/

* Translated using Weblate (Kazakh)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/kk/

* Translated using Weblate (Khmer (Central))

Currently translated at 8.6% (2 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/km/

* Translated using Weblate (Kannada)

Currently translated at 47.8% (11 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/kn/

* Translated using Weblate (Lithuanian)

Currently translated at 56.5% (13 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/lt/

* Translated using Weblate (Latvian)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/lv/

* Translated using Weblate (Marathi)

Currently translated at 8.6% (2 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/mr/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/nb/

* Translated using Weblate (Nepali)

Currently translated at 91.3% (21 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ne/

* Translated using Weblate (Norwegian Nynorsk)

Currently translated at 17.3% (4 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/nn/

* Translated using Weblate (Polish)

Currently translated at 82.6% (19 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pl/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (23 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pt_BR/

* Translated using Weblate (Portuguese)

Currently translated at 91.3% (21 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pt/

* Translated using Weblate (Sanskrit)

Currently translated at 82.6% (19 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sa/

* Translated using Weblate (Yakut)

Currently translated at 30.4% (7 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sah/

* Translated using Weblate (Sardinian)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sc/

* Translated using Weblate (Kurdish (Southern))

Currently translated at 8.6% (2 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sdh/

* Translated using Weblate (Slovak)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sk/

* Translated using Weblate (Albanian)

Currently translated at 60.8% (14 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sq/

* Translated using Weblate (Serbian)

Currently translated at 73.9% (17 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sr/

* Translated using Weblate (Telugu)

Currently translated at 4.3% (1 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/te/

* Translated using Weblate (Thai)

Currently translated at 69.5% (16 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/th/

* Translated using Weblate (Ukrainian)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/uk/

* Translated using Weblate (Uzbek)

Currently translated at 13.0% (3 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/uz/

* Translated using Weblate (Malayalam)

Currently translated at 23.3% (226 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ml/

* Translated using Weblate (Malayalam)

Currently translated at 78.2% (18 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ml/

* Translated using Weblate (Assamese)

Currently translated at 91.4% (885 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/as/

* Translated using Weblate (Burmese)

Currently translated at 27.0% (262 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/my/

* Translated using Weblate (Burmese)

Currently translated at 27.0% (262 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/my/

* Translated using Weblate (Tamil)

Currently translated at 90.0% (872 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ta/

* Translated using Weblate (French)

Currently translated at 92.5% (896 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fr/

* Translated using Weblate (Tatar)

Currently translated at 0.5% (5 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tt/

* Translated using Weblate (Kurdish (Kurmanji))

Currently translated at 26.6% (258 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/kmr/

* Translated using Weblate (Kurdish (Kurmanji))

Currently translated at 26.6% (258 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/kmr/

* Translated using Weblate (Azerbaijani)

Currently translated at 0.5% (5 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/az/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/

* Translated using Weblate (Indonesian)

Currently translated at 92.4% (895 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/id/

* Translated using Weblate (Filipino)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/

* Translated using Weblate (Indonesian)

Currently translated at 98.6% (955 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/id/

* Translated using Weblate (Bulgarian)

Currently translated at 76.0% (736 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.1% (960 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (Bulgarian)

Currently translated at 81.4% (788 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Esperanto)

Currently translated at 94.6% (916 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Esperanto)

Currently translated at 95.1% (921 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

* Translated using Weblate (Czech)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/

* Translated using Weblate (Esperanto)

Currently translated at 95.9% (929 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Esperanto)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Bulgarian)

Currently translated at 80.8% (783 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Russian)

Currently translated at 98.8% (957 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (23 of 23 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ru/

* Translated using Weblate (Bulgarian)

Currently translated at 81.5% (789 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Italian)

Currently translated at 91.9% (890 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/it/

* Translated using Weblate (Russian)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Italian)

Currently translated at 92.5% (896 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/it/

* Translated using Weblate (Japanese)

Currently translated at 93.8% (908 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Japanese)

Currently translated at 95.4% (924 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Turkish)

Currently translated at 97.9% (948 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/

* Translated using Weblate (Turkish)

Currently translated at 99.7% (966 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/id/

* Translated using Weblate (Japanese)

Currently translated at 96.1% (931 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Japanese)

Currently translated at 96.4% (934 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Korean)

Currently translated at 94.4% (914 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ko/

* Translated using Weblate (Korean)

Currently translated at 100.0% (968 of 968 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ko/

---------

Co-authored-by: Anonymous <anonymous@users.noreply.hosted.weblate.org>
Co-authored-by: nadevko <me+oss@nadevko.cc>
Co-authored-by: Димитър Георгиев <dimitar13226@users.noreply.hosted.weblate.org>
Co-authored-by: Eduard Ereza Martínez <ereza@users.noreply.hosted.weblate.org>
Co-authored-by: Adam Havránek <helak@users.noreply.hosted.weblate.org>
Co-authored-by: MajorTanya <majortanya@users.noreply.hosted.weblate.org>
Co-authored-by: KING APPS <kingapps@users.noreply.hosted.weblate.org>
Co-authored-by: Infy Milliaria <infy-tls@users.noreply.hosted.weblate.org>
Co-authored-by: Throw Away <throwawayacc4gulshan@users.noreply.hosted.weblate.org>
Co-authored-by: Lucas Correia <temptingbadger@users.noreply.hosted.weblate.org>
Co-authored-by: ZenVinny <zenvinny@users.noreply.hosted.weblate.org>
Co-authored-by: Dexroneum <dexroneum@users.noreply.hosted.weblate.org>
Co-authored-by: KraXen <kraxen@users.noreply.hosted.weblate.org>
Co-authored-by: David Katrinka <davidkatrinka1995@users.noreply.hosted.weblate.org>
Co-authored-by: nay nai <naynaimiaonai@users.noreply.hosted.weblate.org>
Co-authored-by: abc0922001 <abc0922001@users.noreply.hosted.weblate.org>
Co-authored-by: Horace Johnson <horacejohnson99@users.noreply.hosted.weblate.org>
Co-authored-by: icarus suraci <1caru5@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: GpixeL <gamesfire313@users.noreply.hosted.weblate.org>
Co-authored-by: ZerOriSama <zerori@users.noreply.hosted.weblate.org>
Co-authored-by: Szafranek13 <szafranek13@proton.me>
Co-authored-by: Daniil Paschenko <daniilpaschenko@users.noreply.hosted.weblate.org>
Co-authored-by: Nicola Bortoletto <translate@nicolabortoletto.com>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: TheKingTermux <50316075+TheKingTermux@users.noreply.github.com>
Co-authored-by: on9686 <on9686@users.noreply.hosted.weblate.org>
(cherry picked from commit 7618df898c)
2026-10-26 02:44:24 +06:00
Mend Renovate 92a3ae5c7f Update androidx.paging to v3.5.3 (#4099)
(cherry picked from commit 446f6df82c)
2026-10-26 02:44:24 +06:00
AntsyLich 2cf9536cb1 Stream entries when creating and restoring backups
Closes #3850

Co-authored-by: NGB-Was-Taken <76197326+NGB-Was-Taken@users.noreply.github.com>
(cherry picked from commit 771ba33a33)
2026-10-26 02:44:24 +06:00
AntsyLich 9612d955db Release v0.21.2 2026-10-13 01:52:15 +06:00
AntsyLich 5ca2a39328 Fix concurrent Cloudflare challenges each opening a WebView
Closes #3858

Co-authored-by: tigeryu8900 <tigeryu8900@gmail.com>
(cherry picked from commit 5a5ff22c37)
2026-10-13 01:52:15 +06:00
AntsyLich fb2b224030 Fix some downloads not being recognized
(cherry picked from commit f0f1e4de5a)
2026-10-13 01:39:01 +06:00
AntsyLich ee454f3a82 Ignore long strip taps confirmed after the reader is gone
(cherry picked from commit c371d6331d)
2026-10-13 01:39:01 +06:00
AntsyLich 4fd0081817 Fail the install when nothing can confirm it instead of crashing
(cherry picked from commit 2df48e19a1)
2026-10-13 01:39:01 +06:00
AntsyLich 68dbd7a185 Show invalid filters from extensions instead of crashing
(cherry picked from commit c9e1b6e96e)
2026-10-13 01:39:01 +06:00
AntsyLich 7475f4554c Insert split pages only into the chapter they belong to
(cherry picked from commit 3156b4a1c4)
2026-10-13 01:39:00 +06:00
hazptr 8e688ed51e Fix tracker start date being shifted by the UTC offset (#4049)
Closes #1340
Closes #4049

(cherry picked from commit ee59394542)
2026-10-13 01:39:00 +06:00
AntsyLich cca6b5e2e7 Solve Cloudflare challenges of non-GET requests on the site's root
Closes #3909

(cherry picked from commit 7ae858e602)
2026-10-13 01:39:00 +06:00
AntsyLich 4c316ccc3a Update navigationevent to stop back gestures crashing closed screens
(cherry picked from commit 43de88c718)
2026-10-13 01:39:00 +06:00
AntsyLich 7c7392a533 Guard the database update against duplicates and damaged indexes
(cherry picked from commit ea2b73ad78)
2026-10-13 01:39:00 +06:00
MajorTanya d4fe03cd18 Don't send full Doubles to Comick for progress (#4090)
Previously, user could end up with progress displays like

35.019996643066406/100

because Mihon was sending raw Doubles/simply stringified Doubles for
progress data.

We now round to 2 decimal places & trim off any trailing zeroes such
that integers are sent as "1" instead of "1.0" while retaining "25.5"
and "41.02" as such.

(cherry picked from commit 1ca9b0efb8)
2026-10-13 01:39:00 +06:00
AntsyLich e9670d8a12 Release v0.21.1 2026-10-12 14:09:01 +06:00
AntsyLich 87e808548b Merge branch 'main' into 0.21.0
# Conflicts:
#	CHANGELOG.md
2026-10-12 14:04:00 +06:00
AntsyLich 79fc3a0a8a Release v0.21.0 2026-10-12 01:37:06 +06:00
AntsyLich c8b820a08f Regenerate baseline profiles 2026-10-12 01:37:05 +06:00
AntsyLich f45d0ad31d Remove the WebGPU reader and go back to the old image decoder
Brings back SSIV and the image decoder from v0.20.4 with their settings,
except "Use legacy decoder for long strip reader", which long strip
pages drawn as strips replace.
2026-10-12 01:14:22 +06:00
172 changed files with 3212 additions and 4561 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
+3 -5
View File
@@ -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,
) )
} }
@@ -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("*/*"))
},
), ),
), ),
) )
@@ -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(
@@ -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) {
@@ -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",
@@ -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> {
@@ -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
} }
} }
@@ -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()
@@ -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)
@@ -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
} }
@@ -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
@@ -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)
}
}
@@ -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>
-104
View File
@@ -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" />
-28
View File
@@ -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>
-4
View File
@@ -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,
@@ -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")
@@ -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) }
@@ -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
}
}
@@ -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