Compare commits

...
60 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 51b61a36e8 Keep the pager on its page when chapters are added before layout
Closes #4079
2026-10-12 13:56:33 +06:00
AntsyLich 010ac71a84 Revert "Always update uninitialized library entries (#3179)"
This reverts commit 8c0419657b.

Closes #4081
2026-10-12 13:56:33 +06:00
AntsyLich 9dbb7148c5 Show failed migrations instead of waiting on them forever 2026-10-12 13:56:33 +06:00
AntsyLich a999e49f2f Index duplicate entries so moving their chapters doesn't take hours 2026-10-12 13:56:33 +06:00
AntsyLich ae749bb4f8 Only cancel the install of the extension being cancelled 2026-10-12 13:56:33 +06:00
AntsyLich 649fc83ffa Keep the app alive when a WebView renderer dies 2026-10-12 13:56:32 +06:00
AntsyLich 6dffca1593 Keep a missing date added as is when merging it into favorite_at 2026-10-12 13:56:32 +06:00
AntsyLich 07b9fb695c Fix extension installer crash when cancelling off the main thread 2026-10-12 13:56:32 +06:00
AntsyLich c279291e29 Fix manga screen FAB going under the taskbar on tablets
Closes #4077
2026-10-12 13:51:25 +06:00
AntsyLich abb126ad20 Fix startup crash on some Samsung Android 9 to 11 devices
Closes #4078
2026-10-12 13:51:25 +06:00
AntsyLich 8457fb91e0 Fix chapter refresh failing in locales with non-ASCII digits
Closes #4075
2026-10-12 13:51:25 +06:00
AntsyLich 143a060ac9 Release v0.21.0
Keeps the changelog entries that f45d0ad31d removed for the release under
Unreleased, since main still has the WebGPU reader and the new image
decoder.

(cherry picked from commit 79fc3a0a8a)
2026-10-12 02:40:27 +06:00
AntsyLich 8912ef5b5c Regenerate baseline profiles
(cherry picked from commit c8b820a08f)
2026-10-12 02:34:27 +06:00
163 changed files with 2973 additions and 1497 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ body:
required: true
- label: I have written a short but informative title.
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
- label: I will fill out all of the requested information in this form.
required: true
+2 -2
View File
@@ -52,7 +52,7 @@ body:
label: Mihon version
description: You can find your Mihon version in **More → About**.
placeholder: |
Example: "0.21.0"
Example: "0.21.3"
validations:
required: true
@@ -95,7 +95,7 @@ body:
required: true
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
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
- label: I have filled out all of the requested information in this form, including specific version numbers.
required: true
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
- name: Set up JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
java-version-file: .github/.java-version
distribution: temurin
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
java-version-file: .github/.java-version
distribution: temurin
@@ -90,7 +90,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
java-version-file: .github/.java-version
distribution: temurin
+54 -1
View File
@@ -12,6 +12,56 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased]
## [v0.21.3] - 2026-10-26
### Added
- Add a Time Format setting to customize how times are displayed ([@MajorTanya](https://github.com/MajorTanya)) ([#4118](https://github.com/mihonapp/mihon/pull/4118))
### Changed
- 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
- Remove the "Split tall images" download setting ([@AntsyLich](https://github.com/AntsyLich)) ([`8b9faa3`](https://github.com/mihonapp/mihon/commit/8b9faa3))
### Fixed
- Fixed WebView crashing for sources that set an Origin header ([@AntsyLich](https://github.com/AntsyLich)) ([`5f63c98`](https://github.com/mihonapp/mihon/commit/5f63c98))
- Fixed WebView sending source headers to other sites ([@AntsyLich](https://github.com/AntsyLich)) ([`a01867d`](https://github.com/mihonapp/mihon/commit/a01867d))
- 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 covers reloading when removing an entry from the library ([@AntsyLich](https://github.com/AntsyLich)) ([`a424282`](https://github.com/mihonapp/mihon/commit/a424282))
- 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 Shizuku extension installer being unavailable with Sui ([@AntsyLich](https://github.com/AntsyLich)) ([`bd2ada2`](https://github.com/mihonapp/mihon/commit/bd2ada2))
## [v0.21.2] - 2026-10-13
### Fixed
- Fix long floating point numbers showing up in Comick progress ([@MajorTanya](https://github.com/MajorTanya)) ([#4090](https://github.com/mihonapp/mihon/pull/4090))
- 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
### Added
- Add `id:` prefix search to remaining trackers (AniList, Bangumi, Kitsu, MangaUpdates, Shikimori, and Hikka) ([@MajorTanya](https://github.com/MajorTanya), [@BrutuZ](https://github.com/BrutuZ)) ([#3776](https://github.com/mihonapp/mihon/pull/3776))
@@ -758,7 +808,10 @@ Same as v0.19.6
- 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))
[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.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
+3 -3
View File
@@ -37,8 +37,8 @@ android {
defaultConfig {
applicationId = "app.mihon"
versionCode = 36
versionName = "0.21.0"
versionCode = 39
versionName = "0.21.3"
buildConfigField("boolean", "TELEMETRY_INCLUDED", "${Config.includeTelemetry}")
buildConfigField("boolean", "UPDATER_ENABLED", "${Config.enableUpdater}")
@@ -229,6 +229,7 @@ dependencies {
debugImplementation(libs.androidx.compose.uiTooling)
implementation(libs.androidx.compose.uiToolingPreview)
implementation(libs.androidx.compose.uiUtil)
implementation(libs.androidx.navigationEvent)
implementation(libs.androidx.interpolator)
@@ -293,7 +294,6 @@ dependencies {
// UI libraries
implementation(libs.material)
implementation(libs.flexibleAdapter)
implementation(libs.photoView)
implementation(libs.directionalViewPager) {
exclude(group = "androidx.viewpager", module = "viewpager")
@@ -8,10 +8,8 @@ import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
@@ -61,10 +59,7 @@ class AddTracks(
.minOrNull()
firstReadChapterDate?.let {
val startDate = firstReadChapterDate.toEpochMilliseconds().convertEpochMillisZone(
TimeZone.currentSystemDefault(),
TimeZone.UTC,
)
val startDate = firstReadChapterDate.toEpochMilliseconds()
track = track.copy(
startDate = Instant.fromEpochMilliseconds(startDate),
)
@@ -1,17 +1,22 @@
package eu.kanade.domain.ui
import android.text.format.DateFormat
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.domain.ui.model.AppTheme
import eu.kanade.domain.ui.model.TabletUiMode
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.isDynamicColorAvailable
import logcat.LogPriority
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.util.system.logcat
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.FormatStyle
import java.util.Locale
@@ -38,14 +43,53 @@ class UiPreferences(
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 imagesInDescription: Preference<Boolean> = preferenceStore.getBoolean("pref_render_images_description", true)
companion object {
fun dateFormat(format: String): DateTimeFormatter = when (format) {
"" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
else -> DateTimeFormatter.ofPattern(format, Locale.getDefault())
fun dateFormat(format: String, locale: Locale): DateTimeFormatter = when (format) {
"" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(locale)
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.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.util.lang.toRelativeString
import kotlinx.datetime.LocalDate
@@ -27,10 +28,11 @@ fun relativeDateText(
localDate: LocalDate?,
): String {
val context = LocalContext.current
val locale = LocalLocale.current
val preferences = remember { context.appGraph.uiPreferences }
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(
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.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.PreviewParameter
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
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.history.components.HistoryItem
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.ui.history.HistoryViewModel
import kotlinx.datetime.LocalDate
import mihon.app.di.appGraph
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.DeleteSweep
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.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.util.collectAsState
@Composable
fun HistoryScreen(
@@ -99,6 +107,14 @@ private fun HistoryScreenContent(
onClickDelete: (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(
contentPadding = contentPadding,
) {
@@ -124,6 +140,7 @@ private fun HistoryScreenContent(
HistoryItem(
modifier = Modifier.animateItem(),
history = value,
timeFormatter = timeFormatter,
onClickCover = { onClickCover(value) },
onClickResume = { onClickResume(value) },
onClickDelete = { onClickDelete(value) },
@@ -31,12 +31,15 @@ import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
private val HistoryItemHeight = 96.dp
@Composable
fun HistoryItem(
history: HistoryWithRelations,
timeFormatter: DateTimeFormatter,
onClickCover: () -> Unit,
onClickResume: () -> Unit,
onClickDelete: () -> Unit,
@@ -68,7 +71,9 @@ fun HistoryItem(
overflow = TextOverflow.Ellipsis,
style = textStyle,
)
val readAt = remember { history.readAt?.toTimestampString() ?: "" }
val readAt = remember(timeFormatter, history.readAt) {
history.readAt?.toTimestampString(timeFormatter) ?: ""
}
Text(
text = if (history.chapterNumber > -1) {
stringResource(
@@ -114,6 +119,7 @@ private fun HistoryItemPreviews(
Surface {
HistoryItem(
history = historyWithRelations,
timeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT),
onClickCover = {},
onClickResume = {},
onClickDelete = {},
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
@@ -535,20 +536,20 @@ fun MangaScreenLargeImpl(
)
},
bottomBar = {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
SharedMangaBottomActionMenu(
selected = state.selectedChapters,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
onDownloadChapter = onDownloadChapter,
onMultiDeleteClicked = onMultiDeleteClicked,
fillFraction = 0.5f,
)
}
// Aligned by its own modifier: a wrapper would always be laid out, and the scaffold then drops the
// navigation bar inset under the FAB
SharedMangaBottomActionMenu(
selected = state.selectedChapters,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
onDownloadChapter = onDownloadChapter,
onMultiDeleteClicked = onMultiDeleteClicked,
fillFraction = 0.5f,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.End),
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
@@ -105,6 +105,8 @@ private fun getMarkdownColors(): MarkdownColors {
inlineCodeBackground = codeBackground,
dividerColor = MaterialTheme.colorScheme.outlineVariant,
tableBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f),
highlightText = MaterialTheme.colorScheme.onSecondaryContainer,
highlightBackground = MaterialTheme.colorScheme.secondaryContainer,
)
}
@@ -101,6 +101,15 @@ object SettingsAdvancedScreen : SearchableSettings {
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(
title = stringResource(MR.strings.pref_debug_info),
onClick = { navigator.push(DebugInfoScreen()) },
@@ -7,17 +7,20 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.core.app.ActivityCompat
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.domain.ui.model.TabletUiMode
import eu.kanade.domain.ui.model.ThemeMode
import eu.kanade.domain.ui.model.TimeFormat
import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode
import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.appearance.AppLanguageScreen
import eu.kanade.presentation.more.settings.widget.AppThemeModePreferenceWidget
import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDateTime
@@ -101,12 +104,14 @@ object SettingsAppearanceScreen : SearchableSettings {
): Preference.PreferenceGroup {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val locale = LocalLocale.current
val is24HourSystem = rememberIs24HourSystem()
val now = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).toJavaLocalDateTime() }
val dateFormat by uiPreferences.dateFormat.collectAsState()
val formattedNow = remember(dateFormat) {
UiPreferences.dateFormat(dateFormat).format(now)
val formattedDateNow = remember(dateFormat) {
UiPreferences.dateFormat(dateFormat, locale.platformLocale).format(now)
}
return Preference.PreferenceGroup(
@@ -130,18 +135,31 @@ object SettingsAppearanceScreen : SearchableSettings {
preference = uiPreferences.dateFormat,
entries = DateFormats
.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)"
},
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 = uiPreferences.relativeTime,
title = stringResource(MR.strings.pref_relative_format),
subtitle = stringResource(
MR.strings.pref_relative_format_summary,
stringResource(MR.strings.relative_time_today),
formattedNow,
formattedDateNow,
),
),
Preference.PreferenceItem.SwitchPreference(
@@ -43,11 +43,6 @@ object SettingsDownloadScreen : SearchableSettings {
preference = downloadPreferences.saveChaptersAsCBZ,
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 = downloadPreferences.parallelSourceLimit,
valueRange = 1..10,
@@ -1,6 +1,7 @@
package eu.kanade.presentation.more.settings.screen.about
import android.content.Context
import android.text.format.DateFormat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
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.util.LocalBackPress
import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.RELEASE_URL
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 uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Locale
import kotlin.time.Instant
object AboutScreen : Screen() {
@@ -74,6 +77,7 @@ object AboutScreen : Screen() {
val navigator = LocalNavigator.currentOrThrow
var isCheckingUpdates by remember { mutableStateOf(false) }
val crashLogUtil = remember { context.appGraph.crashLogUtil }
val is24HourSystem = rememberIs24HourSystem()
Scaffold(
topBar = { scrollBehavior ->
@@ -96,7 +100,9 @@ object AboutScreen : Screen() {
item {
TextPreferenceWidget(
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 = {
val deviceInfo = crashLogUtil.getDebugInfo()
context.copyToClipboard("Debug information", deviceInfo)
@@ -275,11 +281,16 @@ object AboutScreen : Screen() {
internal fun getFormattedBuildTime(): String {
return try {
val context = Injekt.get<Context>()
val uiPreferences = context.appGraph.uiPreferences
Instant.parse(BuildConfig.BUILD_TIME)
.toLocalDateTime(TimeZone.currentSystemDefault())
.toDateTimestampString(
UiPreferences.dateFormat(
Injekt.get<Context>().appGraph.uiPreferences.dateFormat.get(),
UiPreferences.dateTimeFormat(
uiPreferences.dateFormat.get(),
uiPreferences.timeFormat.get(),
Locale.getDefault(),
DateFormat.is24HourFormat(context),
),
)
} 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.screen.about.AboutScreen
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.WebViewUtil
import eu.kanade.tachiyomi.util.system.copyToClipboard
@@ -59,6 +60,7 @@ class DebugInfoScreen : Screen() {
private fun getAppInfoGroup(): Preference.PreferenceGroup {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val is24HourSystem = rememberIs24HourSystem()
val installationIdPref = remember { context.appGraph.basePreferences.installationId }
val installationId by installationIdPref.collectAsState()
@@ -72,7 +74,9 @@ class DebugInfoScreen : Screen() {
),
Preference.PreferenceItem.TextPreference(
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(
title = "Installation ID",
@@ -1,6 +1,7 @@
package eu.kanade.presentation.more.settings.screen.debug
import android.content.Context
import android.text.format.DateFormat
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
@@ -9,6 +10,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
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.ui.Modifier
@@ -31,11 +33,13 @@ import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.rememberIs24HourSystem
import eu.kanade.tachiyomi.util.lang.toDateTimestampString
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@@ -46,6 +50,8 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.plus
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.time.Instant
class WorkerInfoScreen : Screen() {
@@ -60,6 +66,12 @@ class WorkerInfoScreen : Screen() {
val navigator = LocalNavigator.currentOrThrow
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 finished by viewModel.finished.collectAsState()
val running by viewModel.running.collectAsState()
@@ -124,29 +136,53 @@ class WorkerInfoScreen : Screen() {
@ViewModelKey
@ContributesIntoMap(AppScope::class)
class WorkerInfoViewModel(
private val context: Context,
context: Context,
) : ViewModel() {
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
.getWorkInfosFlow(
private val dateTimeFormatter = combine(
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),
)
.map(::constructString)
),
dateTimeFormatter,
::constructString,
)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
val running = workManager
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
.map(::constructString)
val running = combine(
workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING)),
dateTimeFormatter,
::constructString,
)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
val enqueued = workManager
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED))
.map(::constructString)
val enqueued = combine(
workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED)),
dateTimeFormatter,
::constructString,
)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
private fun constructString(list: List<WorkInfo>) = buildString {
private fun constructString(
list: List<WorkInfo>,
dateTimeFormatter: DateTimeFormatter,
) = buildString {
if (list.isEmpty()) {
appendLine("-")
} else {
@@ -160,11 +196,7 @@ class WorkerInfoScreen : Screen() {
if (workInfo.state == WorkInfo.State.ENQUEUED) {
val timestamp = Instant.fromEpochMilliseconds(workInfo.nextScheduleTimeMillis)
.toLocalDateTime(TimeZone.currentSystemDefault())
.toDateTimestampString(
UiPreferences.dateFormat(
context.appGraph.uiPreferences.dateFormat.get(),
),
)
.toDateTimestampString(dateTimeFormatter)
appendLine("Next scheduled run: $timestamp")
appendLine("Attempt #${workInfo.runAttemptCount + 1}")
}
@@ -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
}
@@ -5,8 +5,10 @@ import android.graphics.Bitmap
import android.os.Message
import android.webkit.JsPromptResult
import android.webkit.JsResult
import android.webkit.RenderProcessGoneDetail
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -30,6 +32,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import cafe.adriel.voyager.core.stack.mutableStateStackOf
import com.kevinnzou.web.AccompanistWebChromeClient
import com.kevinnzou.web.AccompanistWebViewClient
@@ -45,12 +48,15 @@ import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.util.system.getHtml
import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.system.setUserAgent
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.automirroredrounded.ArrowBack
import mihon.icons.materialsymbols.automirroredrounded.ArrowForward
import mihon.icons.materialsymbols.rounded.Close
import mihon.icons.materialsymbols.rounded.TabClose
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
@@ -103,6 +109,8 @@ fun WebViewScreenContent(
onDispose { isActive = false }
}
val headersHost = remember { url.toUri().host }
val webClient = remember {
object : AccompanistWebViewClient() {
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
@@ -142,6 +150,9 @@ fun WebViewScreenContent(
// Ignore intents urls
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
if (url.startsWith("http") || url.startsWith("https")) {
if (url != view?.url) {
@@ -152,6 +163,17 @@ fun WebViewScreenContent(
return false
}
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
// Not handling it kills the app along with the page
logcat(LogPriority.ERROR) {
"WebView renderer gone: crashed=${detail.didCrash()}, " +
"priority=${detail.rendererPriorityAtExit()}, url=$currentUrl"
}
view.context.toast(MR.strings.information_webview_crashed, Toast.LENGTH_LONG)
onNavigateUp()
return true
}
}
}
@@ -4,8 +4,12 @@ import android.content.Context
import android.net.Uri
import dev.zacsweers.metro.Inject
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 okio.BufferedSource
import okio.buffer
import okio.gzip
import okio.source
@@ -19,9 +23,48 @@ class BackupDecoder(
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 ->
val source = inputStream.source().buffer()
@@ -29,22 +72,32 @@ class BackupDecoder(
require(2)
}
val id1id2 = peeked.readShort()
val backupString = when (id1id2.toInt()) {
val backupSource: BufferedSource = when (id1id2.toInt()) {
0x1f8b -> source.gzip().buffer() // 0x1f8b is gzip magic bytes
MAGIC_JSON_SIGNATURE1, MAGIC_JSON_SIGNATURE2, MAGIC_JSON_SIGNATURE3 -> {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_json))
}
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 {
private const val MAGIC_JSON_SIGNATURE1 = 0x7b7d // `{}`
private const val MAGIC_JSON_SIGNATURE2 = 0x7b22 // `{"`
@@ -2,7 +2,10 @@ package eu.kanade.tachiyomi.data.backup
import android.net.Uri
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.models.BackupTracking
import eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import tachiyomi.domain.source.service.SourceManager
@Inject
@@ -18,8 +21,11 @@ class BackupFileValidator(
* @return List of missing sources or missing trackers.
*/
suspend fun validate(uri: Uri): Results {
val trackerIds = mutableSetOf<Int>()
val backup = try {
backupDecoder.decode(uri)
backupDecoder.decodeMetadata(uri, TrackedManga.serializer()) { manga ->
manga.tracking.mapTo(trackerIds) { it.syncId }
}.backup
} catch (e: Exception) {
throw IllegalStateException(e)
}
@@ -38,11 +44,7 @@ class BackupFileValidator(
.distinct()
.sorted()
val trackers = backup.backupManga
.flatMap { it.tracking }
.map { it.syncId }
.distinct()
val missingTrackers = trackers
val missingTrackers = trackerIds
.mapNotNull { trackerManager.get(it.toLong()) }
.filter { !it.isLoggedIn }
.map { it.name }
@@ -55,4 +57,10 @@ class BackupFileValidator(
val missingSources: 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 eu.kanade.tachiyomi.BuildConfig
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.ExtensionStoresBackupCreator
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.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority
import okio.buffer
@@ -85,27 +88,31 @@ class BackupCreator(
val nonFavoriteManga = if (options.readEntries) mangaRepository.getReadMangaNotInLibrary() else emptyList()
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()
.also {
// Force overwrite old file
(it as? FileOutputStream)?.channel?.truncate(0)
}
.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
@@ -130,14 +137,14 @@ class BackupCreator(
return categoriesBackupCreator()
}
private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
if (!options.libraryEntries) return emptyList()
private fun backupMangas(mangas: List<Manga>, options: BackupOptions): Flow<BackupManga> {
if (!options.libraryEntries) return emptyFlow()
return mangaBackupCreator(mangas, options)
}
private suspend fun backupSources(mangas: List<BackupManga>): List<BackupSource> {
return sourcesBackupCreator(mangas)
private suspend fun backupSources(sourceIds: Set<Long>): List<BackupSource> {
return sourcesBackupCreator(sourceIds)
}
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.toBackupTracking
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.toEpochMillisOrZero
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.repository.CategoryRepository
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.repository.MangaRepository
import tachiyomi.domain.track.repository.TrackRepository
@@ -20,62 +22,71 @@ import tachiyomi.domain.track.repository.TrackRepository
class MangaBackupCreator(
private val mangaRepository: MangaRepository,
private val chapterRepository: ChapterRepository,
private val categoryRepository: CategoryRepository,
private val historyRepository: HistoryRepository,
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 {
backupManga(it, options)
/**
* Builds the entries a chunk at a time, so only one chunk's chapters are held at once.
*/
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 {
// Entry for this manga
val mangaObject = manga.toBackupManga()
private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
val mangaIds = mangas.map { it.id }
mangaObject.excludedScanlators = mangaRepository.getExcludedScanlators(manga.id).toList()
if (options.chapters) {
// Backup all the chapters
chapterRepository.getChapterByMangaId(manga.id, applyScanlatorFilter = false)
.map { it.toBackupChapter() }
.takeUnless { it.isEmpty() }
?.let { mangaObject.chapters = it }
val excludedScanlatorsByMangaId = mangaRepository.getExcludedScanlatorsByMangaIds(mangaIds)
// History is backed up by chapter URL, so it needs the chapters too
val chaptersByMangaId = if (options.chapters || options.history) {
chapterRepository.getChaptersByMangaIds(mangaIds)
} else {
emptyMap()
}
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) {
// Backup categories for this manga
val categoriesForManga = getCategories.await(manga.id)
if (categoriesForManga.isNotEmpty()) {
mangaObject.categories = categoriesForManga.map { it.order }
return mangas.map { manga ->
val mangaObject = manga.toBackupManga()
val chapters = chaptersByMangaId[manga.id].orEmpty()
mangaObject.excludedScanlators = excludedScanlatorsByMangaId[manga.id].orEmpty()
if (options.chapters) {
mangaObject.chapters = chapters.map { it.toBackupChapter() }
}
}
if (options.tracking) {
val tracks = trackRepository.getTracksByMangaId(manga.id).map { it.toBackupTracking() }
if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks
if (options.categories) {
mangaObject.categories = categoriesByMangaId[manga.id].orEmpty().map { it.order }
}
}
if (options.history) {
val historyByMangaId = getHistory.await(manga.id)
if (historyByMangaId.isNotEmpty()) {
val chapterUrlsById = chapterRepository.getChapterByMangaId(manga.id).associate { it.id to it.url }
val history = historyByMangaId.mapNotNull { history ->
if (options.tracking) {
mangaObject.tracking = tracksByMangaId[manga.id].orEmpty().map { it.toBackupTracking() }
}
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
val url = chapterUrlsById[history.chapterId] ?: return@mapNotNull null
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
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.source.Source
import tachiyomi.domain.source.service.SourceManager
@@ -11,11 +10,8 @@ class SourcesBackupCreator(
private val sourceManager: SourceManager,
) {
suspend operator fun invoke(mangas: List<BackupManga>): List<BackupSource> {
return mangas
.map(BackupManga::source)
.distinct()
.map { sourceManager.getOrStub(it).toBackupSource() }
suspend operator fun invoke(sourceIds: Set<Long>): List<BackupSource> {
return sourceIds.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.models.BackupCategory
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.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
@@ -23,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.chunked
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource
@@ -97,7 +97,7 @@ class BackupRestorer(
}
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
val backupMaps = backup.backupSources
@@ -105,7 +105,7 @@ class BackupRestorer(
if (options.libraryEntries) {
restoreSourceNames(backupMaps)
restoreAmount += backup.backupManga.size
restoreAmount += entryCount
}
if (options.categories) {
restoreAmount += 1
@@ -138,7 +138,7 @@ class BackupRestorer(
}
if (options.libraryEntries) {
restoreManga(
backup.backupManga,
uri,
if (options.categories) backup.backupCategories else emptyList(),
restoreCategoriesJob,
)
@@ -175,14 +175,15 @@ class BackupRestorer(
}
private fun CoroutineScope.restoreManga(
backupMangas: List<BackupManga>,
uri: Uri,
backupCategories: List<BackupCategory>,
categoriesRestoreJob: Job?,
) = launch {
categoriesRestoreJob?.join()
mangaRestorer.sortByNew(backupMangas)
backupDecoder.decodeEntries(uri)
.chunked(100)
.forEach { chunk ->
.collect { chunk ->
val restoredAsBatch = try {
ensureActive()
mangaRestorer.restore(chunk, backupCategories)
@@ -23,13 +23,6 @@ class MangaRestorer(
private val now = Clock.System.now().toLocalDateTime(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.
*/
@@ -60,9 +60,9 @@ class MangaCoverFetcher(
get() = diskCacheKeyLazy.value
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)
if (useCustomCover) {
if (useCustomCover && isLibraryManga) {
val customCoverFile = customCoverFileLazy.value
if (customCoverFile.exists()) {
return fileLoader(customCoverFile)
@@ -9,7 +9,7 @@ import tachiyomi.domain.manga.model.Manga as DomainManga
class MangaKeyer : Keyer<DomainManga> {
override fun key(data: DomainManga, options: Options): String {
return if (data.hasCustomCover()) {
return if (data.favorite && data.hasCustomCover()) {
"${data.id};${data.coverLastModified}"
} else {
"${data.thumbnailUrl};${data.coverLastModified}"
@@ -21,7 +21,7 @@ class MangaCoverKeyer(
private val coverCache: CoverCache,
) : Keyer<MangaCover> {
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}"
} else {
"${data.url};${data.lastModified}"
@@ -186,7 +186,8 @@ class DownloadCache(
fun getTotalDownloadCount(): Int {
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 ->
mangaDir.chapterDirs.size
}
@@ -390,20 +391,21 @@ class DownloadCache(
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 {
val updatedRootDir = RootDirectory(storageManager.getDownloadsDirectory())
updatedRootDir.sourceDirs = updatedRootDir.dir?.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.isNullOrBlank() }
.mapNotNull { dir ->
val sourceId = sourceMap[dir.name!!.lowercase()]
sourceId?.let { it to SourceDirectory(dir) }
.flatMap { dir ->
val sourceDir = SourceDirectory(dir)
sourceIdsByDirName[dir.name!!.lowercase()].orEmpty().map { it to sourceDir }
}
.toMap()
updatedRootDir.sourceDirs.values.map { sourceDir ->
updatedRootDir.sourceDirs.values.distinct().map { sourceDir ->
async {
sourceDir.mangaDirs = sourceDir.dir?.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.isNullOrBlank() }
@@ -231,7 +231,7 @@ class DownloadProvider(
enableChapterNameHash: Boolean = libraryPreferences.enableChapterNameHash.get(),
): String {
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 othersChapterDirNames = booleanPairPermutation
.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.
val scanlator = parts.chapterScanlator
val unsanitizedScanlatorChapterDirNames = if (scanlator != null && '/' !in scanlator) {
booleanPairPermutation.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
parts.splitDirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
}
} else {
emptyList()
@@ -272,6 +276,7 @@ class DownloadProvider(
// chapter names)
add(chapterNameV1)
addAll(othersChapterDirNames)
addAll(splitChapterDirNames)
addAll(unsanitizedScanlatorChapterDirNames)
}
}
@@ -291,7 +296,6 @@ class DownloadProvider(
val chapterDirName = parts.dirName(
disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames.get(),
enableHash = libraryPreferences.enableChapterNameHash.get(),
sanitizeScanlator = true,
)
val legacyChapterDirNames = getLegacyChapterDirNames(parts)
@@ -330,9 +334,29 @@ private class ChapterDirNameParts(chapterName: String, val chapterScanlator: Str
chapterScanlator?.let { DiskUtil.buildValidFilename(it) }
}
private val validDirNames = 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 {
if (!chapterScanlator.isNullOrBlank()) {
// 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
splitTallImageIfNeeded(page, tmpDir)
page.uri = file.uri
page.progress = 100
page.status = Page.State.Ready
@@ -645,23 +643,6 @@ class Downloader(
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.
*
@@ -29,9 +29,6 @@ data class Download(
_pagesFlow.value = pages
}
val totalProgress: Int
get() = pages?.sumOf(Page::progress) ?: 0
val downloadedImages: Int
get() = pages?.count { it.status == Page.State.Ready } ?: 0
@@ -192,9 +192,6 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
mangaToUpdate = listToUpdate
.filter {
when {
// Entries added without opening them have no details yet
!it.manga.initialized -> true
it.manga.updateStrategy == UpdateStrategy.ONLY_FETCH_ONCE && it.totalChapters > 0L -> {
skippedUpdates.add(
it.manga to context.stringResource(MR.strings.skipped_reason_not_always_update),
@@ -263,6 +260,7 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
coroutineScope {
mangaToUpdate.groupBy { it.manga.source }.values
.sortedByDescending { it.size }
.map { mangaInSource ->
async {
semaphore.withPermit {
@@ -357,7 +355,7 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
val update = updateMangaFromRemote(
source = source,
manga = manga,
fetchDetails = !manga.initialized || libraryPreferences.autoUpdateMetadata.get(),
fetchDetails = libraryPreferences.autoUpdateMetadata.get(),
fetchChapters = true,
fetchWindow = fetchWindow,
)
@@ -33,6 +33,7 @@ import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import uy.kohesive.injekt.injectLazy
import java.math.RoundingMode
import java.security.SecureRandom
import java.util.Base64
import kotlin.time.Duration.Companion.minutes
@@ -77,7 +78,14 @@ class ComickApi(
put("status", track.status.toApiListStatus())
if (track.last_chapter_read > 0) {
putJsonObject("progress") {
put("number", track.last_chapter_read)
put(
"number",
track.last_chapter_read
.toBigDecimal()
.setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString(),
)
}
} else {
put("progress", null)
@@ -140,7 +148,14 @@ class ComickApi(
put("rating", track.score.toInt().takeIf { it > 0 })
put("status", track.status.toApiListStatus())
putJsonObject("progress") {
put("number", track.last_chapter_read.toString())
put(
"number",
track.last_chapter_read
.toBigDecimal()
.setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString(),
)
}
}
.toString()
@@ -22,6 +22,8 @@ abstract class Installer(private val service: Service) {
private val extensionManager: ExtensionManager by lazy { service.appGraph.extensionManager }
private var waitingInstall = AtomicReference<Entry?>(null)
// Guarded by itself: cancellation comes from any thread, not only main
private val queue = Collections.synchronizedList(mutableListOf<Entry>())
private val cancelListener: (Long) -> Unit = { downloadId -> cancelQueue(downloadId) }
@@ -91,15 +93,15 @@ abstract class Installer(private val service: Service) {
if (!ready) {
return
}
if (queue.isEmpty()) {
service.stopSelf()
return
}
val nextEntry = queue.first()
if (waitingInstall.compareAndSet(null, nextEntry)) {
val nextEntry = synchronized(queue) {
if (queue.isEmpty()) {
service.stopSelf()
return
}
if (!waitingInstall.compareAndSet(null, queue.first())) return
queue.removeAt(0)
processEntry(nextEntry)
}
processEntry(nextEntry)
}
/**
@@ -108,11 +110,11 @@ abstract class Installer(private val service: Service) {
@CallSuper
open fun onDestroy() {
cancelListeners -= cancelListener
queue.forEach {
val remaining = synchronized(queue) { queue.toList().also { queue.clear() } }
remaining.forEach {
logcat(LogPriority.INFO) { "The installer stopped before installing ${it.downloadId}" }
extensionManager.updateInstallStep(it.downloadId, InstallStep.Error)
}
queue.clear()
waitingInstall.store(null)
}
@@ -125,7 +127,10 @@ abstract class Installer(private val service: Service) {
*/
private fun cancelQueue(downloadId: Long) {
val waitingInstall = this.waitingInstall.load()
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
// Every new install cancels its own previous attempt first, which mustn't hit another extension's install
val toCancel = synchronized(queue) { queue.find { it.downloadId == downloadId } }
?: waitingInstall?.takeIf { it.downloadId == downloadId }
?: return
if (cancelEntry(toCancel)) {
queue.remove(toCancel)
if (waitingInstall == toCancel) {
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.extension.installer
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.Service
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -57,7 +58,16 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
return
}
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 -> {
continueQueue(InstallStep.Idle)
@@ -24,6 +24,7 @@ import mihon.domain.extension.model.ContentWarning
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.system.logcat
import java.io.File
import java.io.IOException
/**
* 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 staged = File(target.parentFile, "${target.name}.part")
try {
target.delete()
file.copyAndSetReadOnlyTo(target, overwrite = true)
} catch (e: Exception) {
target.delete()
throw e
file.copyAndSetReadOnlyTo(staged, overwrite = true)
if (!staged.renameTo(target)) throw IOException("Unable to replace ${target.name}")
} finally {
staged.delete()
}
if (currentExtension != null) {
ExtensionInstallReceiver.notifyReplaced(context, extension.packageName)
@@ -239,11 +239,8 @@ class BrowseSourceViewModel(
fun changeMangaFavorite(manga: Manga) {
appScope.launch {
val update = if (manga.favorite) {
val coverLastModified = manga.removeCovers(coverCache).coverLastModified
MangaUpdate(manga.id) {
favoriteAt = null
if (coverLastModified != manga.coverLastModified) this.coverLastModified = coverLastModified
}
manga.removeCovers(coverCache)
MangaUpdate(manga.id) { favoriteAt = null }
} else {
setMangaDefaultChapterFlags.await(manga)
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_INCLUDE -> TriState.ENABLED_IS
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
import android.view.LayoutInflater
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -19,7 +17,6 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -27,17 +24,11 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
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.recyclerview.widget.LinearLayoutManager
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
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.DropdownMenu
import eu.kanade.presentation.components.NestedMenuItem
import eu.kanade.presentation.download.DownloadQueueList
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.automirroredrounded.Sort
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.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import kotlin.math.roundToInt
object DownloadQueueScreen : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val viewModel = metroViewModel<DownloadQueueViewModel>()
val downloadList by viewModel.state.collectAsStateWithLifecycle()
val downloadCount by remember {
derivedStateOf { downloadList.sumOf { it.subItems.size } }
derivedStateOf { downloadList.sumOf { it.downloads.size } }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@@ -135,7 +122,7 @@ object DownloadQueueScreen : Screen() {
text = { Text(text = stringResource(MR.strings.action_newest)) },
onClick = {
viewModel.reorderQueue(
{ it.download.chapter.dateUpload },
{ it.chapter.dateUpload },
true,
)
closeMenu()
@@ -145,7 +132,7 @@ object DownloadQueueScreen : Screen() {
text = { Text(text = stringResource(MR.strings.action_oldest)) },
onClick = {
viewModel.reorderQueue(
{ it.download.chapter.dateUpload },
{ it.chapter.dateUpload },
false,
)
closeMenu()
@@ -160,7 +147,7 @@ object DownloadQueueScreen : Screen() {
text = { Text(text = stringResource(MR.strings.action_asc)) },
onClick = {
viewModel.reorderQueue(
{ it.download.chapter.chapterNumber },
{ it.chapter.chapterNumber },
false,
)
closeMenu()
@@ -170,7 +157,7 @@ object DownloadQueueScreen : Screen() {
text = { Text(text = stringResource(MR.strings.action_desc)) },
onClick = {
viewModel.reorderQueue(
{ it.download.chapter.chapterNumber },
{ it.chapter.chapterNumber },
true,
)
closeMenu()
@@ -240,49 +227,17 @@ object DownloadQueueScreen : Screen() {
return@Scaffold
}
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val left = with(density) { contentPadding.calculateLeftPadding(layoutDirection).toPx().roundToInt() }
val top = with(density) { contentPadding.calculateTopPadding().toPx().roundToInt() }
val right = with(density) { contentPadding.calculateRightPadding(layoutDirection).toPx().roundToInt() }
val bottom = with(density) { contentPadding.calculateBottomPadding().toPx().roundToInt() }
Box(modifier = Modifier.nestedScroll(nestedScrollConnection)) {
AndroidView(
modifier = Modifier.fillMaxWidth(),
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)
},
)
}
DownloadQueueList(
sections = downloadList,
lazyListState = rememberLazyListState(),
contentPadding = contentPadding,
onReorder = viewModel::reorder,
onMove = viewModel::move,
onMoveSeries = viewModel::moveSeries,
onCancel = { viewModel.cancel(listOf(it)) },
onCancelSeries = viewModel::cancelSeries,
modifier = Modifier.nestedScroll(nestedScrollConnection),
)
}
}
}
@@ -1,33 +1,22 @@
package eu.kanade.tachiyomi.ui.download
import android.view.MenuItem
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadManager
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.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
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.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Inject
@@ -37,117 +26,18 @@ class DownloadQueueViewModel(
private val downloadManager: DownloadManager,
) : ViewModel() {
val state: StateFlow<List<DownloadHeaderItem>> = downloadManager.queueState
val state: StateFlow<List<DownloadQueueSection>> = downloadManager.queueState
.map { downloads ->
downloads
.groupBy { it.source }
.map { entry ->
DownloadHeaderItem(entry.key.id, entry.key.name, entry.value.size).apply {
addSubItems(0, entry.value.map { DownloadItem(it, this) })
}
}
.map { (source, downloads) -> DownloadQueueSection(source.id, source.name, downloads) }
}
.flowOn(Dispatchers.Default)
.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
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), false)
fun getDownloadStatusFlow() = downloadManager.statusFlow()
fun getDownloadProgressFlow() = downloadManager.progressFlow()
fun startDownloads() {
downloadManager.startDownloads()
}
@@ -168,102 +58,59 @@ class DownloadQueueViewModel(
viewModelScope.launch { downloadManager.cancelQueuedDownloads(downloads) }
}
fun <R : Comparable<R>> reorderQueue(selector: (DownloadItem) -> R?, reverse: Boolean = false) {
val adapter = adapter ?: return
val newDownloads = mutableListOf<Download>()
adapter.headerItems.forEach { headerItem ->
headerItem as DownloadHeaderItem
headerItem.subItems = headerItem.subItems.sortedBy(selector).toMutableList().apply {
if (reverse) {
reverse()
/**
* Moves [download] to the top or bottom of its section.
*/
fun move(download: Download, toTop: Boolean) {
reorder(
state.value.flatMap { section ->
if (download in section.downloads) {
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.
*
* @param download the download whose status has changed.
* Moves all the downloads of the manga of [download] to the top or bottom of the queue.
*/
fun onStatusChange(download: Download) {
when (download.status) {
Download.State.DOWNLOADING -> {
launchProgressJob(download)
// Initial update of the downloaded pages
onUpdateDownloadedPages(download)
}
Download.State.DOWNLOADED -> {
cancelProgressJob(download)
onUpdateProgress(download)
onUpdateDownloadedPages(download)
}
Download.State.ERROR -> cancelProgressJob(download)
else -> {
/* unused */
}
fun moveSeries(download: Download, toTop: Boolean) {
val (selectedSeries, otherSeries) = state.value
.flatMap { it.downloads }
.partition { it.manga.id == download.manga.id }
reorder(if (toTop) selectedSeries + otherSeries else otherSeries + selectedSeries)
}
fun cancelSeries(download: Download) {
val allDownloadsForSeries = state.value
.flatMap { it.downloads }
.filter { it.manga.id == download.manga.id }
if (allDownloadsForSeries.isNotEmpty()) {
cancel(allDownloadsForSeries)
}
}
/**
* Observe the progress of a download and notify the view.
*
* @param download the download to observe its progress.
* Sorts the downloads of every section by [selector].
*/
private fun launchProgressJob(download: Download) {
val job = viewModelScope.launch {
val pages = download.pagesFlow.filterNotNull().first()
val progressFlows = pages.map(Page::progressFlow)
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
fun <R : Comparable<R>> reorderQueue(selector: (Download) -> R?, reverse: Boolean = false) {
reorder(
state.value.flatMap { section ->
section.downloads.sortedBy(selector).let { if (reverse) it.reversed() else it }
},
)
}
}
/**
* Downloads of the queue from one source.
*/
@Immutable
data class DownloadQueueSection(
val id: Long,
val name: String,
val downloads: List<Download>,
)
@@ -179,6 +179,9 @@ class MainActivity : BaseActivity() {
Migrator.awaitAndRelease()
isAppMigrating.value = false
}
// Uncaught on purpose: a failed database migration otherwise leaves the migrating screen up for good,
// while the crash screen tells what went wrong and lets the logs be shared
lifecycleScope.launch { databaseRepository.awaitOpen() }
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
if (!isTaskRoot) {
@@ -337,6 +340,16 @@ class MainActivity : BaseActivity() {
}
}
override fun reportFullyDrawn() {
try {
super.reportFullyDrawn()
} catch (e: SecurityException) {
// Some Samsung Android 9 to 11 builds deny the platform's fully drawn broadcast
// and the report only feeds startup metrics, so it shouldn't crash the app
logcat(LogPriority.WARN, e)
}
}
@Composable
private fun HandleOnNewIntent(context: Context, navigator: Navigator) {
LaunchedEffect(Unit) {
@@ -414,10 +414,7 @@ class MangaViewModel(
if (isFavorited) {
// Remove from library
if (updateManga.awaitUpdateFavorite(manga.id, false)) {
// Remove covers and update last modified in db
if (manga.removeCovers(coverCache) != manga) {
updateManga.awaitUpdateCoverLastModified(manga.id)
}
manga.removeCovers(coverCache)
onRemoved()
}
} else {
@@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.text.style.TextAlign
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -104,9 +105,15 @@ data class TrackInfoDialogHomeScreen(
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val locale = LocalLocale.current
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()
TrackInfoDialogHome(
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.more
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -23,6 +22,7 @@ import eu.kanade.core.preference.asState
import eu.kanade.domain.base.BasePreferences
import eu.kanade.presentation.more.MoreScreen
import eu.kanade.presentation.util.Tab
import eu.kanade.presentation.util.rememberAnimatedVectorPainter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.ui.category.CategoryScreen
@@ -45,11 +45,12 @@ data object MoreTab : Tab {
@Composable
get() {
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(
index = 4u,
title = stringResource(MR.strings.label_more),
icon = rememberAnimatedVectorPainter(image, isSelected),
icon = rememberAnimatedVectorPainter(enter, exit, isSelected),
)
}
@@ -26,7 +26,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
/**
* 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
private set
@@ -68,13 +68,15 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
val lastPage = pages.last()
// Insert preprocessed pages into current page list
preprocessed.keys.sortedDescending()
.forEach { key ->
if (lastPage.index == key) {
insertPageLastPage = preprocessed[key]
// Insert preprocessed pages into current page list. Pages of the adjacent chapters are split too, and
// placed by their index, so only this chapter's belong here.
preprocessed.filter { it.chapter.chapter.id == chapters.currChapter.chapter.id }
.sortedByDescending { it.index }
.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)
@@ -96,6 +98,8 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
chapters.nextChapter?.pages?.let(newItems::addAll)
val currentItem = items.getOrNull(viewer.pager.currentItem)
// Resets double-page splits, else insert pages get misplaced
items.filterIsInstance<InsertPage>().also { items.removeAll(it) }
@@ -103,10 +107,19 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
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
notifyDataSetChanged()
// The pager only follows the pages it has created, so before its first layout it would stay at the same
// position while pages are added in front, e.g. the next chapter's in right to left, and land in another
// chapter
val currentPosition = items.indexOf(currentItem)
if (currentPosition != -1 && currentPosition != viewer.pager.currentItem) {
viewer.pager.setCurrentItem(currentPosition, false)
}
// Will skip insert page otherwise
if (insertPageLastPage != null) {
viewer.moveToPage(insertPageLastPage)
@@ -151,9 +164,12 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
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) {
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
}
@@ -243,7 +243,9 @@ class WebtoonRecyclerView @JvmOverloads constructor(
inner class GestureListener : GestureDetectorWithLongTap.Listener() {
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)
}
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.openInBrowser
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.view.setComposeContent
import kotlinx.coroutines.Dispatchers
@@ -71,7 +72,7 @@ class WebViewActivity : BaseActivity() {
val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource
value = withContext(Dispatchers.IO) {
try {
source?.headers?.toMultimap()?.mapValues { it.value.getOrNull(0) ?: "" }.orEmpty()
source?.headers?.toWebViewHeaders().orEmpty()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to build headers" }
emptyMap()
@@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toWebViewHeaders
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -47,7 +48,7 @@ class WebViewViewModel(
viewModelScope.launch(Dispatchers.IO) {
val source = sourceId?.let { sourceManager.get(it) as? HttpSource }
headers.value = try {
source?.headers?.toMultimap()?.mapValues { it.value.getOrNull(0) ?: "" }.orEmpty()
source?.headers?.toWebViewHeaders().orEmpty()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to build headers" }
emptyMap()
@@ -7,15 +7,10 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.image.LocalCoverManager
import tachiyomi.source.local.isLocal
import java.io.InputStream
import kotlin.time.Clock
fun Manga.removeCovers(coverCache: CoverCache): Manga {
if (isLocal()) return this
return if (coverCache.deleteFromCache(this, true) > 0) {
copy(coverLastModified = Clock.System.now())
} else {
this
}
fun Manga.removeCovers(coverCache: CoverCache) {
if (isLocal()) return
coverCache.deleteFromCache(this, true)
}
suspend fun Manga.editCover(
@@ -13,21 +13,17 @@ import tachiyomi.core.common.i18n.pluralStringResource
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.absoluteValue
import kotlin.time.Clock
import kotlin.time.Instant
fun LocalDateTime.toDateTimestampString(dateTimeFormatter: DateTimeFormatter): String {
val javaLocalDateTime = this.toJavaLocalDateTime()
val date = dateTimeFormatter.format(javaLocalDateTime)
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).format(javaLocalDateTime)
return "$date $time"
return dateTimeFormatter.format(javaLocalDateTime)
}
fun Instant.toTimestampString(): String {
return DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.format(toLocalDateTime(TimeZone.currentSystemDefault()).toJavaLocalDateTime())
fun Instant.toTimestampString(timeFormatter: DateTimeFormatter): String {
return timeFormatter.format(toLocalDateTime(TimeZone.currentSystemDefault()).toJavaLocalDateTime())
}
fun Long.convertEpochMillisZone(
@@ -55,7 +51,7 @@ fun Instant.toJavaLocalDate(): java.time.LocalDate {
fun LocalDate.toRelativeString(
context: Context,
relative: Boolean = true,
dateFormat: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT),
dateFormat: DateTimeFormatter,
): String {
if (!relative) {
return dateFormat.format(this.toJavaLocalDate())
@@ -22,6 +22,7 @@ import eu.kanade.tachiyomi.util.lang.truncateCenter
import logcat.LogPriority
import mihon.app.di.appGraph
import rikka.shizuku.ShizukuProvider
import rikka.sui.Sui
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
@@ -168,12 +169,13 @@ fun Context.isPackageInstalled(packageName: String): Boolean {
val Context.hasMiuiPackageInstaller get() = isPackageInstalled("com.miui.packageinstaller")
val Context.isShizukuInstalled: Boolean
get() = try {
packageManager.getPermissionInfo(ShizukuProvider.PERMISSION, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
get() = Sui.isSui() ||
try {
packageManager.getPermissionInfo(ShizukuProvider.PERMISSION, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
fun Context.launchRequestPackageInstallsPermission() {
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.graphics.Rect
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.ComponentActivity
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.LocalTextStyle
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 {
if (this == null) {
return false
@@ -1,9 +1,11 @@
package mihon.core.migration
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
class MigrationJobFactory(
@@ -20,7 +22,18 @@ class MigrationJobFactory(
}
async {
val prev = acc.await()
migration(migrationContext) || prev
// A failure would cancel the rest of the chain and leave the app waiting on it for good
val ran = try {
migration(migrationContext)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) {
"Migration failed: { name = ${migration::class.simpleName}, version = ${migration.version} }"
}
false
}
ran || prev
}
} else {
logcat {
@@ -69,7 +69,7 @@ class UpdateMangaFromRemote(
fetchDetails = fetchDetails,
fetchChapters = fetchChapters,
)
awaitUpdateFromSource(manga, update.manga, fetchDetails, manualFetch)
awaitUpdateFromSource(manga, update.manga, manualFetch)
val newChapters = syncChaptersWithSource.await(
rawSourceChapters = update.chapters,
manga = manga,
@@ -89,7 +89,6 @@ class UpdateMangaFromRemote(
private suspend fun awaitUpdateFromSource(
localManga: Manga,
remoteManga: SManga,
fetchDetails: Boolean,
manualFetch: Boolean,
): Boolean {
val remoteTitle = try {
@@ -135,7 +134,7 @@ class UpdateMangaFromRemote(
thumbnailUrl = thumbnailUrl,
status = remoteManga.status.toLong(),
updateStrategy = remoteManga.update_strategy,
initialized = localManga.initialized || fetchDetails,
initialized = true,
memo = remoteManga.memo,
),
)
@@ -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.SectionHeader" parent="TextAppearance.Material3.LabelLarge">
<item name="android:textColor">?android:attr/textColorSecondary</item>
</style>
<!--=======================-->
<!--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()) {
val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.HEADERS
if (preferences.redactSensitiveHeaders.get()) {
redactHeader("Authorization")
redactHeader("Auth")
}
}
builder.addNetworkInterceptor(httpLoggingInterceptor)
}
@@ -14,6 +14,11 @@ class NetworkPreferences(
@IsDebugBuild isDebugBuild: Boolean,
) {
val redactSensitiveHeaders: Preference<Boolean> = preferenceStore.getBoolean(
"redact_sensitive_headers",
true,
)
val verboseLogging: Preference<Boolean> = preferenceStore.getBoolean(
"verbose_logging",
isDebugBuild,
@@ -3,25 +3,35 @@ package eu.kanade.tachiyomi.network.interceptor
import android.annotation.SuppressLint
import android.content.Context
import android.webkit.JavascriptInterface
import android.webkit.RenderProcessGoneDetail
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
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.util.system.isOutdated
import eu.kanade.tachiyomi.util.system.toWebViewHeaders
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import logcat.LogPriority
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
class CloudflareInterceptor(
private val context: Context,
@@ -32,6 +42,18 @@ class CloudflareInterceptor(
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 {
// Check if Cloudflare anti-bot is on
// Checking the cf-mitigated header is the official way to detect a Cloudflare challenge:
@@ -39,19 +61,50 @@ class CloudflareInterceptor(
return response.header("cf-mitigated") == "challenge" && response.header("Server") in SERVER_CHECK
}
override fun intercept(
chain: Interceptor.Chain,
request: Request,
response: Response,
): Response {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val lock = hostLocks.computeIfAbsent(request.url.host) { ReentrantReadWriteLock() }
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 {
response.close()
cookieManager.remove(request.url, COOKIE_NAMES, 0)
val oldCookie = cookieManager.get(request.url)
.firstOrNull { it.name == "cf_clearance" }
resolveWithWebView(request, oldCookie)
return chain.proceed(request)
resolveWithWebView(request, clearance)
}
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
@@ -63,64 +116,84 @@ class CloudflareInterceptor(
}
@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
// OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1)
var webview: WebView? = null
var listenerScriptHandler: ScriptHandler? = null
val useIsolatedWorld = WebViewFeature.isFeatureSupported(WebViewFeature.JS_INJECTION_IN_FRAME_AND_WORLD)
var challengeFound = false
var cloudflareBypassed = false
var challengeFailed = false
var isWebViewOutdated = false
var isRenderProcessGone = false
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 {
webview = createWebView(originalRequest)
webview.addJavascriptInterface(
object {
@Suppress("unused")
@JavascriptInterface
fun interactiveDetected() {
// The challenge cannot be solved non-interactively, abort.
fun onChallengeEvent(event: String) {
when (event) {
// The challenge cannot be solved non-interactively, abort.
"interactiveBegin" -> latch.countDown()
"fail" -> {
challengeFailed = true
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() {
override fun onPageFinished(view: WebView, url: String) {
fun isCloudFlareBypassed(): Boolean {
return cookieManager.get(origRequestUrl.toHttpUrl())
.firstOrNull { it.name == "cf_clearance" }
.let { it != null && it != oldCookie }
}
if (isCloudFlareBypassed()) {
if (!challengeFailed && isCleared(originalRequest.url, originalClearance)) {
cloudflareBypassed = true
latch.countDown()
}
if (url == origRequestUrl) {
if (url == challengeUrl) {
if (!challengeFound) {
// The first request didn't return the challenge, abort.
latch.countDown()
} else {
// Listen for an interactiveBegin event
view.evaluateJavascript(
"""
addEventListener("message", ({data}) => {
if (data?.source === "cloudflare-challenge" && data?.event === "interactiveBegin") {
mihon.interactiveDetected();
}
})
""".trimIndent(),
null,
)
} else if (!useIsolatedWorld) {
view.evaluateJavascript(listenerScript, null)
}
}
}
@@ -140,26 +213,52 @@ class CloudflareInterceptor(
}
}
}
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
// Not handling it kills the app, failing the bypass is enough
logcat(LogPriority.ERROR) {
"WebView renderer gone during Cloudflare bypass: crashed=${detail.didCrash()}, " +
"priority=${detail.rendererPriorityAtExit()}, url=$origRequestUrl"
}
isRenderProcessGone = true
latch.countDown()
return true
}
}
webview.loadUrl(origRequestUrl, headers)
webview.loadUrl(challengeUrl, headers)
}
latch.awaitFor30Seconds()
executor.execute {
if (!cloudflareBypassed) {
// Only destroy can be called on a WebView whose renderer is gone
if (!cloudflareBypassed && !isRenderProcessGone) {
isWebViewOutdated = webview?.isOutdated() == true
}
webview?.run {
stopLoading()
if (!isRenderProcessGone) {
if (useIsolatedWorld) {
WebViewCompat.removeWebMessageListener(
this,
WebViewCompat.getExecutionWorld(this, "mihon"),
"mihon",
)
} else {
removeJavascriptInterface("mihon")
}
listenerScriptHandler?.remove()
stopLoading()
}
destroy()
}
}
// Throw exception if we failed to bypass Cloudflare
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
if (isWebViewOutdated) {
context.toast(MR.strings.information_webview_outdated, Toast.LENGTH_LONG)
@@ -170,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 COOKIE_NAMES = listOf("cf_clearance")
@@ -13,12 +13,10 @@ import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import tachiyomi.i18n.MR
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@@ -59,25 +57,23 @@ abstract class WebViewInterceptor(
return response
}
if (!WebViewUtil.supportsWebView(context)) {
scope.launch(Dispatchers.Main) {
context.toast(MR.strings.information_webview_required, Toast.LENGTH_LONG)
}
return response
}
initWebView
if (!prepareWebView()) return response
return intercept(chain, request, response)
}
fun parseHeaders(headers: Headers): Map<String, String> {
return headers
// Keeping unsafe header makes webview throw [net::ERR_INVALID_ARGUMENT]
.filter { (name, value) ->
isRequestHeaderSafe(name, value)
/**
* Returns whether a WebView can be used, initializing it if so.
*/
protected fun prepareWebView(): Boolean {
if (!WebViewUtil.supportsWebView(context)) {
scope.launch(Dispatchers.Main) {
context.toast(MR.strings.information_webview_required, Toast.LENGTH_LONG)
}
.groupBy(keySelector = { (name, _) -> name }) { (_, value) -> value }
.mapValues { it.value.getOrNull(0).orEmpty() }
return false
}
initWebView
return true
}
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",
)
@@ -11,7 +11,9 @@ import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import kotlinx.coroutines.suspendCancellableCoroutine
import logcat.LogPriority
import okhttp3.Headers
import tachiyomi.core.common.util.system.logcat
import java.util.Locale
import kotlin.coroutines.resume
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 CHROMIUM_BRAND = "Chromium"
private const val CHROME_BRAND = "Google Chrome"
@@ -198,120 +198,6 @@ object ImageUtil {
LEFT,
}
/**
* Check whether the image is considered a tall image.
*
* @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,
)
}
/**
* Splits tall images to improve performance of reader
*/
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(
Locale.ENGLISH,
index + 1,
)}.jpg"
private val BitmapFactory.Options.splitData
get(): List<SplitData> {
val imageHeight = outHeight
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
}
fun canUseHardwareBitmap(bitmap: Bitmap): Boolean {
return canUseHardwareBitmap(bitmap.width, bitmap.height)
}
@@ -573,17 +459,6 @@ object ImageUtil {
return options
}
private fun getBitmapRegionDecoder(imageStream: InputStream): BitmapRegionDecoder? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(imageStream)
} else {
@Suppress("DEPRECATION")
BitmapRegionDecoder.newInstance(imageStream, false)
}
}
private val optimalImageHeight = getDisplayMaxHeightInPx * 2
/**
* Taken from Coil
* (https://github.com/coil-kt/coil/blob/1674d3516f061aeacbe749a435b1924f9648fd41/coil-core/src/androidMain/kotlin/coil3/util/hardwareBitmaps.kt)
@@ -699,6 +574,3 @@ object ImageUtil {
else -> false
}
}
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))
}
}
@@ -1,16 +1,29 @@
package mihon.data.database
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import mihon.domain.database.repository.DatabaseRepository
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DatabaseRepositoryImpl : DatabaseRepository {
class DatabaseRepositoryImpl(
// Lazy since the driver reports its migration state here
private val driver: Lazy<SqlDriver>,
) : DatabaseRepository {
override val isMigrating = MutableStateFlow(false)
override suspend fun awaitOpen() {
withContext(Dispatchers.IO) {
driver.value.executeQuery(null, "SELECT 1", { QueryResult.Value(Unit) }, 0).await()
}
}
}
@@ -34,14 +34,6 @@ class RestoreRepositoryImpl(
private val trackRepository: TrackRepository,
) : 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) {
database.transaction {
entries.forEach { entry ->
@@ -50,6 +50,15 @@ class CategoryRepositoryImpl(
.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) {
database.categoryQueries.insert(name = category.name, flags = category.flags)
}
@@ -113,6 +113,13 @@ class ChapterRepositoryImpl(
.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 {
return database.chapterQueries
.getChapterSummaryByMangaId(mangaId) { total, latestChapterNumber ->
@@ -48,6 +48,15 @@ class HistoryRepositoryImpl(
.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) {
try {
database.historyQueries.resetHistoryById(historyId)
@@ -144,6 +144,13 @@ class MangaRepositoryImpl(
.toSet()
}
override suspend fun getExcludedScanlatorsByMangaIds(mangaIds: List<Long>): Map<Long, List<String>> {
return database.excluded_scanlatorQueries
.getExcludedScanlatorsByMangaIds(mangaIds)
.awaitAsList()
.groupBy({ it.manga_id }, { it.scanlator })
}
override fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow<Set<String>> {
return database.excluded_scanlatorQueries
.getExcludedScanlatorsByMangaId(mangaId)
@@ -31,6 +31,13 @@ class TrackRepositoryImpl(
.awaitAsList()
}
override suspend fun getTracksByMangaIds(mangaIds: List<Long>): Map<Long, List<Track>> {
return database.manga_trackQueries
.getTracksByMangaIds(mangaIds, TrackMapper::mapTrack)
.awaitAsList()
.groupBy { it.mangaId }
}
override suspend fun getTracks(): List<Track> {
return database.manga_trackQueries
.getTracks(TrackMapper::mapTrack)
@@ -34,6 +34,14 @@ ON C.id = MC.category_id
WHERE MC.manga_id = :mangaId
ORDER BY C.`order`;
getCategoriesByMangaIds:
SELECT MC.manga_id, C.id, C.name, C.`order`, C.flags
FROM category C
JOIN manga_category MC
ON C.id = MC.category_id
WHERE MC.manga_id IN :mangaIds
ORDER BY MC.manga_id, C.`order`;
insert:
-- The position is taken inside the insert, so two categories created at once cannot claim the same one
INSERT INTO category(name, `order`, flags)
@@ -48,6 +48,16 @@ AND (
OR ES.scanlator IS NULL
);
getChaptersByMangaIds:
-- The unique index's order, which getChaptersByMangaId returns too, so backups keep their chapter order
SELECT
id, manga_id, remote_url, remote_name, remote_scanlator, remote_chapter_number,
remote_date_upload, remote_order, remote_memo, user_read, user_bookmark,
user_last_page_read, state_date_fetch
FROM chapter
WHERE manga_id IN :mangaIds
ORDER BY manga_id, remote_url;
getChapterSummaryByMangaId:
SELECT count(*) AS total, max(remote_chapter_number) AS latestChapterNumber
FROM chapter
@@ -21,3 +21,9 @@ getExcludedScanlatorsByMangaId:
SELECT scanlator
FROM excluded_scanlator
WHERE manga_id = :mangaId;
getExcludedScanlatorsByMangaIds:
SELECT manga_id, scanlator
FROM excluded_scanlator
WHERE manga_id IN :mangaIds
ORDER BY manga_id, scanlator;
@@ -20,6 +20,12 @@ SELECT id, chapter_id, read_at, read_duration
FROM history
WHERE manga_id = :mangaId;
getHistoryByMangaIds:
SELECT manga_id, id, chapter_id, read_at, read_duration
FROM history
WHERE manga_id IN :mangaIds
ORDER BY manga_id, id;
resetHistoryById:
UPDATE history
SET read_at = NULL
@@ -79,10 +79,6 @@ AND EXISTS (
AND (user_read = 1 OR user_last_page_read != 0)
);
getAllMangaSourceAndUrl:
SELECT source_id, remote_url
FROM manga;
getSourceIdWithFavoriteCount:
SELECT source_id, count(*)
FROM manga
@@ -48,6 +48,14 @@ SELECT
FROM manga_track
WHERE manga_id = :mangaId;
getTracksByMangaIds:
SELECT
id, manga_id, tracker_id, remote_id, remote_url, library_id, title, last_chapter_read,
total_chapters, status, score, start_date, finish_date, private
FROM manga_track
WHERE manga_id IN :mangaIds
ORDER BY manga_id, tracker_id;
upsert:
INSERT INTO manga_track(
manga_id, tracker_id, remote_id, remote_url, library_id, title, last_chapter_read,
@@ -9,8 +9,9 @@ DROP INDEX library_favorite_index;
ALTER TABLE mangas ADD COLUMN favorite_at INTEGER;
-- favorite_modified_at is no fallback for a missing date_added: every manga update rewrote it
UPDATE mangas
SET favorite_at = coalesce(nullif(date_added, 0), favorite_modified_at * 1000, 0)
SET favorite_at = coalesce(date_added, 0)
WHERE favorite = 1;
ALTER TABLE mangas DROP COLUMN favorite;
@@ -4,15 +4,81 @@ import kotlin.String;
import kotlin.collections.List;
import kotlinx.serialization.json.JsonObject;
-- ---------------------------------------------------------------------------------------------------------
-- table - manga
-- ---------------------------------------------------------------------------------------------------------
-- Every view reads `mangas`, so they are dropped here and recreated against the new table below
-- Every view reads `mangas`, so they are dropped here and recreated against the new tables at the end
DROP VIEW historyView;
DROP VIEW libraryView;
DROP VIEW updatesView;
-- ---------------------------------------------------------------------------------------------------------
-- damaged indexes
-- ---------------------------------------------------------------------------------------------------------
-- Some databases hold rows their unique constraints should have rejected, which a damaged index explains. A
-- write to a table also writes its indexes and fails on a damaged one, and a read through one can miss rows,
-- so each table changed in place below is first copied from its rows alone, leaving its indexes behind.
CREATE TABLE mangas_copy AS
SELECT
_id, source, url, artist, author, description, genre, title, status, thumbnail_url, last_update, next_update,
initialized, viewer, chapter_flags, cover_last_modified, update_strategy, calculate_interval, notes, memo,
favorite_at
FROM mangas NOT INDEXED;
DROP TABLE mangas;
ALTER TABLE mangas_copy RENAME TO mangas;
CREATE TABLE chapters_copy AS
SELECT
_id, manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch,
date_upload, memo
FROM chapters NOT INDEXED;
DROP TABLE chapters;
ALTER TABLE chapters_copy RENAME TO chapters;
CREATE TABLE history_copy AS
SELECT _id, chapter_id, last_read, time_read
FROM history NOT INDEXED;
DROP TABLE history;
ALTER TABLE history_copy RENAME TO history;
CREATE TABLE mangas_categories_copy AS
SELECT _id, manga_id, category_id
FROM mangas_categories NOT INDEXED;
DROP TABLE mangas_categories;
ALTER TABLE mangas_categories_copy RENAME TO mangas_categories;
CREATE TABLE manga_sync_copy AS
SELECT
_id, manga_id, sync_id, remote_id, library_id, title, last_chapter_read, total_chapters, status, score,
remote_url, start_date, finish_date, private
FROM manga_sync NOT INDEXED;
DROP TABLE manga_sync;
ALTER TABLE manga_sync_copy RENAME TO manga_sync;
CREATE TABLE excluded_scanlators_copy AS
SELECT manga_id, scanlator
FROM excluded_scanlators NOT INDEXED;
DROP TABLE excluded_scanlators;
ALTER TABLE excluded_scanlators_copy RENAME TO excluded_scanlators;
-- Merging duplicate chapters looks their history up by chapter
CREATE INDEX history_chapter_id_index ON history(chapter_id);
-- ---------------------------------------------------------------------------------------------------------
-- orphans
-- ---------------------------------------------------------------------------------------------------------
-- Rows pointing at an entry, chapter or category that no longer exists were written while foreign keys weren't
-- enforced. Nothing can reach them, and copied over they'd fail the foreign key check that ends the migration.
DELETE FROM chapters WHERE manga_id NOT IN (SELECT _id FROM mangas);
DELETE FROM history WHERE chapter_id NOT IN (SELECT _id FROM chapters);
DELETE FROM mangas_categories
WHERE manga_id NOT IN (SELECT _id FROM mangas) OR category_id NOT IN (SELECT _id FROM categories);
DELETE FROM manga_sync WHERE manga_id NOT IN (SELECT _id FROM mangas);
DELETE FROM excluded_scanlators WHERE manga_id NOT IN (SELECT _id FROM mangas);
-- ---------------------------------------------------------------------------------------------------------
-- table - manga
-- ---------------------------------------------------------------------------------------------------------
-- Entries sharing a source and url are merged into one, since the new table won't hold both. The copies are
-- ranked with the ones in the library first, since that is the entry the user has, then by age. The first
-- one stays, takes each detail it is missing from the first copy that has it, and takes over everything
@@ -31,6 +97,9 @@ WHERE (source, url) IN (
HAVING count(*) > 1
);
-- Moving the chapters looks each one's entry up here, which without an index scans the whole table per chapter
CREATE INDEX duplicate_mangas_id_index ON duplicate_mangas(_id);
UPDATE mangas
SET favorite_at = merged.favorite_at,
initialized = merged.initialized,
@@ -310,8 +379,7 @@ CREATE INDEX manga_category_category_id_index ON manga_category(category_id);
-- ---------------------------------------------------------------------------------------------------------
-- `last_read` is when the chapter was last read and `time_read` is how long it has been read for, so
-- both are renamed to say so. The chapter it belongs to is already unique, which leaves
-- `history_history_chapter_id_index` a prefix of that constraint.
-- both are renamed to say so. The chapter it belongs to is already unique, so the constraint indexes it.
-- The table keeps its name, so the original is renamed aside and the new one built under
-- the real name; renaming a table into place makes SQLite rewrite its stored definition,
-- leaving it spelled differently from the same table on a fresh install. The views were
@@ -333,11 +401,14 @@ CREATE TABLE history(
-- Reaching the entry a row belongs to meant going through its chapter; it names the entry
-- itself now, taken from that same chapter. A row whose chapter is already gone cannot name
-- one and is dropped, which is what the foreign key would do to it once it is enforced again.
-- Some databases hold more than one row for a chapter despite the old unique constraint, and the new one
-- rejects them, so they merge the way the history of duplicate chapters does above.
INSERT INTO history(id, manga_id, chapter_id, read_at, read_duration)
SELECT H._id, C.manga_id, H.chapter_id, H.last_read, H.time_read
SELECT min(H._id), C.manga_id, H.chapter_id, max(H.last_read), sum(H.time_read)
FROM history_temp H
JOIN chapter C
ON C.id = H.chapter_id;
ON C.id = H.chapter_id
GROUP BY H.chapter_id;
DROP TABLE history_temp;
@@ -372,19 +443,20 @@ DROP TABLE excluded_scanlators;
-- ---------------------------------------------------------------------------------------------------------
-- `manga_sync` is what tracking was called when it was still called syncing; `sync_id` is the tracker.
-- An entry that merged can leave two rows for the same tracker on the survivor, which the unique constraint
-- rejects, so the one to keep is chosen first: one on a copy in the library, then the one furthest read, then
-- the one on the copy ranked first, then the oldest. A track is taken whole rather than merged field by field,
-- because two rows on the same tracker can point at different remote entries.
-- An entry that merged can leave two rows for the same tracker on the survivor, and an entry that didn't can
-- still hold two despite the old unique constraint. The new one rejects both, so the one to keep is chosen
-- first: one on a copy in the library, then the one furthest read, then the one on the copy ranked first, then
-- the oldest. A track is taken whole rather than merged field by field, because two rows on the same tracker
-- can point at different remote entries.
CREATE TEMP TABLE duplicate_tracks AS
SELECT
T._id,
first_value(T._id) OVER (
PARTITION BY D.keep_id, T.sync_id
PARTITION BY coalesce(D.keep_id, T.manga_id), T.sync_id
ORDER BY D.favorite DESC, T.last_chapter_read DESC, D.precedence ASC, T._id ASC
) AS keep_track_id
FROM manga_sync T
JOIN duplicate_mangas D
LEFT JOIN duplicate_mangas D
ON D._id = T.manga_id;
DELETE FROM manga_sync
@@ -9,4 +9,9 @@ interface DatabaseRepository {
* app update.
*/
val isMigrating: StateFlow<Boolean>
/**
* Opens the database, migrating it if needed, and throws what made the migration fail.
*/
suspend fun awaitOpen()
}
@@ -6,8 +6,6 @@ import tachiyomi.domain.manga.model.MangaUpdate
interface RestoreRepository {
suspend fun getMangaUrlsBySourceId(): Map<Long, Set<String>>
/**
* Restores [entries] all together: either every one of them is restored or none is. [update] is what to change on each
* entry once its chapters and history are in place, and is part of the same all-or-nothing restore.

Some files were not shown because too many files have changed in this diff Show More