Compare commits

..

98 Commits

Author SHA1 Message Date
Weblate (bot)
e36b4ce60b Translations update from Hosted Weblate (#2639)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/vi/
Translation: Mihon/Mihon

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Co-authored-by: Nguyễn Trung Đức <vaicato16@gmail.com>
Co-authored-by: Swyter <swyterzone@gmail.com>
2025-11-03 10:38:47 +06:00
AntsyLich
6d543024a3 Migrated to the Android specific about libraries gradle plugin 2025-11-02 22:59:32 +06:00
AntsyLich
0e0b6d9283 Handle reader cutout setting with Insets to support Android 15+ (#2640) 2025-11-02 16:28:25 +00:00
AntsyLich
38b1bd7383 Release v0.19.2 2025-11-02 19:46:31 +06:00
Weblate (bot)
8609553896 Translations update from Hosted Weblate (#2373)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/hi/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/jv/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ko/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ms/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pt/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/sk/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/el/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hi/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/id/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/jv/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ko/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ms/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ne/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/nl/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ro/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sk/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/th/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/vi/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/
Translation: Mihon/Mihon
Translation: Mihon/Mihon Plurals

Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: Efe Akın <efeakin1122@gmail.com>
Co-authored-by: Evan Jones (原文轩) <evanjones1883@gmail.com>
Co-authored-by: Farith <mail2@farithadnan.net>
Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Co-authored-by: Itsmechinmoy <167056923+itsmechinmoy@users.noreply.github.com>
Co-authored-by: Itsmechinmoy <itsmechinmoy@users.noreply.hosted.weblate.org>
Co-authored-by: Jakub Fabijan <jakubfabijan@tuta.io>
Co-authored-by: Lyfja <45209212+lyfja@users.noreply.github.com>
Co-authored-by: Madddog1997 <madddog1997@gmail.com>
Co-authored-by: Manjul Tamrakar <manjultamrakar4@gmail.com>
Co-authored-by: Manuela Silva <mmsrs@sky.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Mohamed kh <mohamedkhamekhami@gmail.com>
Co-authored-by: MuhamadSyabitHidayattulloh <tebepc@gmail.com>
Co-authored-by: Nataniel Dika Kurniawan <hikawaart2@gmail.com>
Co-authored-by: Omgeta <anooptiger@hotmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Saft Octavian <saftoctavian@gmail.com>
Co-authored-by: Siebrenvde <siebren@siebrenvde.dev>
Co-authored-by: Swyter <swyterzone@gmail.com>
Co-authored-by: TheKingTermux <50316075+TheKingTermux@users.noreply.github.com>
Co-authored-by: Throw Away <throwawayacc4gulshan@gmail.com>
Co-authored-by: ZerOriSama <godarms2010@live.com>
Co-authored-by: keegang 6705 <darunphobwi@gmail.com>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: ɴᴇᴋᴏ <s99095lkjjim@gmail.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
2025-11-02 19:46:06 +06:00
AntsyLich
5f0c460668 Make reader edge-to-edge (#1908) 2025-11-02 13:41:33 +00:00
Naputt1
ac28b6c80c Fix reader tap zones triggering after scrolling was stopped by the user (#2518)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-11-02 07:39:42 +00:00
Constantin Piber
cc28776735 Update Suwayomi tracker to use GraphQL API instead of REST API (#2585) 2025-11-02 06:26:48 +00:00
Trevor Paley
6ab87c7931 Added proper multi window support in WebView instead of treating everything as a redirect (#2584) 2025-11-02 06:23:01 +00:00
Kashish Aggarwal
8662f80fbf Fix date picker not allowing the same start and finish date in negative time zones (#2617)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-11-01 15:38:33 +00:00
AntsyLich
6508766ccd Fix CHANGELOG.md [skip ci] 2025-11-01 20:36:41 +06:00
anirudhn
09ec9fc8c5 Fix scrollbar not showing when animator duration scale animation is turned off (#2398) 2025-11-01 14:34:07 +00:00
c2y5
87c6f34a55 Fix extension download stuck at pending state in some cases (#2483)
Also auto update extension list whenever a repository is added or removed

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-11-01 14:09:22 +00:00
AntsyLich
643762f913 Add option to customize concurrent downloads, increase page concurrency (#2637) 2025-11-01 20:07:30 +06:00
Mend Renovate
7e880014b0 Update markdown to v0.38.1 (#2636) 2025-11-01 12:48:18 +00:00
AntsyLich
f36c259c1f Add subtitle support to slider preference and general cleanup (#2635) 2025-11-01 12:47:06 +00:00
AntsyLich
aef3beb15f Fix reader "Unable to edit key" error (#2634) 2025-11-01 09:03:51 +00:00
NGB-Was-Taken
e9469451ac Update shizuku.version to v13.1.5 (#2566)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-11-01 08:08:10 +00:00
AntsyLich
f9793d3323 Bump app version code and default user agent 2025-11-01 01:47:20 +06:00
AntsyLich
93ba6acea5 Fix migration "Attempt to invoke virtual method" crash (#2632) 2025-10-31 19:42:42 +00:00
AntsyLich
5e7fecc2c1 Fix migration dialog migrating to wrong entry (#2631) 2025-10-31 19:21:29 +00:00
Mend Renovate
343074da5f Update dependency org.junit.jupiter:junit-jupiter to v6.0.1 (#2630) 2025-11-01 01:06:55 +06:00
AntsyLich
7c08b75555 Fix mass migration advanced search query building (#2629) 2025-10-31 18:50:19 +00:00
Constantin Piber
cbf72f4c60 Migrate Kitsu to use library_id and remote_id properly (#2609) 2025-10-31 23:52:44 +06:00
Mend Renovate
0b6de39f2f Update okhttp monorepo to v5.3.0 (#2628) 2025-10-31 17:19:26 +00:00
Mend Renovate
72c4d1fdee Update plugin google-services to v4.4.4 (#2573) 2025-10-31 17:17:09 +00:00
Mend Renovate
fa96366b55 Update GitHub Actions (major) (#2627) 2025-10-31 17:10:37 +00:00
Mend Renovate
3ff25bc984 Update dependency androidx.work:work-runtime to v2.11.0 (#2626) 2025-10-31 17:07:58 +00:00
Mend Renovate
e9224bc2ba Update dependency com.squareup.okio:okio to v3.16.2 (#2576) 2025-10-31 17:07:38 +00:00
Mend Renovate
3c731c2cf5 Update GitHub Actions (#2581) 2025-10-31 17:06:58 +00:00
Mend Renovate
5ac58d01b8 Update dependency com.google.firebase:firebase-bom to v34.5.0 (#2575) 2025-10-31 23:06:40 +06:00
Mend Renovate
eefaf028ce Update xml.serialization.version to v0.91.3 (#2625) 2025-10-31 17:01:34 +00:00
Mend Renovate
582ccca1ab Update kotlin monorepo to v2.2.21 (#2624) 2025-10-31 16:59:21 +00:00
Mend Renovate
8f972115a8 Update dependency io.kotest:kotest-assertions-core to v6.0.4 (#2594) 2025-10-31 22:45:16 +06:00
Mend Renovate
6f6c033811 Update aboutlib.version to v13 (major) (#2580)
Update aboutlib.version to v13
2025-10-31 22:45:02 +06:00
Mend Renovate
57a0ab6711 Update okhttp monorepo to v5.2.1 (#2577) 2025-10-10 20:05:51 +06:00
Radon Rosborough
58b25d697f Improve handling of downloads for chapters with same metadata and optionally for OSes that don't support Unicode in filename (#2305)
Co-authored-by: jkim <jhskim@hotmail.com>
Co-authored-by: fatotak <111342761+fatotak@users.noreply.github.com>
Co-authored-by: MajorTanya <39014446+MajorTanya@users.noreply.github.com>
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-10-08 05:07:09 +06:00
Mend Renovate
1a31c7c7ee Update okhttp monorepo to v5.2.0 (#2564) 2025-10-07 18:51:34 +00:00
Mend Renovate
ad6b651b37 Update softprops/action-gh-release action to v2.4.0 (#2562) 2025-10-08 00:49:25 +06:00
NGB-Was-Taken
96e5131358 Fix disabling incognito mode from notification (#2512)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-10-05 16:03:45 +00:00
Mend Renovate
1d5bc8d2c2 Update dependency com.google.firebase:firebase-bom to v34.3.0 (#2508) 2025-10-05 14:47:15 +06:00
Mend Renovate
6cee911239 Update gradle/actions action to v5 (#2554) 2025-10-05 14:47:01 +06:00
Mend Renovate
96347e3f76 Update GitHub Actions (#2552) 2025-10-05 07:58:31 +00:00
Mend Renovate
9a45d248b1 Update dependency org.junit.jupiter:junit-jupiter to v6 (#2553) 2025-10-05 07:57:34 +00:00
Mend Renovate
04168ecec8 Update moko to v0.25.1 (#2550) 2025-10-05 07:41:46 +00:00
Mend Renovate
607f0ea9cd Update dependency io.mockk:mockk to v1.14.6 (#2549) 2025-10-05 07:40:48 +00:00
Secozzi
27a4f6f45c Update markdown to 0.37.0 (#2516) 2025-10-05 13:36:13 +06:00
Mend Renovate
5236d003d2 Update kotlin monorepo to v2.2.20 (#2498) 2025-10-05 13:29:27 +06:00
Mend Renovate
d61a41e819 Update dependency androidx.work:work-runtime to v2.10.5 (#2523) 2025-10-05 13:28:49 +06:00
Mend Renovate
5637860dd2 Update sqlite to v2.6.1 (#2525) 2025-10-05 13:28:37 +06:00
Mend Renovate
d4d18d0898 Update dependency com.diffplug.spotless:spotless-plugin-gradle to v8 (#2526) 2025-10-05 13:28:09 +06:00
Guzmazow
065147472e Improve spoofing of X-Requested-With header to support newer WebView versions (#2491) 2025-09-19 23:35:23 +06:00
Constantin Piber
6f635782c2 Delegate Suwayomi tracker authentication to extension (#2476)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-09-18 21:19:09 +00:00
Mend Renovate
86d85f74c0 Update lifecycle.version to v2.9.4 (#2503)
Update dependency androidx.lifecycle:lifecycle-process to v2.9.4
2025-09-18 15:48:14 +06:00
Mend Renovate
29e6a2c4a6 Update sqlite to v2.6.0 (#2504) 2025-09-18 15:47:48 +06:00
Mend Renovate
60c66bbd3a Update dependency androidx.core:core-ktx to v1.17.0 (#2402) 2025-09-17 21:29:03 +06:00
Mend Renovate
060e5b2e2e Update dependency androidx.activity:activity-compose to v1.11.0 (#2499) 2025-09-17 12:21:50 +00:00
AntsyLich
4ac9fcd4d3 Replace compose-stable-marker with compose-runtime-annotation 2025-09-17 18:08:25 +06:00
AntsyLich
d3b7f7e55f Bump compile and target sdk 2025-09-17 18:08:25 +06:00
Mend Renovate
0d926626a1 Update dependency com.google.firebase:firebase-bom to v34.2.0 (#2376) 2025-09-17 17:51:11 +06:00
Mend Renovate
6495a2ea43 Update dependency androidx.benchmark:benchmark-macro-junit4 to v1.4.1 (#2496) 2025-09-17 11:48:09 +00:00
Mend Renovate
94f711ba2a Update dependency androidx.work:work-runtime to v2.10.4 (#2497) 2025-09-17 11:47:49 +00:00
Mend Renovate
9f5c4e03b2 Update dependency androidx.compose:compose-bom to v2025.09.00 (#2401) 2025-09-17 17:39:08 +06:00
Mend Renovate
49562e1915 Update lifecycle.version to v2.9.3 (#2447) 2025-09-17 11:37:56 +00:00
Mend Renovate
57c82b30ba Update dependency org.jsoup:jsoup to v1.21.2 (#2438) 2025-09-17 17:30:26 +06:00
Mend Renovate
e573f72cfd Update dependency io.kotest:kotest-assertions-core to v6.0.3 (#2439) 2025-09-17 17:30:04 +06:00
Mend Renovate
95357a8625 Update GitHub Actions (#2443) 2025-09-17 17:29:14 +06:00
Mend Renovate
bd90307df9 Update dependency com.android.tools.build:gradle to v8.13.0 (#2449) 2025-09-17 17:26:02 +06:00
Secozzi
16b5317b90 Fix migration progress not updating and category flag mischeck (#2484)
- Fixed an issue where migration progress wasn't updated after a manual source search
- Fixed incorrect logic where the category migration flag was ignored due to checking the chapter flag instead
2025-09-17 17:12:21 +06:00
Mend Renovate
83f4b48629 Update plugin firebase-crashlytics to v3.0.6 (#2374) 2025-08-21 07:50:25 +00:00
Mend Renovate
4665dc50f6 Update actions/setup-java action to v5 (#2429) 2025-08-21 13:41:06 +06:00
Mend Renovate
85f5e5019e Update dependency com.github.skydoves:compose-stable-marker to v1.0.7 (#2428) 2025-08-21 13:40:56 +06:00
AntsyLich
4bc3b9f3b6 Bump targetSdk to 35 2025-08-21 12:51:32 +06:00
Mend Renovate
2c0d3678d9 Update actions/dependency-review-action action to v4.7.2 (#2418) 2025-08-21 12:49:14 +06:00
Mend Renovate
feda410152 Update dependency com.android.tools.build:gradle to v8.12.1 (#2417) 2025-08-21 12:48:59 +06:00
Mend Renovate
200c2df5ba Update dependency sh.calvin.reorderable:reorderable to v3 (#2419) 2025-08-21 12:48:43 +06:00
Mend Renovate
be09cddde2 Update dependency io.kotest:kotest-assertions-core to v6 (#2416) 2025-08-21 12:47:04 +06:00
AntsyLich
498317de52 Switch to a fork of QuickJS Java 2025-08-21 12:45:15 +06:00
Mend Renovate
33b876edc6 Update kotlin monorepo to v2.2.10 (#2404) 2025-08-15 08:56:05 +06:00
Mend Renovate
e7251f2034 Update actions/checkout action to v5 (#2395) 2025-08-15 08:55:39 +06:00
Secozzi
3d3c36078a Don't hardcode app name in strings.xml (#2394) 2025-08-11 22:01:07 +06:00
Secozzi
c6a96b3970 Fix height of description not being calculated correctly if images are present (#2382) 2025-08-10 00:21:25 +06:00
AntsyLich
fb3dc1c984 Fix readme CI badge [skip ci] 2025-08-07 20:48:44 +06:00
AntsyLich
029e36bfb4 Release v0.19.1 2025-08-07 19:59:01 +06:00
krysanify
d88dbe6409 Fix crash opening filter sheet with empty library and mark as read/unread for selected items (#2355) 2025-08-07 19:48:21 +06:00
AntsyLich
d0bad9f0bd Remove gradle toolchains plugin 2025-08-07 11:52:50 +06:00
AntsyLich
5c88f3860d Tweak build and release actions (#2367) 2025-08-07 10:32:29 +06:00
AntsyLich
7d717ee7fd Fix 'Default' category showing in library with no user-added categories (#2371) 2025-08-07 09:52:22 +06:00
AntsyLich
a93f71b82b Fix title text color in light mode on mass migration list (#2370) 2025-08-07 03:20:38 +00:00
AntsyLich
a8b6629b08 Move changelog unreleased removed section 2025-08-07 09:11:40 +06:00
AntsyLich
9bf3f15fff Fix local source EPUB files not loading (#2369) 2025-08-07 09:05:15 +06:00
AntsyLich
1c3e96bf7f Revert "Add full predictive back support (#2085)" (#2362)
This reverts commit c12bdbae8e.
2025-08-07 09:04:34 +06:00
Weblate (bot)
45c1a31488 Translations update from Hosted Weblate (#1879)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/as/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/bg/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/bn/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/de/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/el/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/eo/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/es/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/fil/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/hi/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/hr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/id/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ja/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ne/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pt/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ro/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ru/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/uk/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/vi/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cv/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/el/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/gl/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/he/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hi/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/id/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/it/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ml/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/my/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ne/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/nl/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ro/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sc/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ta/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/th/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/uk/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/vi/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/
Translation: Mihon/Mihon
Translation: Mihon/Mihon Plurals

Co-authored-by: Adolfo Jayme Barrientos <fitojb@ubuntu.com>
Co-authored-by: Ahmed TOUCHANE <ahmedtouchane0@gmail.com>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Akhil Raj <akhilakae07@gmail.com>
Co-authored-by: Alex Maryson Jr <akamar87@gmail.com>
Co-authored-by: Aviv Ben Ami <avivbenami@gmail.com>
Co-authored-by: B4LiN7 <87660017+B4LiN7@users.noreply.github.com>
Co-authored-by: C201 <derasetad@gmail.com>
Co-authored-by: Conrad Mateman <conradmateme001@gmail.com>
Co-authored-by: Crazyom <naxom@laposte.net>
Co-authored-by: Danilo Issida Goncalves <danissida@hotmail.com>
Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: Doministo <doministo@seznam.cz>
Co-authored-by: FateXBlood <fatexblood@gmail.com>
Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: Giorgio Sanna <sannagiorgio1997@gmail.com>
Co-authored-by: Guillaume Lecocq <guillaume.taylor@gmail.com>
Co-authored-by: Homura Akemi <amber_c001@protonmail.com>
Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Co-authored-by: Itsmechinmoy <itsmechinmoy@users.noreply.hosted.weblate.org>
Co-authored-by: Jakub Fabijan <jakubfabijan@tuta.io>
Co-authored-by: João Sousa <joaopsousa99@gmail.com>
Co-authored-by: Karley <siegitsi@gmail.com>
Co-authored-by: Lyfja <45209212+lyfja@users.noreply.github.com>
Co-authored-by: Mario Kevin D. A <programas013@gmail.com>
Co-authored-by: Mehedi Talha <meheditalha007@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Mohamed kh <mohamedkhamekhami@gmail.com>
Co-authored-by: Nguyễn Trung Đức <vaicato16@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Prem Kumar <prem12321kumar@gmail.com>
Co-authored-by: Ryo Richie <ryorichie@gmail.com>
Co-authored-by: Saft Octavian <saftoctavian@gmail.com>
Co-authored-by: Siebrenvde <siebren@siebrenvde.dev>
Co-authored-by: Sky children of the Light <tu25261@gmail.com>
Co-authored-by: Swyter <swyterzone@gmail.com>
Co-authored-by: Tahsin Gökalp <tahsinsaan@gmail.com>
Co-authored-by: TheKingTermux <50316075+TheKingTermux@users.noreply.github.com>
Co-authored-by: Yurt Page <yurtpage@gmail.com>
Co-authored-by: ZerOriSama <godarms2010@live.com>
Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Co-authored-by: akir45 <akkn0708@gmail.com>
Co-authored-by: altinat <al@altqx.com>
Co-authored-by: amigo browser <juniperforest1@proton.me>
Co-authored-by: edgole <test.backache009@aleeas.com>
Co-authored-by: f_pluz <pedroh.lobo20@gmail.com>
Co-authored-by: fl0k1 <michele.carnova@gmail.com>
Co-authored-by: gekka <1778962971@qq.com>
Co-authored-by: gulse02 <gnoeoxgulse@gmail.com>
Co-authored-by: kevans <albapazpi@gmail.com>
Co-authored-by: naikhon <naikhon5@gmail.com>
Co-authored-by: pancake <ppzh0@users.noreply.hosted.weblate.org>
Co-authored-by: scb261 <65343233+scb261@users.noreply.github.com>
Co-authored-by: scb261 <scb261261@gmail.com>
Co-authored-by: Đào Ngọc Đang Khoa <daongocdangkhoa2510@gmail.com>
Co-authored-by: ɴᴇᴋᴏ <s99095lkjjim@gmail.com>
Co-authored-by: Артур Давлетов <ar.davletov2013@gmail.com>
Co-authored-by: Георгій Обушенков <heorhii.obushenkov@gmail.com>
Co-authored-by: Димитър Георгиев <dimitar13226@gmail.com>
Co-authored-by: ابومسلم <linuxmint1978@gmail.com>
Co-authored-by: ابْنُ السَدِيمِ <amarlubs2@gmail.com>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
2025-08-07 06:10:32 +06:00
Radon Rosborough
32257e438e Use ComicInfo.xml for chapter metadata in localSource (#2332)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-08-07 05:01:54 +06:00
AntsyLich
49a84c8914 Fix CHANGELOG.md v0.19.0 hyperlink and update release body template 2025-08-06 07:43:08 +06:00
anirudhn
095ef8e74b Fixed scrollbar sometimes not showing during scroll or not reaching the bottom with few items (#2304) 2025-08-06 06:08:47 +06:00
Mend Renovate
4de3bf574a Update gradle/actions action to v4.4.2 (#2357) 2025-08-06 01:07:42 +06:00
MajorTanya
549d74a2c9 Add label to privately installed extensions (#2349)
Just adds the same word as the install option ("Private" in English)
next to the extension version and 18+ label.
2025-08-06 01:07:21 +06:00
188 changed files with 5332 additions and 2479 deletions

View File

@@ -7,7 +7,7 @@ indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.{xml,sq,sqm}]
[*.{xml,sq,sqm,aidl}]
indent_size = 4
# noinspection EditorConfigKeyCorrectness

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.19.0](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.19.2](https://github.com/mihonapp/mihon/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true

View File

@@ -52,7 +52,7 @@ body:
label: Mihon version
description: You can find your Mihon version in **More → About**.
placeholder: |
Example: "0.19.0"
Example: "0.19.2"
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.19.0](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.19.2](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

View File

@@ -1,4 +1,4 @@
name: PR build check
name: Build & Test
on:
pull_request:
paths:
@@ -8,9 +8,12 @@ on:
- '!i18n/src/commonMain/moko-resources/**/plurals.xml'
- 'i18n/src/commonMain/moko-resources/base/strings.xml'
- 'i18n/src/commonMain/moko-resources/base/plurals.xml'
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
@@ -18,42 +21,43 @@ permissions:
jobs:
build:
name: Build app
name: Build & Test App
runs-on: 'ubuntu-24.04'
steps:
- name: Clone repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Dependency Review
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
if: github.event_name == 'pull_request'
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: 17
distribution: temurin
- name: Set up gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Set up Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Check code format
run: ./gradlew spotlessCheck
- name: Build app
run: ./gradlew assembleRelease
run: ./gradlew assembleRelease -Pinclude-telemetry -Penable-updater
- name: Run unit tests
run: ./gradlew testReleaseUnitTest
- name: Upload APK
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/release/app-arm64-v8a-release-unsigned.apk
- name: Upload mapping
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: mapping-${{ github.sha }}
path: app/build/outputs/mapping/release

View File

@@ -1,102 +0,0 @@
name: CI
on:
push:
branches:
- main
tags:
- v*
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build app
runs-on: 'ubuntu-24.04'
steps:
- name: Clone repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: 17
distribution: temurin
- name: Set up gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Check code format
run: ./gradlew spotlessCheck
- name: Build app
run: ./gradlew assembleRelease -Pinclude-telemetry -Penable-updater
- name: Run unit tests
run: ./gradlew testReleaseUnitTest
- name: Upload APK
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/release/app-arm64-v8a-release-unsigned.apk
- name: Upload mapping
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: mapping-${{ github.sha }}
path: app/build/outputs/mapping/release
# Sign APK and create release for tags
- name: Get tag name
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'mihonapp/mihon'
run: |
set -x
echo "VERSION_TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
- name: Sign APK
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'mihonapp/mihon'
uses: r0adkll/sign-android-release@f30bdd30588842ac76044ecdbd4b6d0e3e813478
with:
releaseDirectory: app/build/outputs/apk/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: '35.0.1'
- name: Clean up build artifacts
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'mihonapp/mihon'
run: |
set -e
mv app/build/outputs/apk/release/app-universal-release-unsigned-signed.apk mihon-${{ env.VERSION_TAG }}.apk
mv app/build/outputs/apk/release/app-arm64-v8a-release-unsigned-signed.apk mihon-arm64-v8a-${{ env.VERSION_TAG }}.apk
mv app/build/outputs/apk/release/app-armeabi-v7a-release-unsigned-signed.apk mihon-armeabi-v7a-${{ env.VERSION_TAG }}.apk
mv app/build/outputs/apk/release/app-x86-release-unsigned-signed.apk mihon-x86-${{ env.VERSION_TAG }}.apk
mv app/build/outputs/apk/release/app-x86_64-release-unsigned-signed.apk mihon-x86_64-${{ env.VERSION_TAG }}.apk
- name: Create Release
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'mihonapp/mihon'
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
with:
tag_name: ${{ env.VERSION_TAG }}
name: Mihon ${{ env.VERSION_TAG }}
body: |
<!-->
> [!TIP]
>
> ### If you are unsure which version to download then go with `mihon-${{ env.VERSION_TAG }}.apk`
files: |
mihon-${{ env.VERSION_TAG }}.apk
mihon-arm64-v8a-${{ env.VERSION_TAG }}.apk
mihon-armeabi-v7a-${{ env.VERSION_TAG }}.apk
mihon-x86-${{ env.VERSION_TAG }}.apk
mihon-x86_64-${{ env.VERSION_TAG }}.apk
draft: true
prerelease: false
token: ${{ secrets.MIHON_BOT_TOKEN }}

171
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,171 @@
name: Release
on:
push:
tags:
- v*
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
get_tag:
if: github.repository == 'mihonapp/mihon'
name: Extract tag name
runs-on: 'ubuntu-24.04'
outputs:
tag: ${{ steps.extract.outputs.tag }}
steps:
- name: Get tag name
id: extract
run: echo "tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
build:
if: github.repository == 'mihonapp/mihon'
name: Build
runs-on: 'ubuntu-24.04'
needs: get_tag
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: 17
distribution: temurin
- name: Set up Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Build
run: ./gradlew assembleRelease -Pinclude-telemetry -Penable-updater
- name: Sign APK
uses: r0adkll/sign-android-release@f30bdd30588842ac76044ecdbd4b6d0e3e813478
with:
releaseDirectory: app/build/outputs/apk/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: '35.0.1'
- name: Rename APK
run: |
set -e
mv app/build/outputs/apk/release/app-universal-release-unsigned-signed.apk mihon-${{ needs.get_tag.outputs.tag }}.apk
mv app/build/outputs/apk/release/app-arm64-v8a-release-unsigned-signed.apk mihon-arm64-v8a-${{ needs.get_tag.outputs.tag }}.apk
mv app/build/outputs/apk/release/app-armeabi-v7a-release-unsigned-signed.apk mihon-armeabi-v7a-${{ needs.get_tag.outputs.tag }}.apk
mv app/build/outputs/apk/release/app-x86-release-unsigned-signed.apk mihon-x86-${{ needs.get_tag.outputs.tag }}.apk
mv app/build/outputs/apk/release/app-x86_64-release-unsigned-signed.apk mihon-x86_64-${{ needs.get_tag.outputs.tag }}.apk
- name: Upload APK
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: mihon
path: |
mihon-${{ needs.get_tag.outputs.tag }}.apk
mihon-arm64-v8a-${{ needs.get_tag.outputs.tag }}.apk
mihon-armeabi-v7a-${{ needs.get_tag.outputs.tag }}.apk
mihon-x86-${{ needs.get_tag.outputs.tag }}.apk
mihon-x86_64-${{ needs.get_tag.outputs.tag }}.apk
build_foss:
if: github.repository == 'mihonapp/mihon'
name: Build (FOSS)
runs-on: ubuntu-24.04
needs: get_tag
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: 17
distribution: temurin
- name: Set up Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
cache-disabled: true
- name: Build
run: ./gradlew assembleFoss -Penable-updater
- name: Sign APK
uses: r0adkll/sign-android-release@f30bdd30588842ac76044ecdbd4b6d0e3e813478
with:
releaseDirectory: app/build/outputs/apk/foss
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: '35.0.1'
- name: Rename APK
run: |
set -e
mv app/build/outputs/apk/foss/app-universal-foss-unsigned-signed.apk mihon-${{ needs.get_tag.outputs.tag }}-foss.apk
- name: Upload APK
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: mihon-foss
path: mihon-${{ needs.get_tag.outputs.tag }}-foss.apk
release:
if: github.repository == 'mihonapp/mihon'
name: Create GitHub Release
runs-on: ubuntu-24.04
needs: [get_tag, build, build_foss]
steps:
- name: Download all artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
merge-multiple: true
- name: Delete all artifacts
uses: geekyeggo/delete-artifact@f275313e70c08f6120db482d7a6b98377786765b # v5.1.0
with:
failOnError: false
name: |
mihon
mihon-foss
- name: Create GitHub Release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
with:
tag_name: ${{ needs.get_tag.outputs.tag }}
name: Mihon ${{ needs.get_tag.outputs.tag }}
body: |
Check out the [past release notes](https://github.com/mihonapp/mihon/releases) if youre upgrading from an earlier version. Consider [donating via Open Collective](https://opencollective.com/mihon/contribute) to help keep Mihon improving!
<!-->
<!-->
> [!TIP]
>
> ### If you are unsure which version to download then go with `mihon-${{ needs.get_tag.outputs.tag }}.apk`
files: |
mihon-${{ needs.get_tag.outputs.tag }}.apk
mihon-${{ needs.get_tag.outputs.tag }}-foss.apk
mihon-arm64-v8a-${{ needs.get_tag.outputs.tag }}.apk
mihon-armeabi-v7a-${{ needs.get_tag.outputs.tag }}.apk
mihon-x86-${{ needs.get_tag.outputs.tag }}.apk
mihon-x86_64-${{ needs.get_tag.outputs.tag }}.apk
draft: true
prerelease: false
token: ${{ secrets.MIHON_BOT_TOKEN }}

View File

@@ -12,6 +12,57 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased]
## [v0.19.2] - 2025-11-02
### Added
- Advanced setting to limit download filenames to ASCII characters. This is provided only as a workaround for OSes that do not properly handle standard Unicode filenames. This setting is generally not recommended and should only be used as a last resort ([@raxod502](https://github.com/radian-software)) ([#2305](https://github.com/mihonapp/mihon/pull/2305))
- Option to customize the number of concurrent source and page downloads ([@AntsyLich](https://github.com/AntsyLich)) ([#2637](https://github.com/mihonapp/mihon/pull/2637))
### Changed
- Increased default concurrent page downloads to 5 ([@AntsyLich](https://github.com/AntsyLich)) ([#2637](https://github.com/mihonapp/mihon/pull/2637))
### Improved
- Spoofing of `X-Requested-With` header to support newer WebView versions ([@Guzmazow](https://github.com/Guzmazow)) ([#2491](https://github.com/mihonapp/mihon/pull/2491))
- Download support for chapters with the same metadata. Now a hash based on chapter's url is appended to download filename to tell them apart, letting you download both. Existing downloaded chapters will continue to work normally ([@raxod502](https://github.com/radian-software)) ([#2305](https://github.com/mihonapp/mihon/pull/2305))
- Auto refresh extension list whenever a repository is added or removed ([@c2y5](https://github.com/c2y5)) ([#2483](https://github.com/mihonapp/mihon/pull/2483))
- Added proper multi window support in WebView instead of treating everything as a redirect ([@TheUnlocked](https://github.com/TheUnlocked)) ([#2584](https://github.com/mihonapp/mihon/pull/2584))
### Fixed
- Fix height of description not being calculated correctly if images are present ([@Secozzi](https://github.com/Secozzi)) ([#2382](https://github.com/mihonapp/mihon/pull/2382))
- Fix migration progress not updating after manual search ([@Secozzi](https://github.com/Secozzi)) ([#2484](https://github.com/mihonapp/mihon/pull/2484))
- Fix category migration flag being ignored due to incorrect check against chapter flag ([@Secozzi](https://github.com/Secozzi)) ([#2484](https://github.com/mihonapp/mihon/pull/2484))
- Fix disabling incognito mode from notification ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#2512](https://github.com/mihonapp/mihon/pull/2512))
- Fix mass migration advanced search query building ([@AntsyLich](https://github.com/AntsyLich)) ([#2629](https://github.com/mihonapp/mihon/pull/2629))
- Fix migration dialog migrating to wrong entry ([@AntsyLich](https://github.com/AntsyLich)) ([#2631](https://github.com/mihonapp/mihon/pull/2631))
- Fix migration "Attempt to invoke virtual method" crash ([@AntsyLich](https://github.com/AntsyLich)) ([#2632](https://github.com/mihonapp/mihon/pull/2632))
- Fix reader "Unable to edit key" error ([@AntsyLich](https://github.com/AntsyLich)) ([#2634](https://github.com/mihonapp/mihon/pull/2634))
- Fix extension download stuck in pending state in some cases ([@c2y5](https://github.com/c2y5)) ([#2483](https://github.com/mihonapp/mihon/pull/2483))
- Fix scrollbar not showing when animator duration scale animation is turned off ([@anirudhsnayak](https://github.com/anirudhsnayak)) ([#2398](https://github.com/mihonapp/mihon/pull/2398))
- Fix date picker not allowing the same start and finish date in negative time zones ([@AntsyLich](https://github.com/AntsyLich), [@kashish-aggarwal21](https://github.com/kashish-aggarwal21)) ([#2617](https://github.com/mihonapp/mihon/pull/2617))
- Fix reader tap zones triggering after scrolling was stopped by the user ([@Naputt1](https://github.com/Naputt1), [@AntsyLich](https://github.com/AntsyLich)) ([#2518](https://github.com/mihonapp/mihon/pull/2518))
- Fix reader page indicator being partially visible on some devices ([@AntsyLich](https://github.com/AntsyLich)) ([#1908](https://github.com/mihonapp/mihon/pull/1908))
- Fix inconsistent system bar and reader app bar background ([@AntsyLich](https://github.com/AntsyLich)) ([#1908](https://github.com/mihonapp/mihon/pull/1908))
- Fix transparent system bar background in reader on Android 15+ ([@AntsyLich](https://github.com/AntsyLich)) ([#1908](https://github.com/mihonapp/mihon/pull/1908))
### Other
- Delegate Suwayomi tracker authentication to extension ([@cpiber](https://github.com/cpiber)) ([#2476](https://github.com/mihonapp/mihon/pull/2476))
- Fix Kitsu tracker to conform to tracker data structure properly ([@cpiber](https://github.com/cpiber)) ([#2609](https://github.com/mihonapp/mihon/pull/2609))
- Update Suwayomi tracker to use GraphQL API instead of REST API ([@cpiber](https://github.com/cpiber)) ([#2585](https://github.com/mihonapp/mihon/pull/2585))
## [v0.19.1] - 2025-08-07
### Changed
- LocalSource now reads ComicInfo.xml file for chapter (if available) to display chapter title, number and scanlator ([@raxod502](https://github.com/radian-software)) ([#2332](https://github.com/mihonapp/mihon/pull/2332))
### Removed
- Predictive back support ([@AntsyLich](https://github.com/AntsyLich)) ([#2362](https://github.com/mihonapp/mihon/pull/2362))
### Fixed
- Fix scrollbar sometimes not showing during scroll or not reaching the bottom with few items ([@anirudhsnayak](https://github.com/anirudhsnayak)) ([#2304](https://github.com/mihonapp/mihon/pull/2304))
- Fix local source EPUB files not loading ([@AntsyLich](https://github.com/AntsyLich)) ([#2369](https://github.com/mihonapp/mihon/pull/2369))
- Fix title text color in light mode on mass migration list ([@AntsyLich](https://github.com/AntsyLich)) ([#2370](https://github.com/mihonapp/mihon/pull/2370))
- Fix 'Default' category showing in library with no user-added categories ([@AntsyLich](https://github.com/AntsyLich)) ([#2371](https://github.com/mihonapp/mihon/pull/2371))
- Fix crash when opening filter sheet with an empty library ([@krysanify](https://github.com/krysanify/)) ([#2355](https://github.com/mihonapp/mihon/pull/2355))
- Fix mark as read/unread not working for selected library items ([@krysanify](https://github.com/krysanify/)) ([#2355](https://github.com/mihonapp/mihon/pull/2355))
## [v0.19.0] - 2025-08-04
### Added
- Add more Kaomoji for empty/error screens ([@ianfhunter](https://github.com/ianfhunter/)) ([#1909](https://github.com/mihonapp/mihon/pull/1909))
@@ -50,7 +101,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Make local source default chapter sorting match file explorer behavior ([@AntsyLich](https://github.com/AntsyLich)) ([#2224](https://github.com/mihonapp/mihon/pull/224))
- Include Manga `initialized` status in backup ([@AwkwardPeak7](https://github.com/AwkwardPeak7)) ([#2285](https://github.com/mihonapp/mihon/pull/2285))
### Fixes
### Fixed
- Fix Bangumi search results including novels ([@MajorTanya](https://github.com/MajorTanya)) ([#1885](https://github.com/mihonapp/mihon/pull/1885))
- Fix next chapter button occasionally jumping to the last page of the current chapter ([@perokhe](https://github.com/perokhe)) ([#1920](https://github.com/mihonapp/mihon/pull/1920))
- Fix page number not appearing when opening chapter ([@perokhe](https://github.com/perokhe)) ([#1936](https://github.com/mihonapp/mihon/pull/1936))
@@ -382,8 +433,10 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- 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.19.0...main
[v0.18.0]: https://github.com/mihonapp/mihon/compare/v0.18.0...v0.19.0
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.19.2...main
[v0.19.2]: https://github.com/mihonapp/mihon/compare/v0.19.1...v0.19.2
[v0.19.1]: https://github.com/mihonapp/mihon/compare/v0.19.0...v0.19.1
[v0.19.0]: https://github.com/mihonapp/mihon/compare/v0.18.0...v0.19.0
[v0.18.0]: https://github.com/mihonapp/mihon/compare/v0.17.1...v0.18.0
[v0.17.1]: https://github.com/mihonapp/mihon/compare/v0.17.0...v0.17.1
[v0.17.0]: https://github.com/mihonapp/mihon/compare/v0.16.5...v0.17.0

View File

@@ -12,7 +12,7 @@ Discover and read manga, webtoons, comics, and more easier than ever on your
[![Discord server](https://img.shields.io/discord/1195734228319617024.svg?label=&labelColor=6A7EC2&color=7389D8&logo=discord&logoColor=FFFFFF)](https://discord.gg/mihon)
[![GitHub downloads](https://img.shields.io/github/downloads/mihonapp/mihon/total?label=downloads&labelColor=27303D&color=0D1117&logo=github&logoColor=FFFFFF&style=flat)](https://mihon.app/download)
[![CI](https://img.shields.io/github/actions/workflow/status/mihonapp/mihon/build_push.yml?labelColor=27303D)](https://github.com/mihonapp/mihon/actions/workflows/build_push.yml)
[![CI](https://img.shields.io/github/actions/workflow/status/mihonapp/mihon/build.yml?labelColor=27303D)](https://github.com/mihonapp/mihon/actions/workflows/build_push.yml)
[![License: Apache-2.0](https://img.shields.io/github/license/mihonapp/mihon?labelColor=27303D&color=0877d2)](/LICENSE)
[![Translation status](https://img.shields.io/weblate/progress/mihon?labelColor=27303D&color=946300)](https://hosted.weblate.org/engage/mihon/)

View File

@@ -26,8 +26,8 @@ android {
defaultConfig {
applicationId = "app.mihon"
versionCode = 12
versionName = "0.19.0"
versionCode = 15
versionName = "0.19.2"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
@@ -138,9 +138,9 @@ android {
buildFeatures {
viewBinding = true
buildConfig = true
aidl = true
// Disable some unused things
aidl = false
renderScript = false
shaders = false
}
@@ -261,7 +261,6 @@ dependencies {
implementation(libs.directionalviewpager) {
exclude(group = "androidx.viewpager", module = "viewpager")
}
implementation(libs.insetter)
implementation(libs.richeditor.compose)
implementation(libs.aboutLibraries.compose)
implementation(libs.bundles.voyager)

View File

@@ -0,0 +1,7 @@
package mihon.app.shizuku;
interface IShellInterface {
void install(in AssetFileDescriptor apk) = 1;
void destroy() = 16777114;
}

View File

@@ -114,6 +114,7 @@ class SyncChaptersWithSource(
downloadManager.isChapterDownloaded(
dbChapter.name,
dbChapter.scanlator,
dbChapter.url,
manga.title,
manga.source,
)
@@ -121,12 +122,14 @@ class SyncChaptersWithSource(
if (shouldRenameChapter) {
downloadManager.renameChapter(source, manga, dbChapter, chapter)
}
var toChangeChapter = dbChapter.copy(
name = chapter.name,
chapterNumber = chapter.chapterNumber,
scanlator = chapter.scanlator,
sourceOrder = chapter.sourceOrder,
)
if (chapter.dateUpload != 0L) {
toChangeChapter = toChangeChapter.copy(dateUpload = chapter.dateUpload)
}

View File

@@ -26,6 +26,7 @@ fun List<Chapter>.applyFilters(manga: Manga, downloadManager: DownloadManager):
val downloaded = downloadManager.isChapterDownloaded(
chapter.name,
chapter.scanlator,
chapter.url,
manga.title,
manga.source,
)

View File

@@ -353,13 +353,17 @@ private fun ExtensionItemContent(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
) {
ProvideTextStyle(value = MaterialTheme.typography.bodySmall) {
var hasAlreadyShownAnElement by remember { mutableStateOf(false) }
if (extension is Extension.Installed && extension.lang.isNotEmpty()) {
hasAlreadyShownAnElement = true
Text(
text = LocaleHelper.getSourceDisplayName(extension.lang, LocalContext.current),
)
}
if (extension.versionName.isNotEmpty()) {
if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText()
hasAlreadyShownAnElement = true
Text(
text = extension.versionName,
)
@@ -372,6 +376,8 @@ private fun ExtensionItemContent(
else -> null
}
if (warning != null) {
if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText()
hasAlreadyShownAnElement = true
Text(
text = stringResource(warning).uppercase(),
color = MaterialTheme.colorScheme.error,
@@ -379,6 +385,12 @@ private fun ExtensionItemContent(
overflow = TextOverflow.Ellipsis,
)
}
if (extension is Extension.Installed && !extension.isShared) {
if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText()
Text(
text = stringResource(MR.strings.ext_installer_private),
)
}
if (!installStep.isCompleted()) {
DotSeparatorNoSpaceText()

View File

@@ -1,9 +1,9 @@
package eu.kanade.presentation.components
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Dialog
@@ -32,9 +32,10 @@ fun NavigatorAdaptiveSheet(
) {
ScreenTransition(
navigator = sheetNavigator,
enterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) },
exitTransition = { fadeOut(animationSpec = tween(90)) },
sizeTransform = { SizeTransform() },
transition = {
fadeIn(animationSpec = tween(220, delayMillis = 90)) togetherWith
fadeOut(animationSpec = tween(90))
},
)
}

View File

@@ -255,7 +255,7 @@ private fun ColumnScope.DisplayPage(
value = columns,
valueRange = 0..10,
label = stringResource(MR.strings.pref_library_columns),
valueText = if (columns > 0) {
valueString = if (columns > 0) {
columns.toString()
} else {
stringResource(MR.strings.label_auto)

View File

@@ -58,7 +58,7 @@ fun LibraryContent(
val scope = rememberCoroutineScope()
var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) }
if (showPageTabs && categories.isNotEmpty()) {
if (showPageTabs && categories.isNotEmpty() && (categories.size > 1 || !categories.first().isSystemCategory)) {
LaunchedEffect(categories) {
if (categories.size <= pagerState.currentPage) {
pagerState.scrollToPage(categories.size - 1)

View File

@@ -2,9 +2,6 @@ package eu.kanade.presentation.manga.components
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
@@ -27,18 +24,15 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -55,14 +49,11 @@ import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.manga.EditCoverAction
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import kotlinx.collections.immutable.persistentListOf
import soup.compose.material.motion.MotionConstants
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.PredictiveBack
import tachiyomi.presentation.core.util.clickableNoIndication
import kotlin.coroutines.cancellation.CancellationException
@Composable
fun MangaCoverDialog(
@@ -161,32 +152,10 @@ fun MangaCoverDialog(
val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() }
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
var scale by remember { mutableFloatStateOf(1f) }
PredictiveBackHandler { progress ->
try {
progress.collect { backEvent ->
scale = lerp(1f, 0.8f, PredictiveBack.transform(backEvent.progress))
}
onDismissRequest()
} catch (e: CancellationException) {
animate(
initialValue = scale,
targetValue = 1f,
animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration),
) { value, _ ->
scale = value
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.clickableNoIndication(onClick = onDismissRequest)
.graphicsLayer {
scaleX = scale
scaleY = scale
},
.clickableNoIndication(onClick = onDismissRequest),
) {
AndroidView(
factory = {

View File

@@ -53,6 +53,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@@ -68,6 +69,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
@@ -618,6 +620,7 @@ private fun MangaSummary(
targetValue = if (expanded) 1f else 0f,
label = "summary",
)
var infoHeight by remember { mutableIntStateOf(0) }
Layout(
modifier = modifier.clipToBounds(),
contents = listOf(
@@ -630,25 +633,11 @@ private fun MangaSummary(
)
},
{
Column {
MangaNotesSection(
content = notes,
expanded = true,
onEditNotes = onEditNotesClicked,
)
MarkdownRender(
content = description,
modifier = Modifier.secondaryItemAlpha(),
annotator = descriptionAnnotator(
loadImages = loadImages,
linkStyle = getMarkdownLinkStyle().toSpanStyle(),
),
loadImages = loadImages,
)
}
},
{
Column {
Column(
modifier = Modifier.onSizeChanged { size ->
infoHeight = size.height
},
) {
MangaNotesSection(
content = notes,
expanded = expanded,
@@ -685,14 +674,11 @@ private fun MangaSummary(
}
},
),
) { (shrunk, expanded, actual, scrim), constraints ->
) { (shrunk, actual, scrim), constraints ->
val shrunkHeight = shrunk.single()
.measure(constraints)
.height
val expandedHeight = expanded.single()
.measure(constraints)
.height
val heightDelta = expandedHeight - shrunkHeight
val heightDelta = infoHeight - shrunkHeight
val scrimHeight = 24.dp.roundToPx()
val actualPlaceable = actual.single()

View File

@@ -102,13 +102,9 @@ private fun getMarkdownColors(): MarkdownColors {
val codeBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
return DefaultMarkdownColors(
text = MaterialTheme.colorScheme.onSurface,
codeText = Color.Unspecified,
inlineCodeText = Color.Unspecified,
linkText = Color.Unspecified,
codeBackground = codeBackground,
inlineCodeBackground = codeBackground,
dividerColor = MaterialTheme.colorScheme.outlineVariant,
tableText = Color.Unspecified,
tableBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f),
)
}
@@ -139,7 +135,6 @@ private fun getMarkdownTypography(): MarkdownTypography {
ordered = MaterialTheme.typography.bodyMedium,
bullet = MaterialTheme.typography.bodyMedium,
list = MaterialTheme.typography.bodyMedium,
link = link,
textLink = TextLinkStyles(style = link.toSpanStyle()),
table = MaterialTheme.typography.bodyMedium,
)

View File

@@ -15,10 +15,10 @@ sealed class Preference {
abstract val title: String
abstract val enabled: Boolean
sealed class PreferenceItem<T> : Preference() {
sealed class PreferenceItem<T, R> : Preference() {
abstract val subtitle: String?
abstract val icon: ImageVector?
abstract val onValueChanged: suspend (value: T) -> Boolean
abstract val onValueChanged: suspend (value: T) -> R
/**
* A basic [PreferenceItem] that only displays texts.
@@ -28,9 +28,9 @@ sealed class Preference {
override val subtitle: String? = null,
override val enabled: Boolean = true,
val onClick: (() -> Unit)? = null,
) : PreferenceItem<String>() {
) : PreferenceItem<String, Unit>() {
override val icon: ImageVector? = null
override val onValueChanged: suspend (value: String) -> Boolean = { true }
override val onValueChanged: suspend (value: String) -> Unit = {}
}
/**
@@ -42,7 +42,7 @@ sealed class Preference {
override val subtitle: String? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (value: Boolean) -> Boolean = { true },
) : PreferenceItem<Boolean>() {
) : PreferenceItem<Boolean, Boolean>() {
override val icon: ImageVector? = null
}
@@ -52,12 +52,13 @@ sealed class Preference {
data class SliderPreference(
val value: Int,
override val title: String,
override val subtitle: String? = null,
val valueString: String? = null,
val valueRange: IntProgression = 0..1,
@IntRange(from = 0) val steps: Int = with(valueRange) { (last - first) - 1 },
override val subtitle: String? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (value: Int) -> Boolean = { true },
) : PreferenceItem<Int>() {
override val onValueChanged: suspend (value: Int) -> Unit = {},
) : PreferenceItem<Int, Unit>() {
override val icon: ImageVector? = null
}
@@ -75,7 +76,7 @@ sealed class Preference {
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (value: T) -> Boolean = { true },
) : PreferenceItem<T>() {
) : PreferenceItem<T, Boolean>() {
internal fun internalSet(value: Any) = preference.set(value as T)
internal suspend fun internalOnValueChanged(value: Any) = onValueChanged(value as T)
@@ -96,8 +97,8 @@ sealed class Preference {
{ v, e -> subtitle?.format(e[v]) },
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (value: String) -> Boolean = { true },
) : PreferenceItem<String>()
override val onValueChanged: suspend (value: String) -> Unit = {},
) : PreferenceItem<String, Unit>()
/**
* A [PreferenceItem] that displays a list of entries as a dialog.
@@ -121,7 +122,7 @@ sealed class Preference {
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (value: Set<String>) -> Boolean = { true },
) : PreferenceItem<Set<String>>()
) : PreferenceItem<Set<String>, Boolean>()
/**
* A [PreferenceItem] that shows a EditText in the dialog.
@@ -132,7 +133,7 @@ sealed class Preference {
override val subtitle: String? = "%s",
override val enabled: Boolean = true,
override val onValueChanged: suspend (value: String) -> Boolean = { true },
) : PreferenceItem<String>() {
) : PreferenceItem<String, Boolean>() {
override val icon: ImageVector? = null
}
@@ -143,31 +144,31 @@ sealed class Preference {
val tracker: Tracker,
val login: () -> Unit,
val logout: () -> Unit,
) : PreferenceItem<String>() {
) : PreferenceItem<String, Unit>() {
override val title: String = ""
override val enabled: Boolean = true
override val subtitle: String? = null
override val icon: ImageVector? = null
override val onValueChanged: suspend (value: String) -> Boolean = { true }
override val onValueChanged: suspend (value: String) -> Unit = {}
}
data class InfoPreference(
override val title: String,
) : PreferenceItem<String>() {
) : PreferenceItem<String, Unit>() {
override val enabled: Boolean = true
override val subtitle: String? = null
override val icon: ImageVector? = null
override val onValueChanged: suspend (value: String) -> Boolean = { true }
override val onValueChanged: suspend (value: String) -> Unit = {}
}
data class CustomPreference(
override val title: String,
val content: @Composable () -> Unit,
) : PreferenceItem<Unit>() {
) : PreferenceItem<Unit, Unit>() {
override val enabled: Boolean = true
override val subtitle: String? = null
override val icon: ImageVector? = null
override val onValueChanged: suspend (value: Unit) -> Boolean = { true }
override val onValueChanged: suspend (value: Unit) -> Unit = {}
}
}
@@ -175,6 +176,6 @@ sealed class Preference {
override val title: String,
override val enabled: Boolean = true,
val preferenceItems: ImmutableList<PreferenceItem<out Any>>,
val preferenceItems: ImmutableList<PreferenceItem<out Any, out Any>>,
) : Preference()
}

View File

@@ -35,7 +35,7 @@ val LocalPreferenceMinHeight = compositionLocalOf(structuralEqualityPolicy()) {
@Composable
fun StatusWrapper(
item: Preference.PreferenceItem<*>,
item: Preference.PreferenceItem<*, *>,
highlightKey: String?,
content: @Composable () -> Unit,
) {
@@ -56,7 +56,7 @@ fun StatusWrapper(
@Composable
internal fun PreferenceItem(
item: Preference.PreferenceItem<*>,
item: Preference.PreferenceItem<*, *>,
highlightKey: String?,
) {
val scope = rememberCoroutineScope()
@@ -83,17 +83,18 @@ internal fun PreferenceItem(
}
is Preference.PreferenceItem.SliderPreference -> {
BaseSliderItem(
label = item.title,
value = item.value,
valueRange = item.valueRange,
valueText = item.subtitle.takeUnless { it.isNullOrEmpty() } ?: item.value.toString(),
steps = item.steps,
labelStyle = MaterialTheme.typography.titleLarge.copy(fontSize = TitleFontSize),
title = item.title,
subtitle = item.subtitle,
valueString = item.valueString.takeUnless { it.isNullOrEmpty() } ?: item.value.toString(),
onChange = {
scope.launch {
item.onValueChanged(it)
}
},
titleStyle = MaterialTheme.typography.titleLarge.copy(fontSize = TitleFontSize),
modifier = Modifier.padding(
horizontal = PrefsHorizontalPadding,
vertical = PrefsVerticalPadding,

View File

@@ -71,7 +71,7 @@ fun PreferenceScreen(
}
// Create Preference Item
is Preference.PreferenceItem<*> -> item {
is Preference.PreferenceItem<*, *> -> item {
PreferenceItem(
item = preference,
highlightKey = highlightKey,

View File

@@ -323,6 +323,11 @@ object SettingsAdvancedScreen : SearchableSettings {
title = stringResource(MR.strings.pref_update_library_manga_titles),
subtitle = stringResource(MR.strings.pref_update_library_manga_titles_summary),
),
Preference.PreferenceItem.SwitchPreference(
preference = libraryPreferences.disallowNonAsciiFilenames(),
title = stringResource(MR.strings.pref_disallow_non_ascii_filenames),
subtitle = stringResource(MR.strings.pref_disallow_non_ascii_filenames_details),
),
),
)
}

View File

@@ -37,6 +37,8 @@ object SettingsDownloadScreen : SearchableSettings {
val allCategories by getCategories.subscribe().collectAsState(initial = emptyList())
val downloadPreferences = remember { Injekt.get<DownloadPreferences>() }
val parallelSourceLimit by downloadPreferences.parallelSourceLimit().collectAsState()
val parallelPageLimit by downloadPreferences.parallelPageLimit().collectAsState()
return listOf(
Preference.PreferenceItem.SwitchPreference(
preference = downloadPreferences.downloadOnlyOverWifi(),
@@ -51,6 +53,19 @@ object SettingsDownloadScreen : SearchableSettings {
title = stringResource(MR.strings.split_tall_images),
subtitle = stringResource(MR.strings.split_tall_images_summary),
),
Preference.PreferenceItem.SliderPreference(
value = parallelSourceLimit,
valueRange = 1..10,
title = stringResource(MR.strings.pref_download_concurrent_sources),
onValueChanged = { downloadPreferences.parallelSourceLimit().set(it) },
),
Preference.PreferenceItem.SliderPreference(
value = parallelPageLimit,
valueRange = 1..15,
title = stringResource(MR.strings.pref_download_concurrent_pages),
subtitle = stringResource(MR.strings.pref_download_concurrent_pages_summary),
onValueChanged = { downloadPreferences.parallelPageLimit().set(it) },
),
getDeleteChaptersGroup(
downloadPreferences = downloadPreferences,
categories = allCategories,

View File

@@ -1,6 +1,5 @@
package eu.kanade.presentation.more.settings.screen
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
@@ -10,6 +9,7 @@ import eu.kanade.presentation.more.settings.Preference
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.util.system.hasDisplayCutout
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
@@ -101,11 +101,9 @@ object SettingsReaderScreen : SearchableSettings {
title = stringResource(MR.strings.pref_fullscreen),
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.cutoutShort(),
preference = readerPreferences.drawUnderCutout(),
title = stringResource(MR.strings.pref_cutout_short),
enabled = fullscreen &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
LocalView.current.rootWindowInsets?.displayCutout != null, // has cutout
enabled = LocalView.current.hasDisplayCutout() && fullscreen,
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.keepScreenOn(),
@@ -143,23 +141,17 @@ object SettingsReaderScreen : SearchableSettings {
value = flashMillis / ReaderPreferences.MILLI_CONVERSION,
valueRange = 1..15,
title = stringResource(MR.strings.pref_flash_duration),
subtitle = stringResource(MR.strings.pref_flash_duration_summary, flashMillis),
valueString = stringResource(MR.strings.pref_flash_duration_summary, flashMillis),
enabled = flashPageState,
onValueChanged = {
flashMillisPref.set(it * ReaderPreferences.MILLI_CONVERSION)
true
},
onValueChanged = { flashMillisPref.set(it * ReaderPreferences.MILLI_CONVERSION) },
),
Preference.PreferenceItem.SliderPreference(
value = flashInterval,
valueRange = 1..10,
title = stringResource(MR.strings.pref_flash_page_interval),
subtitle = pluralStringResource(MR.plurals.pref_pages, flashInterval, flashInterval),
valueString = pluralStringResource(MR.plurals.pref_pages, flashInterval, flashInterval),
enabled = flashPageState,
onValueChanged = {
flashIntervalPref.set(it)
true
},
onValueChanged = { flashIntervalPref.set(it) },
),
Preference.PreferenceItem.ListPreference(
preference = flashColorPref,
@@ -342,11 +334,8 @@ object SettingsReaderScreen : SearchableSettings {
it.WEBTOON_PADDING_MIN..it.WEBTOON_PADDING_MAX
},
title = stringResource(MR.strings.pref_webtoon_side_padding),
subtitle = numberFormat.format(webtoonSidePadding / 100f),
onValueChanged = {
webtoonSidePaddingPref.set(it)
true
},
valueString = numberFormat.format(webtoonSidePadding / 100f),
onValueChanged = { webtoonSidePaddingPref.set(it) },
),
Preference.PreferenceItem.ListPreference(
preference = readerPreferences.readerHideThreshold(),

View File

@@ -183,7 +183,7 @@ private fun SearchResult(
emptySequence()
}
}
is Preference.PreferenceItem<*> -> sequenceOf(null to p)
is Preference.PreferenceItem<*, *> -> sequenceOf(null to p)
}
}
// Don't show info preference

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.extension.ExtensionManager
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.channels.Channel
@@ -27,6 +28,7 @@ class ExtensionReposScreenModel(
private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(),
private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(),
private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
@@ -53,6 +55,7 @@ class ExtensionReposScreenModel(
fun createRepo(baseUrl: String) {
screenModelScope.launchIO {
when (val result = createExtensionRepo.await(baseUrl)) {
CreateExtensionRepo.Result.Success -> extensionManager.findAvailableExtensions()
CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
CreateExtensionRepo.Result.RepoAlreadyExists -> _events.send(RepoEvent.RepoAlreadyExists)
is CreateExtensionRepo.Result.DuplicateFingerprint -> {
@@ -93,6 +96,7 @@ class ExtensionReposScreenModel(
fun deleteRepo(baseUrl: String) {
screenModelScope.launchIO {
deleteExtensionRepo.await(baseUrl)
extensionManager.findAvailableExtensions()
}
}

View File

@@ -57,7 +57,7 @@ fun ExtensionRepoCreateDialog(
},
text = {
Column {
Text(text = stringResource(MR.strings.action_add_repo_message))
Text(text = stringResource(MR.strings.action_add_repo_message, stringResource(MR.strings.app_name)))
OutlinedTextField(
modifier = Modifier

View File

@@ -99,7 +99,7 @@ class DebugInfoScreen : Screen() {
}
private fun getDeviceInfoGroup(): Preference.PreferenceGroup {
val items = persistentListOf<Preference.PreferenceItem<out Any>>().mutate {
val items = persistentListOf<Preference.PreferenceItem<out Any, out Any>>().mutate {
it.add(
Preference.PreferenceItem.TextPreference(
title = "Model",

View File

@@ -6,6 +6,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.TextStyle
@@ -15,9 +16,10 @@ import androidx.compose.ui.unit.sp
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
@Composable
fun PageIndicatorText(
fun ReaderPageIndicator(
currentPage: Int,
totalPages: Int,
modifier: Modifier = Modifier,
) {
if (currentPage <= 0 || totalPages <= 0) return
@@ -36,6 +38,7 @@ fun PageIndicatorText(
Box(
contentAlignment = Alignment.Center,
modifier = modifier,
) {
Text(
text = text,
@@ -50,10 +53,10 @@ fun PageIndicatorText(
@PreviewLightDark
@Composable
private fun PageIndicatorTextPreview() {
private fun ReaderPageIndicatorPreview() {
TachiyomiPreviewTheme {
Surface {
PageIndicatorText(currentPage = 10, totalPages = 69)
ReaderPageIndicator(currentPage = 10, totalPages = 69)
}
}
}

View File

@@ -2,42 +2,41 @@ package eu.kanade.presentation.reader.appbars
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bookmark
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.reader.components.ChapterNavigator
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
private val animationSpec = tween<IntOffset>(200)
private val readerBarsSlideAnimationSpec = tween<IntOffset>(200)
private val readerBarsFadeAnimationSpec = tween<Float>(150)
@Composable
fun ReaderAppBars(
visible: Boolean,
fullscreen: Boolean,
mangaTitle: String?,
chapterTitle: String?,
@@ -71,83 +70,26 @@ fun ReaderAppBars(
.surfaceColorAtElevation(3.dp)
.copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f)
val modifierWithInsetsPadding = if (fullscreen) {
Modifier.systemBarsPadding()
} else {
Modifier
}
Column(
modifier = Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.fillMaxHeight()) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically(
initialOffsetY = { -it },
animationSpec = animationSpec,
),
exit = slideOutVertically(
targetOffsetY = { -it },
animationSpec = animationSpec,
),
enter = slideInVertically(initialOffsetY = { -it }, animationSpec = readerBarsSlideAnimationSpec) +
fadeIn(animationSpec = readerBarsFadeAnimationSpec),
exit = slideOutVertically(targetOffsetY = { -it }, animationSpec = readerBarsSlideAnimationSpec) +
fadeOut(animationSpec = readerBarsFadeAnimationSpec),
) {
AppBar(
modifier = modifierWithInsetsPadding
ReaderTopBar(
modifier = Modifier
.background(backgroundColor)
.clickable(onClick = onClickTopAppBar),
backgroundColor = backgroundColor,
title = mangaTitle,
subtitle = chapterTitle,
mangaTitle = mangaTitle,
chapterTitle = chapterTitle,
navigateUp = navigateUp,
actions = {
AppBarActions(
actions = persistentListOf<AppBar.AppBarAction>().builder()
.apply {
add(
AppBar.Action(
title = stringResource(
if (bookmarked) {
MR.strings.action_remove_bookmark
} else {
MR.strings.action_bookmark
},
),
icon = if (bookmarked) {
Icons.Outlined.Bookmark
} else {
Icons.Outlined.BookmarkBorder
},
onClick = onToggleBookmarked,
),
)
onOpenInWebView?.let {
add(
AppBar.OverflowAction(
title = stringResource(MR.strings.action_open_in_web_view),
onClick = it,
),
)
}
onOpenInBrowser?.let {
add(
AppBar.OverflowAction(
title = stringResource(MR.strings.action_open_in_browser),
onClick = it,
),
)
}
onShare?.let {
add(
AppBar.OverflowAction(
title = stringResource(MR.strings.action_share),
onClick = it,
),
)
}
}
.build(),
)
},
bookmarked = bookmarked,
onToggleBookmarked = onToggleBookmarked,
onOpenInWebView = onOpenInWebView,
onOpenInBrowser = onOpenInBrowser,
onShare = onShare,
)
}
@@ -155,19 +97,12 @@ fun ReaderAppBars(
AnimatedVisibility(
visible = visible,
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = animationSpec,
),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = animationSpec,
),
enter = slideInVertically(initialOffsetY = { it }, animationSpec = readerBarsSlideAnimationSpec) +
fadeIn(animationSpec = readerBarsFadeAnimationSpec),
exit = slideOutVertically(targetOffsetY = { it }, animationSpec = readerBarsSlideAnimationSpec) +
fadeOut(animationSpec = readerBarsFadeAnimationSpec),
) {
Column(
modifier = modifierWithInsetsPadding,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) {
ChapterNavigator(
isRtl = isRtl,
onNextChapter = onNextChapter,
@@ -178,8 +113,12 @@ fun ReaderAppBars(
totalPages = totalPages,
onPageIndexChange = onPageIndexChange,
)
BottomReaderBar(
backgroundColor = backgroundColor,
ReaderBottomBar(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.padding(horizontal = MaterialTheme.padding.small)
.windowInsetsPadding(WindowInsets.navigationBars),
readingMode = readingMode,
onClickReadingMode = onClickReadingMode,
orientation = orientation,

View File

@@ -1,10 +1,7 @@
package eu.kanade.presentation.reader.appbars
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
@@ -12,9 +9,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
@@ -22,8 +18,7 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun BottomReaderBar(
backgroundColor: Color,
fun ReaderBottomBar(
readingMode: ReadingMode,
onClickReadingMode: () -> Unit,
orientation: ReaderOrientation,
@@ -31,12 +26,11 @@ fun BottomReaderBar(
cropEnabled: Boolean,
onClickCropBorder: () -> Unit,
onClickSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.padding(8.dp),
modifier = modifier
.pointerInput(Unit) {},
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {

View File

@@ -0,0 +1,83 @@
package eu.kanade.presentation.reader.appbars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bookmark
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun ReaderTopBar(
mangaTitle: String?,
chapterTitle: String?,
navigateUp: () -> Unit,
bookmarked: Boolean,
onToggleBookmarked: () -> Unit,
onOpenInWebView: (() -> Unit)?,
onOpenInBrowser: (() -> Unit)?,
onShare: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
AppBar(
modifier = modifier,
backgroundColor = Color.Transparent,
title = mangaTitle,
subtitle = chapterTitle,
navigateUp = navigateUp,
actions = {
AppBarActions(
actions = persistentListOf<AppBar.AppBarAction>().builder()
.apply {
add(
AppBar.Action(
title = stringResource(
if (bookmarked) {
MR.strings.action_remove_bookmark
} else {
MR.strings.action_bookmark
},
),
icon = if (bookmarked) {
Icons.Outlined.Bookmark
} else {
Icons.Outlined.BookmarkBorder
},
onClick = onToggleBookmarked,
),
)
onOpenInWebView?.let {
add(
AppBar.OverflowAction(
title = stringResource(MR.strings.action_open_in_web_view),
onClick = it,
),
)
}
onOpenInBrowser?.let {
add(
AppBar.OverflowAction(
title = stringResource(MR.strings.action_open_in_browser),
onClick = it,
),
)
}
onShare?.let {
add(
AppBar.OverflowAction(
title = stringResource(MR.strings.action_share),
onClick = it,
),
)
}
}
.build(),
)
},
)
}

View File

@@ -1,5 +1,6 @@
package eu.kanade.presentation.reader.settings
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
@@ -8,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.util.system.hasDisplayCutout
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CheckboxItem
import tachiyomi.presentation.core.components.SettingsChipRow
@@ -64,10 +66,11 @@ internal fun ColumnScope.GeneralPage(screenModel: ReaderSettingsScreenModel) {
pref = screenModel.preferences.fullscreen(),
)
if (screenModel.hasDisplayCutout && screenModel.preferences.fullscreen().get()) {
val isFullscreen by screenModel.preferences.fullscreen().collectAsState()
if (LocalActivity.current?.hasDisplayCutout() == true && isFullscreen) {
CheckboxItem(
label = stringResource(MR.strings.pref_cutout_short),
pref = screenModel.preferences.cutoutShort(),
pref = screenModel.preferences.drawUnderCutout(),
)
}
@@ -100,7 +103,7 @@ internal fun ColumnScope.GeneralPage(screenModel: ReaderSettingsScreenModel) {
value = flashMillis / ReaderPreferences.MILLI_CONVERSION,
valueRange = 1..15,
label = stringResource(MR.strings.pref_flash_duration),
valueText = stringResource(MR.strings.pref_flash_duration_summary, flashMillis),
valueString = stringResource(MR.strings.pref_flash_duration_summary, flashMillis),
onChange = { flashMillisPref.set(it * ReaderPreferences.MILLI_CONVERSION) },
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
@@ -108,7 +111,7 @@ internal fun ColumnScope.GeneralPage(screenModel: ReaderSettingsScreenModel) {
value = flashInterval,
valueRange = 1..10,
label = stringResource(MR.strings.pref_flash_page_interval),
valueText = pluralStringResource(MR.plurals.pref_pages, flashInterval, flashInterval),
valueString = pluralStringResource(MR.plurals.pref_pages, flashInterval, flashInterval),
onChange = {
flashIntervalPref.set(it)
},

View File

@@ -156,7 +156,7 @@ private fun ColumnScope.WebtoonViewerSettings(screenModel: ReaderSettingsScreenM
value = webtoonSidePadding,
valueRange = ReaderPreferences.let { it.WEBTOON_PADDING_MIN..it.WEBTOON_PADDING_MAX },
label = stringResource(MR.strings.pref_webtoon_side_padding),
valueText = numberFormat.format(webtoonSidePadding / 100f),
valueString = numberFormat.format(webtoonSidePadding / 100f),
onChange = {
screenModel.preferences.webtoonSidePadding().set(it)
},

View File

@@ -1,46 +1,13 @@
package eu.kanade.presentation.util
import android.annotation.SuppressLint
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.SeekableTransitionState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import cafe.adriel.voyager.core.annotation.InternalVoyagerApi
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.ScreenModelStore
import cafe.adriel.voyager.core.screen.Screen
@@ -49,28 +16,18 @@ import cafe.adriel.voyager.core.screen.uniqueScreenKey
import cafe.adriel.voyager.core.stack.StackEvent
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.ScreenTransitionContent
import eu.kanade.tachiyomi.util.view.getWindowRadius
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import soup.compose.material.motion.animation.materialSharedAxisXIn
import soup.compose.material.motion.animation.materialSharedAxisXOut
import soup.compose.material.motion.animation.materialSharedAxisX
import soup.compose.material.motion.animation.rememberSlideDistance
import tachiyomi.presentation.core.util.PredictiveBack
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.absoluteValue
/**
* For invoking back press to the parent activity
*/
@SuppressLint("ComposeCompositionLocalUsage")
val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null }
interface Tab : cafe.adriel.voyager.navigator.tab.Tab {
@@ -98,278 +55,41 @@ interface AssistContentScreen {
fun onProvideAssistUrl(): String?
}
@OptIn(InternalVoyagerApi::class)
@Composable
fun DefaultNavigatorScreenTransition(
navigator: Navigator,
modifier: Modifier = Modifier,
) {
val screenCandidatesToDispose = rememberSaveable(saver = screenCandidatesToDisposeSaver()) {
mutableStateOf(emptySet())
}
val currentScreens = navigator.items
DisposableEffect(currentScreens) {
onDispose {
val newScreenKeys = navigator.items.map { it.key }
screenCandidatesToDispose.value += currentScreens.filter { it.key !in newScreenKeys }
}
}
val slideDistance = rememberSlideDistance(slideDistance = 30.dp)
val slideDistance = rememberSlideDistance()
ScreenTransition(
navigator = navigator,
transition = {
materialSharedAxisX(
forward = navigator.lastEvent != StackEvent.Pop,
slideDistance = slideDistance,
)
},
modifier = modifier,
enterTransition = {
if (it == SwipeEdge.Right) {
materialSharedAxisXIn(forward = false, slideDistance = slideDistance)
} else {
materialSharedAxisXIn(forward = true, slideDistance = slideDistance)
}
},
exitTransition = {
if (it == SwipeEdge.Right) {
materialSharedAxisXOut(forward = false, slideDistance = slideDistance)
} else {
materialSharedAxisXOut(forward = true, slideDistance = slideDistance)
}
},
popEnterTransition = {
if (it == SwipeEdge.Right) {
materialSharedAxisXIn(forward = true, slideDistance = slideDistance)
} else {
materialSharedAxisXIn(forward = false, slideDistance = slideDistance)
}
},
popExitTransition = {
if (it == SwipeEdge.Right) {
materialSharedAxisXOut(forward = true, slideDistance = slideDistance)
} else {
materialSharedAxisXOut(forward = false, slideDistance = slideDistance)
}
},
content = { screen ->
if (this.transition.targetState == this.transition.currentState) {
LaunchedEffect(Unit) {
val newScreens = navigator.items.map { it.key }
val screensToDispose = screenCandidatesToDispose.value.filterNot { it.key in newScreens }
if (screensToDispose.isNotEmpty()) {
screensToDispose.forEach { navigator.dispose(it) }
navigator.clearEvent()
}
screenCandidatesToDispose.value = emptySet()
}
}
screen.Content()
},
)
}
enum class SwipeEdge {
Unknown,
Left,
Right,
}
private enum class AnimationType {
Pop,
Cancel,
}
@Composable
fun ScreenTransition(
navigator: Navigator,
transition: AnimatedContentTransitionScope<Screen>.() -> ContentTransform,
modifier: Modifier = Modifier,
enterTransition: AnimatedContentTransitionScope<Screen>.(SwipeEdge) -> EnterTransition = { fadeIn() },
exitTransition: AnimatedContentTransitionScope<Screen>.(SwipeEdge) -> ExitTransition = { fadeOut() },
popEnterTransition: AnimatedContentTransitionScope<Screen>.(SwipeEdge) -> EnterTransition = enterTransition,
popExitTransition: AnimatedContentTransitionScope<Screen>.(SwipeEdge) -> ExitTransition = exitTransition,
sizeTransform: (AnimatedContentTransitionScope<Screen>.() -> SizeTransform?)? = null,
flingAnimationSpec: () -> AnimationSpec<Float> = { spring(stiffness = Spring.StiffnessLow) },
content: ScreenTransitionContent = { it.Content() },
) {
val view = LocalView.current
val viewConfig = LocalViewConfiguration.current
val scope = rememberCoroutineScope()
val state = remember {
ScreenTransitionState(
navigator = navigator,
scope = scope,
flingAnimationSpec = flingAnimationSpec(),
windowCornerRadius = view.getWindowRadius().toFloat(),
)
}
val transitionState = remember { SeekableTransitionState(navigator.lastItem) }
val transition = rememberTransition(transitionState = transitionState)
if (state.isPredictiveBack || state.isAnimating) {
LaunchedEffect(state.progress) {
if (!state.isPredictiveBack) return@LaunchedEffect
val previousEntry = navigator.items.getOrNull(navigator.size - 2)
if (previousEntry != null) {
transitionState.seekTo(fraction = state.progress, targetState = previousEntry)
}
}
} else {
LaunchedEffect(navigator) {
snapshotFlow { navigator.lastItem }
.collect {
state.cancelCancelAnimation()
if (it != transitionState.currentState) {
transitionState.animateTo(it)
} else {
transitionState.snapTo(it)
}
}
}
}
PredictiveBackHandler(enabled = navigator.canPop) { backEvent ->
state.cancelCancelAnimation()
var startOffset: Offset? = null
backEvent
.dropWhile {
if (startOffset == null) startOffset = Offset(it.touchX, it.touchY)
if (state.isAnimating) return@dropWhile true
// Touch slop check
val diff = Offset(it.touchX, it.touchY) - startOffset!!
diff.x.absoluteValue < viewConfig.touchSlop && diff.y.absoluteValue < viewConfig.touchSlop
}
.onCompletion {
if (it == null) {
state.finish()
} else {
state.cancel()
}
}
.collect {
state.setPredictiveBackProgress(
progress = it.progress,
swipeEdge = when (it.swipeEdge) {
BackEventCompat.EDGE_LEFT -> SwipeEdge.Left
BackEventCompat.EDGE_RIGHT -> SwipeEdge.Right
else -> SwipeEdge.Unknown
},
)
}
}
transition.AnimatedContent(
AnimatedContent(
targetState = navigator.lastItem,
transitionSpec = transition,
modifier = modifier,
transitionSpec = {
val pop = navigator.lastEvent == StackEvent.Pop || state.isPredictiveBack
ContentTransform(
targetContentEnter = if (pop) {
popEnterTransition(state.swipeEdge)
} else {
enterTransition(state.swipeEdge)
},
initialContentExit = if (pop) {
popExitTransition(state.swipeEdge)
} else {
exitTransition(state.swipeEdge)
},
targetContentZIndex = if (pop) 0f else 1f,
sizeTransform = sizeTransform?.invoke(this),
)
},
contentKey = { it.key },
) {
navigator.saveableState("transition", it) {
content(it)
label = "transition",
) { screen ->
navigator.saveableState("transition", screen) {
content(screen)
}
}
}
@Stable
private class ScreenTransitionState(
private val navigator: Navigator,
private val scope: CoroutineScope,
private val flingAnimationSpec: AnimationSpec<Float>,
windowCornerRadius: Float,
) {
var isPredictiveBack: Boolean by mutableStateOf(false)
private set
var progress: Float by mutableFloatStateOf(0f)
private set
var swipeEdge: SwipeEdge by mutableStateOf(SwipeEdge.Unknown)
private set
private var animationJob: Pair<Job, AnimationType>? by mutableStateOf(null)
val isAnimating: Boolean
get() = animationJob?.first?.isActive == true
val windowCornerShape = RoundedCornerShape(windowCornerRadius)
private fun reset() {
this.isPredictiveBack = false
this.swipeEdge = SwipeEdge.Unknown
this.animationJob = null
}
fun setPredictiveBackProgress(progress: Float, swipeEdge: SwipeEdge) {
this.progress = lerp(0f, 0.65f, PredictiveBack.transform(progress))
this.swipeEdge = swipeEdge
this.isPredictiveBack = true
}
fun finish() {
if (!isPredictiveBack) {
navigator.pop()
return
}
animationJob = scope.launch {
try {
animate(
initialValue = progress,
targetValue = 1f,
animationSpec = flingAnimationSpec,
block = { i, _ -> progress = i },
)
navigator.pop()
} catch (e: CancellationException) {
// Cancelled
progress = 0f
} finally {
reset()
}
} to AnimationType.Pop
}
fun cancel() {
if (!isPredictiveBack) {
return
}
animationJob = scope.launch {
try {
animate(
initialValue = progress,
targetValue = 0f,
animationSpec = flingAnimationSpec,
block = { i, _ -> progress = i },
)
} catch (e: CancellationException) {
// Cancelled
progress = 1f
} finally {
reset()
}
} to AnimationType.Cancel
}
fun cancelCancelAnimation() {
if (animationJob?.second == AnimationType.Cancel) {
animationJob?.first?.cancel()
animationJob = null
}
}
}
private fun screenCandidatesToDisposeSaver(): Saver<MutableState<Set<Screen>>, List<Screen>> {
return Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toSet()) },
)
BackHandler(enabled = navigator.canPop, onBack = navigator::pop)
}

View File

@@ -2,6 +2,7 @@ package eu.kanade.presentation.webview
import android.content.pm.ApplicationInfo
import android.graphics.Bitmap
import android.os.Message
import android.webkit.WebResourceRequest
import android.webkit.WebView
import androidx.compose.foundation.clickable
@@ -19,6 +20,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -28,11 +30,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.stack.mutableStateStackOf
import com.kevinnzou.web.AccompanistWebChromeClient
import com.kevinnzou.web.AccompanistWebViewClient
import com.kevinnzou.web.LoadingState
import com.kevinnzou.web.WebContent
import com.kevinnzou.web.WebView
import com.kevinnzou.web.WebViewState
import com.kevinnzou.web.rememberWebViewNavigator
import com.kevinnzou.web.rememberWebViewState
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.WarningBanner
@@ -44,6 +49,18 @@ import kotlinx.coroutines.launch
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
class WebViewWindow(webContent: WebContent) {
var state by mutableStateOf(WebViewState(webContent))
var popupMessage: Message? = null
private set
var webView: WebView? = null
constructor(popupMessage: Message) : this(WebContent.NavigatorOnly) {
this.popupMessage = popupMessage
}
}
@Composable
fun WebViewScreenContent(
onNavigateUp: () -> Unit,
@@ -55,7 +72,26 @@ fun WebViewScreenContent(
headers: Map<String, String> = emptyMap(),
onUrlChange: (String) -> Unit = {},
) {
val state = rememberWebViewState(url = url, additionalHttpHeaders = headers)
val windowStack = remember {
mutableStateStackOf(
WebViewWindow(
WebContent.Url(url = url, additionalHttpHeaders = headers),
),
)
}
val currentWindow = windowStack.lastItemOrNull!!
val popState: (() -> Unit) = remember {
{
if (windowStack.size == 1) {
onNavigateUp()
} else {
windowStack.pop()
}
}
}
val navigator = rememberWebViewNavigator()
val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
@@ -116,14 +152,39 @@ fun WebViewScreenContent(
}
}
val webChromeClient = remember {
object : AccompanistWebChromeClient() {
override fun onCreateWindow(
view: WebView,
isDialog: Boolean,
isUserGesture: Boolean,
resultMsg: Message,
): Boolean {
// if it wasn't initiated by a user gesture, we should ignore it like a normal browser would
if (isUserGesture) {
windowStack.push(WebViewWindow(resultMsg))
return true
}
return false
}
}
}
fun initializePopup(webView: WebView, message: Message): WebView {
val transport = message.obj as WebView.WebViewTransport
transport.webView = webView
message.sendToTarget()
return webView
}
Scaffold(
topBar = {
Box {
Column {
AppBar(
title = state.pageTitle ?: initialTitle,
title = currentWindow.state.pageTitle ?: initialTitle,
subtitle = currentUrl,
navigateUp = onNavigateUp,
navigateUp = popState,
navigationIcon = Icons.Outlined.Close,
actions = {
AppBarActions(
@@ -186,7 +247,7 @@ fun WebViewScreenContent(
}
}
}
when (val loadingState = state.loadingState) {
when (val loadingState = currentWindow.state.loadingState) {
is LoadingState.Initializing -> LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
@@ -203,27 +264,55 @@ fun WebViewScreenContent(
}
},
) { contentPadding ->
WebView(
state = state,
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
navigator = navigator,
onCreated = { webView ->
webView.setDefaultSettings()
// We need to key the WebView composable to the window object since simply updating the WebView composable will
// not cause it to re-invoke the WebView factory and render the new current window's WebView. This lets us
// completely reset the WebView composable when the current window switches.
key(currentWindow) {
WebView(
state = currentWindow.state,
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
navigator = navigator,
onCreated = { webView ->
webView.setDefaultSettings()
// Debug mode (chrome://inspect/#devices)
if (BuildConfig.DEBUG &&
0 != webView.context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE
) {
WebView.setWebContentsDebuggingEnabled(true)
}
// Debug mode (chrome://inspect/#devices)
if (BuildConfig.DEBUG &&
0 != webView.context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE
) {
WebView.setWebContentsDebuggingEnabled(true)
}
headers["user-agent"]?.let {
webView.settings.userAgentString = it
}
},
client = webClient,
)
headers["user-agent"]?.let {
webView.settings.userAgentString = it
}
},
onDispose = { webView ->
val window = windowStack.items.find { it.webView == webView }
if (window == null) {
// If we couldn't find any window on the stack that owns this WebView, it means that we can
// safely dispose of it because the window containing it has been closed.
webView.destroy()
} else {
// The composable is being disposed but the WebView object is not.
// When the WebView element is recomposed, we will want the WebView to resume from its state
// before it was unmounted, we won't want it to reset back to its original target.
window.state = WebViewState(WebContent.NavigatorOnly)
}
},
client = webClient,
chromeClient = webChromeClient,
factory = { context ->
currentWindow.webView
?: WebView(context).also { webView ->
currentWindow.webView = webView
currentWindow.popupMessage?.let {
initializePopup(webView, it)
}
}
},
)
}
}
}

View File

@@ -122,7 +122,7 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
val pendingIntent = PendingIntent.getBroadcast(
this@App,
0,
Intent(ACTION_DISABLE_INCOGNITO_MODE),
Intent(ACTION_DISABLE_INCOGNITO_MODE).setPackage(BuildConfig.APPLICATION_ID),
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
)
setContentIntent(pendingIntent)
@@ -220,8 +220,8 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
// Override the value passed as X-Requested-With in WebView requests
val stackTrace = Looper.getMainLooper().thread.stackTrace
val isChromiumCall = stackTrace.any { trace ->
trace.className.equals("org.chromium.base.BuildInfo", ignoreCase = true) &&
setOf("getAll", "getPackageName", "<init>").any { trace.methodName.equals(it, ignoreCase = true) }
trace.className.lowercase() in setOf("org.chromium.base.buildinfo", "org.chromium.base.apkinfo") &&
trace.methodName.lowercase() in setOf("getall", "getpackagename", "<init>")
}
if (isChromiumCall) return WebViewUtil.spoofedPackageName(applicationContext)

View File

@@ -6,7 +6,6 @@ import com.jakewharton.disklrucache.DiskLruCache
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.saveTo
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import logcat.LogPriority
import okhttp3.Response
@@ -115,7 +114,7 @@ class ChapterCache(
fun isImageInCache(imageUrl: String): Boolean {
return try {
diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)).use { it != null }
} catch (e: IOException) {
} catch (_: IOException) {
false
}
}
@@ -147,7 +146,7 @@ class ChapterCache(
try {
// Get editor from md5 key.
val key = DiskUtil.hashKeyForDisk(imageUrl)
editor = diskCache.edit(key) ?: throw IOException("Unable to edit key")
editor = diskCache.edit(key) ?: return
// Get OutputStream and write image with Okio.
response.body.source().saveTo(editor.newOutputStream(0))

View File

@@ -128,6 +128,7 @@ class DownloadCache(
*
* @param chapterName the name of the chapter to query.
* @param chapterScanlator scanlator of the chapter to query
* @param chapterUrl the url of the chapter to query
* @param mangaTitle the title of the manga to query.
* @param sourceId the id of the source of the chapter.
* @param skipCache whether to skip the directory cache and check in the filesystem.
@@ -135,13 +136,14 @@ class DownloadCache(
fun isChapterDownloaded(
chapterName: String,
chapterScanlator: String?,
chapterUrl: String,
mangaTitle: String,
sourceId: Long,
skipCache: Boolean,
): Boolean {
if (skipCache) {
val source = sourceManager.getOrStub(sourceId)
return provider.findChapterDir(chapterName, chapterScanlator, mangaTitle, source) != null
return provider.findChapterDir(chapterName, chapterScanlator, chapterUrl, mangaTitle, source) != null
}
renewCache()
@@ -153,6 +155,7 @@ class DownloadCache(
return provider.getValidChapterDirNames(
chapterName,
chapterScanlator,
chapterUrl,
).any { it in mangaDir.chapterDirs }
}
}
@@ -233,7 +236,7 @@ class DownloadCache(
rootDownloadsDirMutex.withLock {
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return
provider.getValidChapterDirNames(chapter.name, chapter.scanlator).forEach {
provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).forEach {
if (it in mangaDir.chapterDirs) {
mangaDir.chapterDirs -= it
}
@@ -254,7 +257,7 @@ class DownloadCache(
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return
chapters.forEach { chapter ->
provider.getValidChapterDirNames(chapter.name, chapter.scanlator).forEach {
provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).forEach {
if (it in mangaDir.chapterDirs) {
mangaDir.chapterDirs -= it
}

View File

@@ -159,7 +159,7 @@ class DownloadManager(
* @return the list of pages from the chapter.
*/
fun buildPageList(source: Source, manga: Manga, chapter: Chapter): List<Page> {
val chapterDir = provider.findChapterDir(chapter.name, chapter.scanlator, manga.title, source)
val chapterDir = provider.findChapterDir(chapter.name, chapter.scanlator, chapter.url, manga.title, source)
val files = chapterDir?.listFiles().orEmpty()
.filter { it.isFile && ImageUtil.isImage(it.name) { it.openInputStream() } }
@@ -185,11 +185,12 @@ class DownloadManager(
fun isChapterDownloaded(
chapterName: String,
chapterScanlator: String?,
chapterUrl: String,
mangaTitle: String,
sourceId: Long,
skipCache: Boolean = false,
): Boolean {
return cache.isChapterDownloaded(chapterName, chapterScanlator, mangaTitle, sourceId, skipCache)
return cache.isChapterDownloaded(chapterName, chapterScanlator, chapterUrl, mangaTitle, sourceId, skipCache)
}
/**
@@ -368,7 +369,7 @@ class DownloadManager(
* @param newChapter the target chapter with the new name.
*/
suspend fun renameChapter(source: Source, manga: Manga, oldChapter: Chapter, newChapter: Chapter) {
val oldNames = provider.getValidChapterDirNames(oldChapter.name, oldChapter.scanlator)
val oldNames = provider.getValidChapterDirNames(oldChapter.name, oldChapter.scanlator, oldChapter.url)
val mangaDir = provider.getMangaDir(manga.title, source).getOrElse { e ->
logcat(LogPriority.ERROR, e) { "Manga download folder doesn't exist. Skipping renaming after source sync" }
return
@@ -379,7 +380,7 @@ class DownloadManager(
.mapNotNull { mangaDir.findFile(it) }
.firstOrNull() ?: return
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator)
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
if (oldDownload.isFile && oldDownload.extension == "cbz") {
newName += ".cbz"
}

View File

@@ -3,12 +3,14 @@ package eu.kanade.tachiyomi.data.download
import android.content.Context
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.lang.Hash.md5
import eu.kanade.tachiyomi.util.storage.DiskUtil
import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.displayablePath
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.i18n.MR
@@ -25,6 +27,7 @@ import java.io.IOException
class DownloadProvider(
private val context: Context,
private val storageManager: StorageManager = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(),
) {
private val downloadsDir: UniFile?
@@ -96,9 +99,15 @@ class DownloadProvider(
* @param mangaTitle the title of the manga to query.
* @param source the source of the chapter.
*/
fun findChapterDir(chapterName: String, chapterScanlator: String?, mangaTitle: String, source: Source): UniFile? {
fun findChapterDir(
chapterName: String,
chapterScanlator: String?,
chapterUrl: String,
mangaTitle: String,
source: Source,
): UniFile? {
val mangaDir = findMangaDir(mangaTitle, source)
return getValidChapterDirNames(chapterName, chapterScanlator).asSequence()
return getValidChapterDirNames(chapterName, chapterScanlator, chapterUrl).asSequence()
.mapNotNull { mangaDir?.findFile(it) }
.firstOrNull()
}
@@ -113,7 +122,7 @@ class DownloadProvider(
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): Pair<UniFile?, List<UniFile>> {
val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList()
return mangaDir to chapters.mapNotNull { chapter ->
getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence()
getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).asSequence()
.mapNotNull { mangaDir.findFile(it) }
.firstOrNull()
}
@@ -125,7 +134,10 @@ class DownloadProvider(
* @param source the source to query.
*/
fun getSourceDirName(source: Source): String {
return DiskUtil.buildValidFilename(source.toString())
return DiskUtil.buildValidFilename(
source.toString(),
disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames().get(),
)
}
/**
@@ -134,23 +146,75 @@ class DownloadProvider(
* @param mangaTitle the title of the manga to query.
*/
fun getMangaDirName(mangaTitle: String): String {
return DiskUtil.buildValidFilename(mangaTitle)
return DiskUtil.buildValidFilename(
mangaTitle,
disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames().get(),
)
}
/**
* Returns the chapter directory name for a chapter.
*
* @param chapterName the name of the chapter to query.
* @param chapterScanlator scanlator of the chapter to query
* @param chapterScanlator scanlator of the chapter to query.
* @param chapterUrl url of the chapter to query.
*/
fun getChapterDirName(chapterName: String, chapterScanlator: String?): String {
val newChapterName = sanitizeChapterName(chapterName)
return DiskUtil.buildValidFilename(
fun getChapterDirName(
chapterName: String,
chapterScanlator: String?,
chapterUrl: String,
disallowNonAsciiFilenames: Boolean = libraryPreferences.disallowNonAsciiFilenames().get(),
): String {
var dirName = sanitizeChapterName(chapterName)
if (!chapterScanlator.isNullOrBlank()) {
dirName = chapterScanlator + "_" + dirName
}
// Subtract 7 bytes for hash and underscore, 4 bytes for .cbz
dirName = DiskUtil.buildValidFilename(dirName, DiskUtil.MAX_FILE_NAME_BYTES - 11, disallowNonAsciiFilenames)
dirName += "_" + md5(chapterUrl).take(6)
return dirName
}
/**
* Returns list of names that might have been previously used as
* the directory name for a chapter.
* Add to this list if naming pattern ever changes.
*
* @param chapterName the name of the chapter to query.
* @param chapterScanlator scanlator of the chapter to query.
* @param chapterUrl url of the chapter to query.
*/
private fun getLegacyChapterDirNames(
chapterName: String,
chapterScanlator: String?,
chapterUrl: String,
): List<String> {
val sanitizedChapterName = sanitizeChapterName(chapterName)
val chapterNameV1 = DiskUtil.buildValidFilename(
when {
!chapterScanlator.isNullOrBlank() -> "${chapterScanlator}_$newChapterName"
else -> newChapterName
!chapterScanlator.isNullOrBlank() -> "${chapterScanlator}_$sanitizedChapterName"
else -> sanitizedChapterName
},
)
// Get the filename that would be generated if the user were
// using the other value for the disallow non-ASCII
// filenames setting. This ensures that chapters downloaded
// before the user changed the setting can still be found.
val otherChapterDirName =
getChapterDirName(
chapterName,
chapterScanlator,
chapterUrl,
!libraryPreferences.disallowNonAsciiFilenames().get(),
)
return buildList(2) {
// Chapter name without hash (unable to handle duplicate
// chapter names)
add(chapterNameV1)
add(otherChapterDirName)
}
}
/**
@@ -165,24 +229,30 @@ class DownloadProvider(
}
fun isChapterDirNameChanged(oldChapter: Chapter, newChapter: Chapter): Boolean {
return oldChapter.name != newChapter.name ||
oldChapter.scanlator?.takeIf { it.isNotBlank() } != newChapter.scanlator?.takeIf { it.isNotBlank() }
return getChapterDirName(oldChapter.name, oldChapter.scanlator, oldChapter.url) !=
getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
}
/**
* Returns valid downloaded chapter directory names.
*
* @param chapterName the name of the chapter to query.
* @param chapterScanlator scanlator of the chapter to query
* @param chapter the domain chapter object.
*/
fun getValidChapterDirNames(chapterName: String, chapterScanlator: String?): List<String> {
val chapterDirName = getChapterDirName(chapterName, chapterScanlator)
return buildList(2) {
fun getValidChapterDirNames(chapterName: String, chapterScanlator: String?, chapterUrl: String): List<String> {
val chapterDirName = getChapterDirName(chapterName, chapterScanlator, chapterUrl)
val legacyChapterDirNames = getLegacyChapterDirNames(chapterName, chapterScanlator, chapterUrl)
return buildList {
// Folder of images
add(chapterDirName)
// Archived chapters
add("$chapterDirName.cbz")
// any legacy names
legacyChapterDirNames.forEach {
add(it)
add("$it.cbz")
}
}
}
}

View File

@@ -191,15 +191,17 @@ class Downloader(
if (isRunning) return
downloaderJob = scope.launch {
val activeDownloadsFlow = queueState.transformLatest { queue ->
val activeDownloadsFlow = combine(
queueState,
downloadPreferences.parallelSourceLimit().changes(),
) { a, b -> a to b }.transformLatest { (queue, parallelCount) ->
while (true) {
val activeDownloads = queue.asSequence()
// Ignore completed downloads, leave them in the queue
.filter { it.status.value <= Download.State.DOWNLOADING.value }
.groupBy { it.source }
.toList()
// Concurrently download from 5 different sources
.take(5)
.take(parallelCount)
.map { (_, downloads) -> downloads.first() }
emit(activeDownloads)
@@ -211,7 +213,8 @@ class Downloader(
}.filter { it }
activeDownloadsErroredFlow.first()
}
}.distinctUntilChanged()
}
.distinctUntilChanged()
// Use supervisorScope to cancel child jobs when the downloader job is cancelled
supervisorScope {
@@ -274,7 +277,7 @@ class Downloader(
val wasEmpty = queueState.value.isEmpty()
val chaptersToQueue = chapters.asSequence()
// Filter out those already downloaded.
.filter { provider.findChapterDir(it.name, it.scanlator, manga.title, source) == null }
.filter { provider.findChapterDir(it.name, it.scanlator, it.url, manga.title, source) == null }
// Add chapters to queue from the start.
.sortedByDescending { it.sourceOrder }
// Filter out those already enqueued.
@@ -299,7 +302,10 @@ class Downloader(
maxDownloadsFromSource > CHAPTERS_PER_SOURCE_QUEUE_WARNING_THRESHOLD
) {
notifier.onWarning(
context.stringResource(MR.strings.download_queue_size_warning),
context.stringResource(
MR.strings.download_queue_size_warning,
context.stringResource(MR.strings.app_name),
),
WARNING_NOTIF_TIMEOUT_MS,
NotificationHandler.openUrl(context, LibraryUpdateNotifier.HELP_WARNING_URL),
)
@@ -333,7 +339,11 @@ class Downloader(
return
}
val chapterDirname = provider.getChapterDirName(download.chapter.name, download.chapter.scanlator)
val chapterDirname = provider.getChapterDirName(
download.chapter.name,
download.chapter.scanlator,
download.chapter.url,
)
val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
try {
@@ -359,24 +369,23 @@ class Downloader(
download.status = Download.State.DOWNLOADING
// Start downloading images, consider we can have downloaded images already
// Concurrently do 2 pages at a time
pageList.asFlow()
.flatMapMerge(concurrency = 2) { page ->
flow {
// Fetch image URL if necessary
if (page.imageUrl.isNullOrEmpty()) {
page.status = Page.State.LoadPage
try {
page.imageUrl = download.source.getImageUrl(page)
} catch (e: Throwable) {
page.status = Page.State.Error(e)
}
pageList.asFlow().flatMapMerge(concurrency = downloadPreferences.parallelPageLimit().get()) { page ->
flow {
// Fetch image URL if necessary
if (page.imageUrl.isNullOrEmpty()) {
page.status = Page.State.LoadPage
try {
page.imageUrl = download.source.getImageUrl(page)
} catch (e: Throwable) {
page.status = Page.State.Error(e)
}
}
withIOContext { getOrDownloadImage(page, download, tmpDir) }
emit(page)
}.flowOn(Dispatchers.IO)
withIOContext { getOrDownloadImage(page, download, tmpDir) }
emit(page)
}
.flowOn(Dispatchers.IO)
}
.collect {
// Do when page is downloaded.
notifier.onProgressChange(download)

View File

@@ -104,6 +104,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false)
track.remote_id = remoteTrack.remote_id
track.library_id = remoteTrack.library_id
if (track.status != COMPLETED) {
track.status = if (hasReadChapters) READING else track.status

View File

@@ -76,7 +76,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
.awaitSuccess()
.parseAs<KitsuAddMangaResult>()
.let {
track.remote_id = it.data.id
track.library_id = it.data.id
track
}
}
@@ -88,7 +88,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
val data = buildJsonObject {
putJsonObject("data") {
put("type", "libraryEntries")
put("id", track.remote_id)
put("id", track.library_id)
putJsonObject("attributes") {
put("status", track.toApiStatus())
put("progress", track.last_chapter_read.toInt())
@@ -102,7 +102,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
authClient.newCall(
Request.Builder()
.url("${BASE_URL}library-entries/${track.remote_id}")
.url("${BASE_URL}library-entries/${track.library_id}")
.headers(
headersOf("Content-Type", VND_API_JSON),
)
@@ -119,7 +119,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
withIOContext {
authClient.newCall(
DELETE(
"${BASE_URL}library-entries/${track.remoteId}",
"${BASE_URL}library-entries/${track.libraryId}",
headers = headersOf("Content-Type", VND_API_JSON),
),
)
@@ -192,7 +192,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
suspend fun getLibManga(track: Track): Track {
return withIOContext {
val url = "${BASE_URL}library-entries".toUri().buildUpon()
.encodedQuery("filter[id]=${track.remote_id}")
.encodedQuery("filter[id]=${track.library_id}")
.appendQueryParameter("include", "manga")
.build()
with(json) {

View File

@@ -21,7 +21,8 @@ data class KitsuListSearchResult(
val manga = included[0].attributes
return TrackSearch.create(TrackerManager.KITSU).apply {
remote_id = userData.id
remote_id = included[0].id
library_id = userData.id
title = manga.canonicalTitle
total_chapters = manga.chapterCount ?: 0
cover_url = manga.posterImage?.original ?: ""

View File

@@ -70,7 +70,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
}
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getTrackSearch(track.tracking_url)
val remoteTrack = api.getTrackSearch(track.remote_id)
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
return track
@@ -88,14 +88,13 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
override suspend fun match(manga: DomainManga): TrackSearch? =
try {
api.getTrackSearch(manga.url)
api.getTrackSearch(manga.url.getMangaId())
} catch (e: Exception) {
null
}
override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean = source?.let {
accept(it)
} == true
override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean =
track.remoteUrl == manga.url && source?.let { accept(it) } == true
override fun migrateTrack(track: DomainTrack, manga: DomainManga, newSource: Source): DomainTrack? =
if (accept(newSource)) {
@@ -103,4 +102,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
} else {
null
}
private fun String.getMangaId(): Long =
this.substringAfterLast('/').toLong()
}

View File

@@ -1,22 +1,22 @@
package eu.kanade.tachiyomi.data.track.suwayomi
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.json.Json
import okhttp3.Credentials
import okhttp3.Dns
import okhttp3.FormBody
import okhttp3.Headers
import kotlinx.serialization.json.addAll
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
@@ -25,79 +25,147 @@ import java.security.MessageDigest
class SuwayomiApi(private val trackId: Long) {
private val network: NetworkHelper by injectLazy()
private val json: Json by injectLazy()
private val client: OkHttpClient =
network.client.newBuilder()
.dns(Dns.SYSTEM) // don't use DNS over HTTPS as it breaks IP addressing
.build()
private val sourceManager: SourceManager by injectLazy()
private val source: HttpSource by lazy { (sourceManager.get(sourceId) as HttpSource) }
private val client: OkHttpClient by lazy { source.client }
private val baseUrl: String by lazy { source.baseUrl.trimEnd('/') }
private val apiUrl: String by lazy { "$baseUrl/api/graphql" }
private fun headersBuilder(): Headers.Builder = Headers.Builder().apply {
if (basePassword.isNotEmpty() && baseLogin.isNotEmpty()) {
val credentials = Credentials.basic(baseLogin, basePassword)
add("Authorization", credentials)
suspend fun getTrackSearch(mangaId: Long): TrackSearch = withIOContext {
val query = """
|query GetManga(${'$'}mangaId: Int!) {
| manga(id: ${'$'}mangaId) {
| ...MangaFragment
| }
|}
|
|$MangaFragment
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("mangaId", mangaId)
}
}
}
private val headers: Headers by lazy { headersBuilder().build() }
private val baseUrl by lazy { getPrefBaseUrl() }
private val baseLogin by lazy { getPrefBaseLogin() }
private val basePassword by lazy { getPrefBasePassword() }
suspend fun getTrackSearch(trackUrl: String): TrackSearch = withIOContext {
val url = try {
// test if getting api url or manga id
val mangaId = trackUrl.toLong()
"$baseUrl/api/v1/manga/$mangaId"
} catch (e: NumberFormatException) {
trackUrl
}
val manga = with(json) {
client.newCall(GET("$url/full", headers))
client.newCall(
POST(
apiUrl,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<MangaDataClass>()
.parseAs<GetMangaResult>()
.data
.entry
}
TrackSearch.create(trackId).apply {
remote_id = mangaId
title = manga.title
cover_url = "$url/thumbnail"
cover_url = "$baseUrl/${manga.thumbnailUrl}"
summary = manga.description.orEmpty()
tracking_url = url
total_chapters = manga.chapterCount
publishing_status = manga.status
last_chapter_read = manga.lastChapterRead?.chapterNumber ?: 0.0
tracking_url = "$baseUrl/manga/$mangaId"
total_chapters = manga.chapters.totalCount.toLong()
publishing_status = manga.status.name
last_chapter_read = manga.latestReadChapter?.chapterNumber ?: 0.0
status = when (manga.unreadCount) {
manga.chapterCount -> Suwayomi.UNREAD
0L -> Suwayomi.COMPLETED
manga.chapters.totalCount -> Suwayomi.UNREAD
0 -> Suwayomi.COMPLETED
else -> Suwayomi.READING
}
}
}
suspend fun updateProgress(track: Track): Track {
val url = track.tracking_url
val chapters = with(json) {
client.newCall(GET("$url/chapters", headers))
.awaitSuccess()
.parseAs<List<ChapterDataClass>>()
val mangaId = track.remote_id
val chaptersQuery = """
|query GetMangaUnreadChapters(${'$'}mangaId: Int!) {
| chapters(condition: {mangaId: ${'$'}mangaId, isRead: false}) {
| nodes {
| id
| chapterNumber
| }
| }
|}
""".trimMargin()
val chaptersPayload = buildJsonObject {
put("query", chaptersQuery)
putJsonObject("variables") {
put("mangaId", mangaId)
}
}
val chaptersToMark = with(json) {
client.newCall(
POST(
apiUrl,
body = chaptersPayload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<GetMangaUnreadChaptersResult>()
.data
.entry
.nodes
.mapNotNull { n -> n.id.takeIf { n.chapterNumber <= track.last_chapter_read } }
}
val lastChapterIndex = chapters.first { it.chapterNumber == track.last_chapter_read }.index
client.newCall(
PUT(
"$url/chapter/$lastChapterIndex",
headers,
FormBody.Builder(Charset.forName("utf8"))
.add("markPrevRead", "true")
.add("read", "true")
.build(),
),
).awaitSuccess()
val markQuery = """
|mutation MarkChaptersRead(${'$'}chapters: [Int!]!) {
| updateChapters(input: {ids: ${'$'}chapters, patch: {isRead: true}}) {
| chapters {
| id
| }
| }
|}
""".trimMargin()
val markPayload = buildJsonObject {
put("query", markQuery)
putJsonObject("variables") {
putJsonArray("chapters") {
addAll(chaptersToMark)
}
}
}
with(json) {
client.newCall(
POST(
apiUrl,
body = markPayload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
}
return getTrackSearch(track.tracking_url)
val trackQuery = """
|mutation TrackManga(${'$'}mangaId: Int!) {
| trackProgress(input: {mangaId: ${'$'}mangaId}) {
| trackRecords {
| lastChapterRead
| }
| }
|}
""".trimMargin()
val trackPayload = buildJsonObject {
put("query", trackQuery)
putJsonObject("variables") {
put("mangaId", mangaId)
}
}
with(json) {
client.newCall(
POST(
apiUrl,
body = trackPayload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
}
return getTrackSearch(track.remote_id)
}
private val sourceId by lazy {
@@ -106,18 +174,35 @@ class SuwayomiApi(private val trackId: Long) {
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$sourceId", Context.MODE_PRIVATE)
companion object {
private val MangaFragment = """
|fragment MangaFragment on MangaType {
| artist
| author
| description
| id
| status
| thumbnailUrl
| title
| url
| genre
| inLibraryAt
| chapters {
| totalCount
| }
| latestUploadedChapter {
| uploadDate
| }
| latestFetchedChapter {
| fetchedAt
| }
| latestReadChapter {
| lastReadAt
| chapterNumber
| }
| unreadCount
| downloadCount
|}
""".trimMargin()
}
private fun getPrefBaseUrl(): String = preferences.getString(ADDRESS_TITLE, ADDRESS_DEFAULT)!!
private fun getPrefBaseLogin(): String = preferences.getString(LOGIN_TITLE, LOGIN_DEFAULT)!!
private fun getPrefBasePassword(): String = preferences.getString(PASSWORD_TITLE, PASSWORD_DEFAULT)!!
}
private const val ADDRESS_TITLE = "Server URL Address"
private const val ADDRESS_DEFAULT = ""
private const val LOGIN_TITLE = "Login (Basic Auth)"
private const val LOGIN_DEFAULT = ""
private const val PASSWORD_TITLE = "Password (Basic Auth)"
private const val PASSWORD_DEFAULT = ""

View File

@@ -1,100 +1,90 @@
package eu.kanade.tachiyomi.data.track.suwayomi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
public enum class MangaStatus(
public val rawValue: String,
) {
UNKNOWN("UNKNOWN"),
ONGOING("ONGOING"),
COMPLETED("COMPLETED"),
LICENSED("LICENSED"),
PUBLISHING_FINISHED("PUBLISHING_FINISHED"),
CANCELLED("CANCELLED"),
ON_HIATUS("ON_HIATUS"),
}
@Serializable
data class SourceDataClass(
val id: String,
val name: String,
val lang: String,
val iconUrl: String,
public data class MangaFragment(
public val artist: String?,
public val author: String?,
public val description: String?,
public val id: Int,
public val status: MangaStatus,
public val thumbnailUrl: String?,
public val title: String,
public val url: String,
public val genre: List<String>,
public val inLibraryAt: Long,
public val chapters: Chapters,
public val latestUploadedChapter: LatestUploadedChapter?,
public val latestFetchedChapter: LatestFetchedChapter?,
public val latestReadChapter: LatestReadChapter?,
public val unreadCount: Int,
public val downloadCount: Int,
) {
@Serializable
public data class Chapters(
public val totalCount: Int,
)
/** The Source provides a latest listing */
val supportsLatest: Boolean,
@Serializable
public data class LatestUploadedChapter(
public val uploadDate: Long,
)
/** The Source implements [ConfigurableSource] */
val isConfigurable: Boolean,
@Serializable
public data class LatestFetchedChapter(
public val fetchedAt: Long,
)
/** The Source class has a @Nsfw annotation */
val isNsfw: Boolean,
@Serializable
public data class LatestReadChapter(
public val lastReadAt: Long,
public val chapterNumber: Double,
)
}
/** A nicer version of [name] */
val displayName: String,
@Serializable
public data class GetMangaResult(
public val data: GetMangaData,
)
@Serializable
data class MangaDataClass(
val id: Int,
val sourceId: String,
val url: String,
val title: String,
val thumbnailUrl: String?,
val initialized: Boolean,
val artist: String?,
val author: String?,
val description: String?,
val genre: List<String>,
val status: String,
val inLibrary: Boolean,
val inLibraryAt: Long,
val source: SourceDataClass?,
val meta: Map<String, String>,
val realUrl: String?,
val lastFetchedAt: Long?,
val chaptersLastFetchedAt: Long?,
val freshData: Boolean,
val unreadCount: Long?,
val downloadCount: Long?,
val chapterCount: Long, // actually is nullable server side, but should be set at this time
val lastChapterRead: ChapterDataClass?,
val age: Long?,
val chaptersAge: Long?,
public data class GetMangaData(
@SerialName("manga")
public val entry: MangaFragment,
)
@Serializable
data class ChapterDataClass(
val id: Int,
val url: String,
val name: String,
val uploadDate: Long,
val chapterNumber: Double,
val scanlator: String?,
val mangaId: Int,
/** chapter is read */
val read: Boolean,
/** chapter is bookmarked */
val bookmarked: Boolean,
/** last read page, zero means not read/no data */
val lastPageRead: Int,
/** last read page, zero means not read/no data */
val lastReadAt: Long,
/** this chapter's index, starts with 1 */
val index: Int,
/** the date we fist saw this chapter*/
val fetchedAt: Long,
/** is chapter downloaded */
val downloaded: Boolean,
/** used to construct pages in the front-end */
val pageCount: Int,
/** total chapter count, used to calculate if there's a next and prev chapter */
val chapterCount: Int?,
/** used to store client specific values */
val meta: Map<String, String>,
public data class GetMangaUnreadChaptersEntry(
public val nodes: List<GetMangaUnreadChaptersNode>,
)
@Serializable
public data class GetMangaUnreadChaptersNode(
public val id: Int,
public val chapterNumber: Double,
)
@Serializable
public data class GetMangaUnreadChaptersResult(
public val data: GetMangaUnreadChaptersData,
)
@Serializable
public data class GetMangaUnreadChaptersData(
@SerialName("chapters")
public val entry: GetMangaUnreadChaptersEntry,
)

View File

@@ -140,7 +140,7 @@ class ExtensionManager(
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
withUIContext { context.toast(MR.strings.extension_api_error) }
emptyList()
return
}
enableAdditionalSubLanguages(extensions)

View File

@@ -1,27 +1,73 @@
package eu.kanade.tachiyomi.extension.installer
import android.app.Service
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Process
import android.os.IBinder
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.getUriSize
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.app.shizuku.IShellInterface
import mihon.app.shizuku.ShellInterface
import rikka.shizuku.Shizuku
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
import java.io.BufferedReader
import java.io.InputStream
class ShizukuInstaller(private val service: Service) : Installer(service) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var shellInterface: IShellInterface? = null
private val shizukuArgs by lazy {
Shizuku.UserServiceArgs(
ComponentName(service, ShellInterface::class.java),
)
.tag("shizuku_service")
.processNameSuffix("shizuku_service")
.debuggable(BuildConfig.DEBUG)
.daemon(false)
}
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
shellInterface = IShellInterface.Stub.asInterface(service)
ready = true
checkQueue()
}
override fun onServiceDisconnected(name: ComponentName?) {
shellInterface = null
}
}
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, Int.MIN_VALUE)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
if (status == PackageInstaller.STATUS_SUCCESS) {
continueQueue(InstallStep.Installed)
} else {
logcat(LogPriority.ERROR) { "Failed to install extension $packageName: $message" }
continueQueue(InstallStep.Error)
}
}
}
private val shizukuDeadListener = Shizuku.OnBinderDeadListener {
logcat { "Shizuku was killed prematurely" }
service.stopSelf()
@@ -31,8 +77,8 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) {
if (grantResult == PackageManager.PERMISSION_GRANTED) {
ready = true
checkQueue()
Shizuku.bindUserService(shizukuArgs, connection)
} else {
service.stopSelf()
}
@@ -41,40 +87,34 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
}
}
fun initShizuku() {
if (ready) return
if (!Shizuku.pingBinder()) {
logcat(LogPriority.ERROR) { "Shizuku is not ready to use" }
service.toast(MR.strings.ext_installer_shizuku_stopped)
service.stopSelf()
return
}
if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
Shizuku.bindUserService(shizukuArgs, connection)
} else {
Shizuku.addRequestPermissionResultListener(shizukuPermissionListener)
Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
}
}
override var ready = false
override fun processEntry(entry: Entry) {
super.processEntry(entry)
scope.launch {
var sessionId: String? = null
try {
val size = service.getUriSize(entry.uri) ?: throw IllegalStateException()
service.contentResolver.openInputStream(entry.uri)!!.use {
val userId = Process.myUserHandle().hashCode()
val createCommand = "pm install-create --user $userId -r -i ${service.packageName} -S $size"
val createResult = exec(createCommand)
sessionId = SESSION_ID_REGEX.find(createResult.out)?.value
?: throw RuntimeException("Failed to create install session")
val writeResult = exec("pm install-write -S $size $sessionId base -", it)
if (writeResult.resultCode != 0) {
throw RuntimeException("Failed to write APK to session $sessionId")
}
val commitResult = exec("pm install-commit $sessionId")
if (commitResult.resultCode != 0) {
throw RuntimeException("Failed to commit install session $sessionId")
}
continueQueue(InstallStep.Installed)
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
if (sessionId != null) {
exec("pm install-abandon $sessionId")
}
continueQueue(InstallStep.Error)
}
try {
shellInterface?.install(
service.contentResolver.openAssetFileDescriptor(entry.uri, "r"),
)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
continueQueue(InstallStep.Error)
}
}
@@ -84,41 +124,26 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
override fun onDestroy() {
Shizuku.removeBinderDeadListener(shizukuDeadListener)
Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener)
Shizuku.unbindUserService(shizukuArgs, connection, true)
service.unregisterReceiver(receiver)
logcat { "ShizukuInstaller destroy" }
scope.cancel()
super.onDestroy()
}
private fun exec(command: String, stdin: InputStream? = null): ShellResult {
@Suppress("DEPRECATION")
val process = Shizuku.newProcess(arrayOf("sh", "-c", command), null, null)
if (stdin != null) {
process.outputStream.use { stdin.copyTo(it) }
}
val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
val resultCode = process.waitFor()
return ShellResult(resultCode, output)
}
private data class ShellResult(val resultCode: Int, val out: String)
init {
Shizuku.addBinderDeadListener(shizukuDeadListener)
ready = if (Shizuku.pingBinder()) {
if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
true
} else {
Shizuku.addRequestPermissionResultListener(shizukuPermissionListener)
Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
false
}
} else {
logcat(LogPriority.ERROR) { "Shizuku is not ready to use" }
service.toast(MR.strings.ext_installer_shizuku_stopped)
service.stopSelf()
false
}
ContextCompat.registerReceiver(
service,
receiver,
IntentFilter(ACTION_INSTALL_RESULT),
ContextCompat.RECEIVER_EXPORTED,
)
initShizuku()
}
}
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 14045
private val SESSION_ID_REGEX = Regex("(?<=\\[).+?(?=])")
const val ACTION_INSTALL_RESULT = "${BuildConfig.APPLICATION_ID}.ACTION_INSTALL_RESULT"

View File

@@ -169,6 +169,7 @@ class ExtensionsScreenModel(
fun cancelInstallUpdateExtension(extension: Extension) {
extensionManager.cancelInstallUpdateExtension(extension)
removeDownloadState(extension)
}
private fun addDownloadState(extension: Extension, installStep: InstallStep) {

View File

@@ -1,10 +1,8 @@
package eu.kanade.tachiyomi.ui.home
import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
@@ -24,19 +22,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.lerp
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
@@ -56,7 +48,6 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import soup.compose.material.motion.MotionConstants
import soup.compose.material.motion.animation.materialFadeThroughIn
import soup.compose.material.motion.animation.materialFadeThroughOut
import tachiyomi.domain.library.service.LibraryPreferences
@@ -65,10 +56,8 @@ import tachiyomi.presentation.core.components.material.NavigationBar
import tachiyomi.presentation.core.components.material.NavigationRail
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.util.PredictiveBack
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import kotlin.coroutines.cancellation.CancellationException
object HomeScreen : Screen() {
@@ -93,8 +82,6 @@ object HomeScreen : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
var scale by remember { mutableFloatStateOf(1f) }
TabNavigator(
tab = LibraryTab,
key = TabNavigatorKey,
@@ -134,11 +121,7 @@ object HomeScreen : Screen() {
Box(
modifier = Modifier
.padding(contentPadding)
.consumeWindowInsets(contentPadding)
.graphicsLayer {
scaleX = scale
scaleY = scale
},
.consumeWindowInsets(contentPadding),
) {
AnimatedContent(
targetState = tabNavigator.current,
@@ -158,31 +141,7 @@ object HomeScreen : Screen() {
val goToLibraryTab = { tabNavigator.current = LibraryTab }
var handlingBack by remember { mutableStateOf(false) }
PredictiveBackHandler(
enabled = handlingBack || tabNavigator.current::class != LibraryTab::class,
) { progress ->
handlingBack = true
val currentTab = tabNavigator.current
try {
progress.collect { backEvent ->
scale = lerp(1f, 0.92f, PredictiveBack.transform(backEvent.progress))
tabNavigator.current = if (backEvent.progress > 0.25f) TABS[0] else currentTab
}
goToLibraryTab()
} catch (e: CancellationException) {
tabNavigator.current = currentTab
} finally {
animate(
initialValue = scale,
targetValue = 1f,
animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration),
) { value, _ ->
scale = value
}
handlingBack = false
}
}
BackHandler(enabled = tabNavigator.current != LibraryTab, onBack = goToLibraryTab)
LaunchedEffect(Unit) {
launch {

View File

@@ -484,6 +484,7 @@ class LibraryScreenModel(
downloadManager.isChapterDownloaded(
chapter.name,
chapter.scanlator,
chapter.url,
manga.title,
manga.source,
)
@@ -499,8 +500,9 @@ class LibraryScreenModel(
* Marks mangas' chapters read status.
*/
fun markReadSelection(read: Boolean) {
val selection = state.value.selectedManga
screenModelScope.launchNonCancellable {
state.value.selectedManga.forEach { manga ->
selection.forEach { manga ->
setReadStatus.await(
manga = manga,
read = read,
@@ -573,7 +575,7 @@ class LibraryScreenModel(
fun getRandomLibraryItemForCurrentCategory(): LibraryItem? {
val state = state.value
return state.getItemsForCategoryId(state.activeCategory.id).randomOrNull()
return state.getItemsForCategoryId(state.activeCategory?.id).randomOrNull()
}
fun showSettingsDialog() {
@@ -631,7 +633,7 @@ class LibraryScreenModel(
lastSelectionCategory = null
mutableState.update { state ->
val newSelection = state.selection.mutate { list ->
state.getItemsForCategoryId(state.activeCategory.id).map { it.id }.let(list::addAll)
state.getItemsForCategoryId(state.activeCategory?.id).map { it.id }.let(list::addAll)
}
state.copy(selection = newSelection)
}
@@ -641,7 +643,7 @@ class LibraryScreenModel(
lastSelectionCategory = null
mutableState.update { state ->
val newSelection = state.selection.mutate { list ->
val itemIds = state.getItemsForCategoryId(state.activeCategory.id).fastMap { it.id }
val itemIds = state.getItemsForCategoryId(state.activeCategory?.id).fastMap { it.id }
val (toRemove, toAdd) = itemIds.partition { it in list }
list.removeAll(toRemove)
list.addAll(toAdd)
@@ -756,7 +758,7 @@ class LibraryScreenModel(
maximumValue = displayedCategories.lastIndex.coerceAtLeast(0),
)
val activeCategory: Category by lazy { displayedCategories[coercedActiveCategoryIndex] }
val activeCategory: Category? = displayedCategories.getOrNull(coercedActiveCategoryIndex)
val isLibraryEmpty = libraryData.favorites.isEmpty()
@@ -764,7 +766,8 @@ class LibraryScreenModel(
val selectedManga by lazy { selection.mapNotNull { libraryData.favoritesById[it]?.libraryManga?.manga } }
fun getItemsForCategoryId(categoryId: Long): List<LibraryItem> {
fun getItemsForCategoryId(categoryId: Long?): List<LibraryItem> {
if (categoryId == null) return emptyList()
val category = displayedCategories.find { it.id == categoryId } ?: return emptyList()
return getItemsForCategory(category)
}

View File

@@ -527,7 +527,13 @@ class MangaScreenModel(
val downloaded = if (isLocal) {
true
} else {
downloadManager.isChapterDownloaded(chapter.name, chapter.scanlator, manga.title, manga.source)
downloadManager.isChapterDownloaded(
chapter.name,
chapter.scanlator,
chapter.url,
manga.title,
manga.source,
)
}
val downloadState = when {
activeDownload != null -> activeDownload.status

View File

@@ -54,6 +54,7 @@ import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
import eu.kanade.tachiyomi.util.lang.toLocalDate
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
@@ -84,7 +85,6 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
data class TrackInfoDialogHomeScreen(
@@ -220,7 +220,7 @@ data class TrackInfoDialogHomeScreen(
try {
val matchResult = item.tracker.match(manga) ?: throw Exception()
item.tracker.register(matchResult, mangaId)
} catch (e: Exception) {
} catch (_: Exception) {
withUIContext { Injekt.get<Application>().toast(MR.strings.error_no_match) }
}
}
@@ -446,56 +446,46 @@ private data class TrackDateSelectorScreen(
@Transient
private val selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
val dateToCheck = Instant.ofEpochMilli(utcTimeMillis)
.atZone(ZoneOffset.systemDefault())
.toLocalDate()
val targetDate = Instant.ofEpochMilli(utcTimeMillis).toLocalDate(ZoneOffset.UTC)
if (dateToCheck > LocalDate.now()) {
// Disallow future dates
return false
}
// Disallow future dates
if (targetDate > LocalDate.now(ZoneOffset.UTC)) return false
return if (start && track.finishDate > 0) {
// Disallow start date to be set later than finish date
val dateFinished = Instant.ofEpochMilli(track.finishDate)
.atZone(ZoneId.systemDefault())
.toLocalDate()
dateToCheck <= dateFinished
} else if (!start && track.startDate > 0) {
// Disallow end date to be set earlier than start date
val dateStarted = Instant.ofEpochMilli(track.startDate)
.atZone(ZoneId.systemDefault())
.toLocalDate()
dateToCheck >= dateStarted
} else {
// Nothing set before
true
return when {
// Disallow setting start date after finish date
start && track.finishDate > 0 -> {
val finishDate = Instant.ofEpochMilli(track.finishDate).toLocalDate(ZoneOffset.UTC)
targetDate <= finishDate
}
// Disallow setting finish date before start date
!start && track.startDate > 0 -> {
val startDate = Instant.ofEpochMilli(track.startDate).toLocalDate(ZoneOffset.UTC)
startDate <= targetDate
}
else -> {
true
}
}
}
override fun isSelectableYear(year: Int): Boolean {
if (year > LocalDate.now().year) {
// Disallow future dates
return false
}
// Disallow future years
if (year > LocalDate.now(ZoneOffset.UTC).year) return false
return if (start && track.finishDate > 0) {
// Disallow start date to be set later than finish date
val dateFinished = Instant.ofEpochMilli(track.finishDate)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.year
year <= dateFinished
} else if (!start && track.startDate > 0) {
// Disallow end date to be set earlier than start date
val dateStarted = Instant.ofEpochMilli(track.startDate)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.year
year >= dateStarted
} else {
// Nothing set before
true
return when {
// Disallow setting start year after finish year
start && track.finishDate > 0 -> {
val finishDate = Instant.ofEpochMilli(track.finishDate).toLocalDate(ZoneOffset.UTC)
year <= finishDate.year
}
// Disallow setting finish year before start year
!start && track.startDate > 0 -> {
val startDate = Instant.ofEpochMilli(track.startDate).toLocalDate(ZoneOffset.UTC)
startDate.year <= year
}
else -> {
true
}
}
}
}

View File

@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.reader
import android.annotation.SuppressLint
import android.app.Activity
import android.app.assist.AssistContent
import android.content.ClipData
import android.content.ClipboardManager
@@ -16,40 +15,45 @@ import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.View.LAYER_TYPE_HARDWARE
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.transition.doOnEnd
import androidx.core.view.WindowCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.google.android.material.elevation.SurfaceColors
import com.google.android.material.transition.platform.MaterialContainerTransform
import com.hippo.unifile.UniFile
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.core.util.ifSourcesLoaded
import eu.kanade.domain.base.BasePreferences
import eu.kanade.presentation.reader.DisplayRefreshHost
import eu.kanade.presentation.reader.OrientationSelectDialog
import eu.kanade.presentation.reader.PageIndicatorText
import eu.kanade.presentation.reader.ReaderContentOverlay
import eu.kanade.presentation.reader.ReaderPageActionsDialog
import eu.kanade.presentation.reader.ReaderPageIndicator
import eu.kanade.presentation.reader.ReadingModeSelectDialog
import eu.kanade.presentation.reader.appbars.ReaderAppBars
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
@@ -73,18 +77,17 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.hasDisplayCutout
import eu.kanade.tachiyomi.util.system.isNightMode
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.setComposeContent
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
@@ -121,8 +124,6 @@ class ReaderActivity : BaseActivity() {
val viewModel by viewModels<ReaderViewModel>()
private var assistUrl: String? = null
private val hasCutout by lazy { hasDisplayCutout() }
/**
* Configuration at reader level, like background color or forced orientation.
*/
@@ -132,7 +133,7 @@ class ReaderActivity : BaseActivity() {
private var readingModeToast: Toast? = null
private val displayRefreshHost = DisplayRefreshHost()
private val windowInsetsController by lazy { WindowInsetsControllerCompat(window, binding.root) }
private val windowInsetsController by lazy { WindowInsetsControllerCompat(window, window.decorView) }
private var loadingIndicator: ReaderProgressIndicator? = null
@@ -146,7 +147,7 @@ class ReaderActivity : BaseActivity() {
registerSecureActivity(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
overrideActivityTransition(
Activity.OVERRIDE_TRANSITION_OPEN,
OVERRIDE_TRANSITION_OPEN,
R.anim.shared_axis_x_push_enter,
R.anim.shared_axis_x_push_exit,
)
@@ -155,10 +156,17 @@ class ReaderActivity : BaseActivity() {
overridePendingTransition(R.anim.shared_axis_x_push_enter, R.anim.shared_axis_x_push_exit)
}
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
super.onCreate(savedInstanceState)
binding = ReaderActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.setComposeOverlay()
if (viewModel.needsInit()) {
val manga = intent.extras?.getLong("manga", -1) ?: -1L
@@ -181,7 +189,7 @@ class ReaderActivity : BaseActivity() {
}
config = ReaderConfig()
initializeMenu()
setMenuVisibility(viewModel.state.value.menuVisible)
// Finish when incognito mode is disabled
preferences.incognitoMode().changes()
@@ -238,6 +246,92 @@ class ReaderActivity : BaseActivity() {
.launchIn(lifecycleScope)
}
private fun ReaderActivityBinding.setComposeOverlay(): Unit = composeOverlay.setComposeContent {
val state by viewModel.state.collectAsState()
val showPageNumber by readerPreferences.showPageNumber().collectAsState()
val settingsScreenModel = remember {
ReaderSettingsScreenModel(
readerState = viewModel.state,
onChangeReadingMode = viewModel::setMangaReadingMode,
onChangeOrientation = viewModel::setMangaOrientationType,
)
}
Box(modifier = Modifier.fillMaxSize()) {
if (!state.menuVisible && showPageNumber) {
ReaderPageIndicator(
currentPage = state.currentPage,
totalPages = state.totalPages,
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding(),
)
}
ContentOverlay(state = state)
AppBars(state = state)
}
val onDismissRequest = viewModel::closeDialog
when (state.dialog) {
is ReaderViewModel.Dialog.Loading -> {
AlertDialog(
onDismissRequest = {},
confirmButton = {},
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator()
Text(stringResource(MR.strings.loading))
}
},
)
}
is ReaderViewModel.Dialog.Settings -> {
ReaderSettingsDialog(
onDismissRequest = onDismissRequest,
onShowMenus = { setMenuVisibility(true) },
onHideMenus = { setMenuVisibility(false) },
screenModel = settingsScreenModel,
)
}
is ReaderViewModel.Dialog.ReadingModeSelect -> {
ReadingModeSelectDialog(
onDismissRequest = onDismissRequest,
screenModel = settingsScreenModel,
onChange = { stringRes ->
menuToggleToast?.cancel()
if (!readerPreferences.showReadingMode().get()) {
menuToggleToast = toast(stringRes)
}
},
)
}
is ReaderViewModel.Dialog.OrientationModeSelect -> {
OrientationSelectDialog(
onDismissRequest = onDismissRequest,
screenModel = settingsScreenModel,
onChange = { stringRes ->
menuToggleToast?.cancel()
menuToggleToast = toast(stringRes)
},
)
}
is ReaderViewModel.Dialog.PageActions -> {
ReaderPageActionsDialog(
onDismissRequest = onDismissRequest,
onSetAsCover = viewModel::setAsCover,
onShare = viewModel::shareImage,
onSave = viewModel::saveImage,
)
}
null -> {}
}
}
/**
* Called when the activity is destroyed. Cleans up the viewer, configuration and any view.
*/
@@ -289,7 +383,7 @@ class ReaderActivity : BaseActivity() {
super.finish()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
overrideActivityTransition(
Activity.OVERRIDE_TRANSITION_CLOSE,
OVERRIDE_TRANSITION_CLOSE,
R.anim.shared_axis_x_pop_enter,
R.anim.shared_axis_x_pop_exit,
)
@@ -327,180 +421,82 @@ class ReaderActivity : BaseActivity() {
return handled || super.dispatchGenericMotionEvent(event)
}
/**
* Initializes the reader menu. It sets up click listeners and the initial visibility.
*/
private fun initializeMenu() {
binding.pageNumber.setComposeContent {
val state by viewModel.state.collectAsState()
val showPageNumber by viewModel.readerPreferences.showPageNumber().collectAsState()
@Composable
private fun ContentOverlay(state: ReaderViewModel.State) {
val flashOnPageChange by readerPreferences.flashOnPageChange().collectAsState()
if (!state.menuVisible && showPageNumber) {
PageIndicatorText(
currentPage = state.currentPage,
totalPages = state.totalPages,
)
}
val colorOverlayEnabled by readerPreferences.colorFilter().collectAsState()
val colorOverlay by readerPreferences.colorFilterValue().collectAsState()
val colorOverlayMode by readerPreferences.colorFilterMode().collectAsState()
val colorOverlayBlendMode = remember(colorOverlayMode) {
ReaderPreferences.ColorFilterMode.getOrNull(colorOverlayMode)?.second
}
binding.dialogRoot.setComposeContent {
val state by viewModel.state.collectAsState()
val settingsScreenModel = remember {
ReaderSettingsScreenModel(
readerState = viewModel.state,
hasDisplayCutout = hasCutout,
onChangeReadingMode = viewModel::setMangaReadingMode,
onChangeOrientation = viewModel::setMangaOrientationType,
)
}
if (!ifSourcesLoaded()) {
return@setComposeContent
}
val isHttpSource = viewModel.getSource() is HttpSource
val isFullscreen by readerPreferences.fullscreen().collectAsState()
val flashOnPageChange by readerPreferences.flashOnPageChange().collectAsState()
val colorOverlayEnabled by readerPreferences.colorFilter().collectAsState()
val colorOverlay by readerPreferences.colorFilterValue().collectAsState()
val colorOverlayMode by readerPreferences.colorFilterMode().collectAsState()
val colorOverlayBlendMode = remember(colorOverlayMode) {
ReaderPreferences.ColorFilterMode.getOrNull(colorOverlayMode)?.second
}
val cropBorderPaged by readerPreferences.cropBorders().collectAsState()
val cropBorderWebtoon by readerPreferences.cropBordersWebtoon().collectAsState()
val isPagerType = ReadingMode.isPagerType(viewModel.getMangaReadingMode())
val cropEnabled = if (isPagerType) cropBorderPaged else cropBorderWebtoon
ReaderContentOverlay(
brightness = state.brightnessOverlayValue,
color = colorOverlay.takeIf { colorOverlayEnabled },
colorBlendMode = colorOverlayBlendMode,
)
ReaderAppBars(
visible = state.menuVisible,
fullscreen = isFullscreen,
mangaTitle = state.manga?.title,
chapterTitle = state.currentChapter?.chapter?.name,
navigateUp = onBackPressedDispatcher::onBackPressed,
onClickTopAppBar = ::openMangaScreen,
bookmarked = state.bookmarked,
onToggleBookmarked = viewModel::toggleChapterBookmark,
onOpenInWebView = ::openChapterInWebView.takeIf { isHttpSource },
onOpenInBrowser = ::openChapterInBrowser.takeIf { isHttpSource },
onShare = ::shareChapter.takeIf { isHttpSource },
viewer = state.viewer,
onNextChapter = ::loadNextChapter,
enabledNext = state.viewerChapters?.nextChapter != null,
onPreviousChapter = ::loadPreviousChapter,
enabledPrevious = state.viewerChapters?.prevChapter != null,
currentPage = state.currentPage,
totalPages = state.totalPages,
onPageIndexChange = {
isScrollingThroughPages = true
moveToPageIndex(it)
},
readingMode = ReadingMode.fromPreference(
viewModel.getMangaReadingMode(resolveDefault = false),
),
onClickReadingMode = viewModel::openReadingModeSelectDialog,
orientation = ReaderOrientation.fromPreference(
viewModel.getMangaOrientation(resolveDefault = false),
),
onClickOrientation = viewModel::openOrientationModeSelectDialog,
cropEnabled = cropEnabled,
onClickCropBorder = {
val enabled = viewModel.toggleCropBorders()
menuToggleToast?.cancel()
menuToggleToast = toast(if (enabled) MR.strings.on else MR.strings.off)
},
onClickSettings = viewModel::openSettingsDialog,
)
if (flashOnPageChange) {
DisplayRefreshHost(
hostState = displayRefreshHost,
)
}
val onDismissRequest = viewModel::closeDialog
when (state.dialog) {
is ReaderViewModel.Dialog.Loading -> {
AlertDialog(
onDismissRequest = {},
confirmButton = {},
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator()
Text(stringResource(MR.strings.loading))
}
},
)
}
is ReaderViewModel.Dialog.Settings -> {
ReaderSettingsDialog(
onDismissRequest = onDismissRequest,
onShowMenus = { setMenuVisibility(true) },
onHideMenus = { setMenuVisibility(false) },
screenModel = settingsScreenModel,
)
}
is ReaderViewModel.Dialog.ReadingModeSelect -> {
ReadingModeSelectDialog(
onDismissRequest = onDismissRequest,
screenModel = settingsScreenModel,
onChange = { stringRes ->
menuToggleToast?.cancel()
if (!readerPreferences.showReadingMode().get()) {
menuToggleToast = toast(stringRes)
}
},
)
}
is ReaderViewModel.Dialog.OrientationModeSelect -> {
OrientationSelectDialog(
onDismissRequest = onDismissRequest,
screenModel = settingsScreenModel,
onChange = { stringRes ->
menuToggleToast?.cancel()
menuToggleToast = toast(stringRes)
},
)
}
is ReaderViewModel.Dialog.PageActions -> {
ReaderPageActionsDialog(
onDismissRequest = onDismissRequest,
onSetAsCover = viewModel::setAsCover,
onShare = viewModel::shareImage,
onSave = viewModel::saveImage,
)
}
null -> {}
}
}
val toolbarColor = ColorUtils.setAlphaComponent(
SurfaceColors.SURFACE_2.getColor(this),
if (isNightMode()) 230 else 242, // 90% dark 95% light
ReaderContentOverlay(
brightness = state.brightnessOverlayValue,
color = colorOverlay.takeIf { colorOverlayEnabled },
colorBlendMode = colorOverlayBlendMode,
)
@Suppress("DEPRECATION")
window.statusBarColor = toolbarColor
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
@Suppress("DEPRECATION")
window.navigationBarColor = toolbarColor
if (flashOnPageChange) {
DisplayRefreshHost(hostState = displayRefreshHost)
}
}
@Composable
fun AppBars(state: ReaderViewModel.State) {
if (!ifSourcesLoaded()) {
return
}
// Set initial visibility
setMenuVisibility(viewModel.state.value.menuVisible)
val isHttpSource = viewModel.getSource() is HttpSource
val cropBorderPaged by readerPreferences.cropBorders().collectAsState()
val cropBorderWebtoon by readerPreferences.cropBordersWebtoon().collectAsState()
val isPagerType = ReadingMode.isPagerType(viewModel.getMangaReadingMode())
val cropEnabled = if (isPagerType) cropBorderPaged else cropBorderWebtoon
ReaderAppBars(
visible = state.menuVisible,
mangaTitle = state.manga?.title,
chapterTitle = state.currentChapter?.chapter?.name,
navigateUp = onBackPressedDispatcher::onBackPressed,
onClickTopAppBar = ::openMangaScreen,
bookmarked = state.bookmarked,
onToggleBookmarked = viewModel::toggleChapterBookmark,
onOpenInWebView = ::openChapterInWebView.takeIf { isHttpSource },
onOpenInBrowser = ::openChapterInBrowser.takeIf { isHttpSource },
onShare = ::shareChapter.takeIf { isHttpSource },
viewer = state.viewer,
onNextChapter = ::loadNextChapter,
enabledNext = state.viewerChapters?.nextChapter != null,
onPreviousChapter = ::loadPreviousChapter,
enabledPrevious = state.viewerChapters?.prevChapter != null,
currentPage = state.currentPage,
totalPages = state.totalPages,
onPageIndexChange = {
isScrollingThroughPages = true
moveToPageIndex(it)
},
readingMode = ReadingMode.fromPreference(
viewModel.getMangaReadingMode(resolveDefault = false),
),
onClickReadingMode = viewModel::openReadingModeSelectDialog,
orientation = ReaderOrientation.fromPreference(
viewModel.getMangaOrientation(resolveDefault = false),
),
onClickOrientation = viewModel::openOrientationModeSelectDialog,
cropEnabled = cropEnabled,
onClickCropBorder = {
val enabled = viewModel.toggleCropBorders()
menuToggleToast?.cancel()
menuToggleToast = toast(if (enabled) MR.strings.on else MR.strings.off)
},
onClickSettings = viewModel::openSettingsDialog,
)
}
/**
@@ -510,13 +506,8 @@ class ReaderActivity : BaseActivity() {
viewModel.showMenus(visible)
if (visible) {
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
} else {
if (readerPreferences.fullscreen().get()) {
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
windowInsetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else if (readerPreferences.fullscreen().get()) {
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
}
}
@@ -542,7 +533,7 @@ class ReaderActivity : BaseActivity() {
binding.viewerContainer.removeAllViews()
}
viewModel.onViewerLoaded(newViewer)
updateViewerInset(readerPreferences.fullscreen().get())
updateViewerInset(readerPreferences.fullscreen().get(), readerPreferences.drawUnderCutout().get())
binding.viewerContainer.addView(newViewer.getView())
if (readerPreferences.showReadingMode().get()) {
@@ -593,7 +584,7 @@ class ReaderActivity : BaseActivity() {
try {
readingModeToast?.cancel()
readingModeToast = toast(ReadingMode.fromPreference(mode).stringRes)
} catch (e: ArrayIndexOutOfBoundsException) {
} catch (_: ArrayIndexOutOfBoundsException) {
logcat(LogPriority.ERROR) { "Unknown reading mode: $mode" }
}
}
@@ -785,16 +776,31 @@ class ReaderActivity : BaseActivity() {
/**
* Updates viewer inset depending on fullscreen reader preferences.
*/
private fun updateViewerInset(fullscreen: Boolean) {
viewModel.state.value.viewer?.getView()?.applyInsetter {
if (!fullscreen) {
type(navigationBars = true, statusBars = true) {
padding()
}
}
private fun updateViewerInset(fullscreen: Boolean, drawUnderCutout: Boolean) {
val view = viewModel.state.value.viewer?.getView() ?: return
view.applyInsetsPadding(ViewCompat.getRootWindowInsets(view), fullscreen, drawUnderCutout)
ViewCompat.setOnApplyWindowInsetsListener(view) { view, windowInsets ->
view.applyInsetsPadding(windowInsets, fullscreen, drawUnderCutout)
windowInsets
}
}
private fun View.applyInsetsPadding(
windowInsets: WindowInsetsCompat?,
fullscreen: Boolean,
drawUnderCutout: Boolean,
) {
val insets = when {
!fullscreen -> windowInsets?.getInsets(WindowInsetsCompat.Type.systemBars())
!drawUnderCutout -> windowInsets?.getInsets(WindowInsetsCompat.Type.displayCutout())
else -> null
}
?: Insets.NONE
setPadding(insets.left, insets.top, insets.right, insets.bottom)
}
/**
* Class that handles the user preferences of the reader.
*/
@@ -847,10 +853,6 @@ class ReaderActivity : BaseActivity() {
.onEach { setDisplayProfile(it) }
.launchIn(lifecycleScope)
readerPreferences.cutoutShort().changes()
.onEach(::setCutoutShort)
.launchIn(lifecycleScope)
readerPreferences.keepScreenOn().changes()
.onEach(::setKeepScreenOn)
.launchIn(lifecycleScope)
@@ -859,14 +861,21 @@ class ReaderActivity : BaseActivity() {
.onEach(::setCustomBrightness)
.launchIn(lifecycleScope)
merge(readerPreferences.grayscale().changes(), readerPreferences.invertedColors().changes())
.onEach { setLayerPaint(readerPreferences.grayscale().get(), readerPreferences.invertedColors().get()) }
combine(
readerPreferences.grayscale().changes(),
readerPreferences.invertedColors().changes(),
) { grayscale, invertedColors -> grayscale to invertedColors }
.onEach { (grayscale, invertedColors) ->
setLayerPaint(grayscale, invertedColors)
}
.launchIn(lifecycleScope)
readerPreferences.fullscreen().changes()
.onEach {
WindowCompat.setDecorFitsSystemWindows(window, !it)
updateViewerInset(it)
combine(
readerPreferences.fullscreen().changes(),
readerPreferences.drawUnderCutout().changes(),
) { fullscreen, drawUnderCutout -> fullscreen to drawUnderCutout }
.onEach { (fullscreen, drawUnderCutout) ->
updateViewerInset(fullscreen, drawUnderCutout)
}
.launchIn(lifecycleScope)
}
@@ -901,18 +910,6 @@ class ReaderActivity : BaseActivity() {
}
}
private fun setCutoutShort(enabled: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
window.attributes.layoutInDisplayCutoutMode = when (enabled) {
true -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
false -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
}
// Trigger relayout
setMenuVisibility(viewModel.state.value.menuVisible)
}
/**
* Sets the keep screen on mode according to [enabled].
*/

View File

@@ -38,7 +38,6 @@ import eu.kanade.tachiyomi.util.chapter.filterDownloaded
import eu.kanade.tachiyomi.util.chapter.removeDuplicates
import eu.kanade.tachiyomi.util.editCover
import eu.kanade.tachiyomi.util.lang.byteSize
import eu.kanade.tachiyomi.util.lang.takeBytes
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir
import kotlinx.coroutines.CancellationException
@@ -175,6 +174,7 @@ class ReaderViewModel @JvmOverloads constructor(
!downloadManager.isChapterDownloaded(
it.name,
it.scanlator,
it.url,
manga.title,
manga.source,
)
@@ -184,6 +184,7 @@ class ReaderViewModel @JvmOverloads constructor(
downloadManager.isChapterDownloaded(
it.name,
it.scanlator,
it.url,
manga.title,
manga.source,
)
@@ -397,6 +398,7 @@ class ReaderViewModel @JvmOverloads constructor(
val isDownloaded = downloadManager.isChapterDownloaded(
dbChapter.name,
dbChapter.scanlator,
dbChapter.url,
manga.title,
manga.source,
skipCache = true,
@@ -473,6 +475,7 @@ class ReaderViewModel @JvmOverloads constructor(
val isNextChapterDownloaded = downloadManager.isChapterDownloaded(
nextChapter.name,
nextChapter.scanlator,
nextChapter.url,
manga.title,
manga.source,
)
@@ -757,7 +760,8 @@ class ReaderViewModel @JvmOverloads constructor(
val chapter = page.chapter.chapter
val filenameSuffix = " - ${page.number}"
return DiskUtil.buildValidFilename(
"${manga.title} - ${chapter.name}".takeBytes(DiskUtil.MAX_FILE_NAME_BYTES - filenameSuffix.byteSize()),
"${manga.title} - ${chapter.name}",
DiskUtil.MAX_FILE_NAME_BYTES - filenameSuffix.byteSize(),
) + filenameSuffix
}

View File

@@ -80,6 +80,7 @@ class ChapterLoader(
val isDownloaded = downloadManager.isChapterDownloaded(
dbChapter.name,
dbChapter.scanlator,
dbChapter.url,
manga.title,
manga.source,
skipCache = true,

View File

@@ -33,7 +33,13 @@ internal class DownloadPageLoader(
override suspend fun getPages(): List<ReaderPage> {
val dbChapter = chapter.chapter
val chapterPath = downloadProvider.findChapterDir(dbChapter.name, dbChapter.scanlator, manga.title, source)
val chapterPath = downloadProvider.findChapterDir(
dbChapter.name,
dbChapter.scanlator,
dbChapter.url,
manga.title,
source,
)
return if (chapterPath?.isFile == true) {
getPagesFromArchive(chapterPath)
} else {

View File

@@ -31,7 +31,7 @@ class ReaderPreferences(
fun fullscreen() = preferenceStore.getBoolean("fullscreen", true)
fun cutoutShort() = preferenceStore.getBoolean("cutout_short", true)
fun drawUnderCutout() = preferenceStore.getBoolean("cutout_short", true)
fun keepScreenOn() = preferenceStore.getBoolean("pref_keep_screen_on_key", false)

View File

@@ -13,7 +13,6 @@ import uy.kohesive.injekt.api.get
class ReaderSettingsScreenModel(
readerState: StateFlow<ReaderViewModel.State>,
val hasDisplayCutout: Boolean,
val onChangeReadingMode: (ReadingMode) -> Unit,
val onChangeOrientation: (ReaderOrientation) -> Unit,
val preferences: ReaderPreferences = Injekt.get(),

View File

@@ -37,6 +37,7 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
downloadManager.isChapterDownloaded(
chapterName = goingToChapter.name,
chapterScanlator = goingToChapter.scanlator,
chapterUrl = goingToChapter.url,
mangaTitle = manga.title,
sourceId = manga.source,
skipCache = true,

View File

@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.HapticFeedbackConstants
@@ -34,6 +35,10 @@ class WebtoonRecyclerView @JvmOverloads constructor(
private var firstVisibleItemPosition = 0
private var lastVisibleItemPosition = 0
private var currentScale = DEFAULT_RATE
private var isScrolling = false
private var hasTappedWhileScrolling = false
var zoomOutDisabled = false
set(value) {
field = value
@@ -62,7 +67,11 @@ class WebtoonRecyclerView @JvmOverloads constructor(
super.onMeasure(widthSpec, heightSpec)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(e: MotionEvent): Boolean {
if (e.actionMasked == MotionEvent.ACTION_DOWN) {
hasTappedWhileScrolling = isScrolling
}
detector.onTouchEvent(e)
return super.onTouchEvent(e)
}
@@ -82,6 +91,7 @@ class WebtoonRecyclerView @JvmOverloads constructor(
val totalItemCount = layoutManager?.itemCount ?: 0
atLastPosition = visibleItemCount > 0 && lastVisibleItemPosition == totalItemCount - 1
atFirstPosition = firstVisibleItemPosition == 0
isScrolling = state != SCROLL_STATE_IDLE
}
private fun getPositionX(positionX: Float): Float {
@@ -215,7 +225,9 @@ class WebtoonRecyclerView @JvmOverloads constructor(
inner class GestureListener : GestureDetectorWithLongTap.Listener() {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
tapListener?.invoke(ev)
if (!hasTappedWhileScrolling) {
tapListener?.invoke(ev)
}
return false
}

View File

@@ -107,6 +107,7 @@ class UpdatesScreenModel(
val downloaded = downloadManager.isChapterDownloaded(
update.chapterName,
update.scanlator,
update.chapterUrl,
update.mangaTitle,
update.sourceId,
)

View File

@@ -15,5 +15,5 @@ fun List<Chapter>.filterDownloaded(manga: Manga): List<Chapter> {
val downloadCache: DownloadCache = Injekt.get()
return filter { downloadCache.isChapterDownloaded(it.name, it.scanlator, manga.title, manga.source, false) }
return filter { downloadCache.isChapterDownloaded(it.name, it.scanlator, it.url, manga.title, manga.source, false) }
}

View File

@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.view.View
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.domain.ui.model.TabletUiMode
import uy.kohesive.injekt.Injekt
@@ -57,11 +58,19 @@ fun Context.isNightMode(): Boolean {
/**
* Checks whether if the device has a display cutout (i.e. notch, camera cutout, etc.).
*
* Only works in Android 9+.
* Only works on Android 9+.
*/
fun Activity.hasDisplayCutout(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
window.decorView.rootWindowInsets?.displayCutout != null
return window.decorView.hasDisplayCutout()
}
/**
* Checks whether if the device has a display cutout (i.e. notch, camera cutout, etc.).
*
* Only works on Android 9+.
*/
fun View.hasDisplayCutout(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && rootWindowInsets?.displayCutout != null
}
/**

View File

@@ -4,11 +4,9 @@ package eu.kanade.tachiyomi.util.view
import android.content.res.Resources
import android.graphics.Rect
import android.os.Build
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.RoundedCorner
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -97,22 +95,3 @@ fun View?.isVisibleOnScreen(): Boolean {
Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels)
return actualPosition.intersect(screen)
}
/**
* Returns window radius (in pixel) applied to this view
*/
fun View.getWindowRadius(): Int {
val rad = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val windowInsets = rootWindowInsets
listOfNotNull(
windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT),
windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT),
windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT),
windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT),
)
.minOfOrNull { it.radius }
} else {
null
}
return rad ?: 0
}

View File

@@ -0,0 +1,176 @@
/*
* Copyright 2024 Mihon Open Source Project
* Copyright 2015-2024 Javier Tomás
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* The file contains code originally licensed under the MIT license:
*
* Copyright (c) 2024 Zachary Wander
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package mihon.app.shizuku
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.pm.PackageInstaller
import android.content.res.AssetFileDescriptor
import android.os.Build
import android.os.IBinder
import android.os.ParcelFileDescriptor
import android.os.UserHandle
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.extension.installer.ACTION_INSTALL_RESULT
import rikka.shizuku.SystemServiceHelper
import java.io.OutputStream
import kotlin.system.exitProcess
class ShellInterface : IShellInterface.Stub() {
private val context = createContext()
private val userId = UserHandle::class.java
.getMethod("myUserId")
.invoke(null) as Int
private val packageName = BuildConfig.APPLICATION_ID
@SuppressLint("PrivateApi")
override fun install(apk: AssetFileDescriptor) {
val pmInterface = Class.forName($$"android.content.pm.IPackageManager$Stub")
.getMethod("asInterface", IBinder::class.java)
.invoke(null, SystemServiceHelper.getSystemService("package"))
val packageInstaller = Class.forName("android.content.pm.IPackageManager")
.getMethod("getPackageInstaller")
.invoke(pmInterface)
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
setInstallerPackageName(packageName)
}
}
val sessionId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
packageInstaller::class.java.getMethod(
"createSession",
PackageInstaller.SessionParams::class.java,
String::class.java,
String::class.java,
Int::class.java,
).invoke(packageInstaller, params, packageName, packageName, userId) as Int
} else {
packageInstaller::class.java.getMethod(
"createSession",
PackageInstaller.SessionParams::class.java,
String::class.java,
Int::class.java,
).invoke(packageInstaller, params, packageName, userId) as Int
}
val session = packageInstaller::class.java
.getMethod("openSession", Int::class.java)
.invoke(packageInstaller, sessionId)
(
session::class.java.getMethod(
"openWrite",
String::class.java,
Long::class.java,
Long::class.java,
).invoke(session, "extension", 0L, apk.length) as ParcelFileDescriptor
).let { fd ->
val revocable = Class.forName("android.os.SystemProperties")
.getMethod("getBoolean", String::class.java, Boolean::class.java)
.invoke(null, "fw.revocable_fd", false) as Boolean
if (revocable) {
ParcelFileDescriptor.AutoCloseOutputStream(fd)
} else {
Class.forName($$"android.os.FileBridge$FileBridgeOutputStream")
.getConstructor(ParcelFileDescriptor::class.java)
.newInstance(fd) as OutputStream
}
}
.use { output ->
apk.createInputStream().use { input -> input.copyTo(output) }
}
val statusIntent = PendingIntent.getBroadcast(
context,
0,
Intent(ACTION_INSTALL_RESULT).setPackage(packageName),
PendingIntent.FLAG_MUTABLE,
)
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
session::class.java.getMethod("commit", IntentSender::class.java, Boolean::class.java)
.invoke(session, statusIntent.intentSender, false)
} else {
session::class.java.getMethod("commit", IntentSender::class.java)
.invoke(session, statusIntent.intentSender)
}
}
override fun destroy() {
exitProcess(0)
}
@SuppressLint("PrivateApi")
private fun createContext(): Context {
val activityThread = Class.forName("android.app.ActivityThread")
val systemMain = activityThread.getMethod("systemMain").invoke(null)
val systemContext = activityThread.getMethod("getSystemContext").invoke(systemMain) as Context
val shellUserHandle = UserHandle::class.java
.getConstructor(Int::class.java)
.newInstance(userId)
val shellContext = systemContext::class.java.getMethod(
"createPackageContextAsUser",
String::class.java,
Int::class.java,
UserHandle::class.java,
).invoke(
systemContext,
"com.android.shell",
Context.CONTEXT_INCLUDE_CODE or Context.CONTEXT_IGNORE_SECURITY,
shellUserHandle,
) as Context
return shellContext.createPackageContext("com.android.shell", 0)
}
}

View File

@@ -89,7 +89,7 @@ class MigrateMangaUseCase(
}
// Update categories
if (MigrationFlag.CHAPTER in flags) {
if (MigrationFlag.CATEGORY in flags) {
val categoryIds = getCategories.await(current.id).map { it.id }
setMangaCategories.await(target.id, categoryIds)
}

View File

@@ -315,13 +315,6 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
private val sourceManager: SourceManager = Injekt.get(),
) : StateScreenModel<ScreenModel.State>(State()) {
init {
screenModelScope.launchIO {
initSources()
mutableState.update { it.copy(isLoading = false) }
}
}
private val sourcesComparator = { includedSources: List<Long> ->
compareBy<MigrationSource>(
{ !it.isSelected },
@@ -330,6 +323,13 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
)
}
init {
screenModelScope.launchIO {
initSources()
mutableState.update { it.copy(isLoading = false) }
}
}
private fun updateSources(save: Boolean = true, action: (List<MigrationSource>) -> List<MigrationSource>) {
mutableState.update { state ->
val updatedSources = action(state.sources)

View File

@@ -12,6 +12,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
@@ -49,9 +50,14 @@ internal fun Screen.MigrateMangaDialog(
) {
val scope = rememberCoroutineScope()
val screenModel = rememberScreenModel { MigrateDialogScreenModel(current, target) }
val screenModel = rememberScreenModel { MigrateDialogScreenModel() }
LaunchedEffect(current, target) {
screenModel.init(current, target)
}
val state by screenModel.state.collectAsState()
if (state.isMigrated) return
if (state.isMigrating) {
LoadingScreen(
modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
@@ -118,15 +124,13 @@ internal fun Screen.MigrateMangaDialog(
}
private class MigrateDialogScreenModel(
private val current: Manga,
private val target: Manga,
private val sourcePreference: SourcePreferences = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val migrateManga: MigrateMangaUseCase = Injekt.get(),
) : StateScreenModel<MigrateDialogScreenModel.State>(State()) {
init {
fun init(current: Manga, target: Manga) {
val applicableFlags = buildList {
MigrationFlag.entries.forEach {
val applicable = when (it) {
@@ -140,7 +144,14 @@ private class MigrateDialogScreenModel(
}
}
val selectedFlags = sourcePreference.migrationFlags().get()
mutableState.update { it.copy(applicableFlags = applicableFlags, selectedFlags = selectedFlags) }
mutableState.update {
it.copy(
current = current,
target = target,
applicableFlags = applicableFlags,
selectedFlags = selectedFlags,
)
}
}
fun toggleSelection(flag: MigrationFlag) {
@@ -153,15 +164,21 @@ private class MigrateDialogScreenModel(
}
suspend fun migrateManga(replace: Boolean) {
sourcePreference.migrationFlags().set(state.value.selectedFlags)
val state = state.value
val current = state.current ?: return
val target = state.target ?: return
sourcePreference.migrationFlags().set(state.selectedFlags)
mutableState.update { it.copy(isMigrating = true) }
migrateManga(current, target, replace)
mutableState.update { it.copy(isMigrating = false) }
mutableState.update { it.copy(isMigrating = false, isMigrated = true) }
}
data class State(
val current: Manga? = null,
val target: Manga? = null,
val applicableFlags: List<MigrationFlag> = emptyList(),
val selectedFlags: Set<MigrationFlag> = emptySet(),
val isMigrating: Boolean = false,
val isMigrated: Boolean = false,
)
}

View File

@@ -44,7 +44,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
@@ -200,10 +199,10 @@ fun MigrationListItem(
.background(
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color(0xAA000000),
1f to MaterialTheme.colorScheme.background,
),
)
.fillMaxHeight(0.33f)
.fillMaxHeight(0.4f)
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
@@ -214,8 +213,7 @@ fun MigrationListItem(
text = manga.title,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
style = MaterialTheme.typography.labelMedium
.merge(shadow = Shadow(color = Color.Black, blurRadius = 4f)),
style = MaterialTheme.typography.labelMedium,
)
BadgeGroup(modifier = Modifier.padding(4.dp)) {
Badge(text = "$chapterCount")

View File

@@ -251,6 +251,7 @@ class MigrationListScreenModel(
} catch (_: Exception) {
}
migratingManga.searchResult.value = result.toSuccessSearchResult()
updateMigrationProgress()
}
}

View File

@@ -95,8 +95,8 @@ abstract class BaseSmartSearchEngine<T>(
}
private fun removeTextInBrackets(text: String, readForward: Boolean): String {
val openingChars = if (readForward) "([<{ " else ")]}>"
val closingChars = if (readForward) ")]}>" else "([<{ "
val openingChars = if (readForward) "([<{" else ")]}>"
val closingChars = if (readForward) ")]}>" else "([<{"
var depth = 0
return buildString {

View File

@@ -13,12 +13,6 @@
android:layout_height="match_parent"
android:descendantFocusability="blocksDescendants" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/page_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal" />
</FrameLayout>
<eu.kanade.tachiyomi.ui.reader.ReaderNavigationOverlayView
@@ -30,7 +24,7 @@
android:visibility="gone" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/dialog_root"
android:id="@+id/compose_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@@ -4,8 +4,8 @@ import org.gradle.api.JavaVersion as GradleJavaVersion
import org.jetbrains.kotlin.gradle.dsl.JvmTarget as KotlinJvmTarget
object AndroidConfig {
const val COMPILE_SDK = 35
const val TARGET_SDK = 34
const val COMPILE_SDK = 36
const val TARGET_SDK = 36
const val MIN_SDK = 26
const val NDK = "27.1.12297006"
const val BUILD_TOOLS = "35.0.1"

View File

@@ -2,6 +2,7 @@ package mihon.core.archive
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.parser.Parser
import java.io.Closeable
import java.io.File
import java.io.InputStream
@@ -39,7 +40,7 @@ class EpubReader(private val reader: ArchiveReader) : Closeable by reader {
fun getPackageHref(): String {
val meta = getInputStream(resolveZipPath("META-INF", "container.xml"))
if (meta != null) {
val metaDoc = meta.use { Jsoup.parse(it, null, "") }
val metaDoc = meta.use { Jsoup.parse(it, null, "", Parser.xmlParser()) }
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
if (path != null) {
return path
@@ -52,7 +53,7 @@ class EpubReader(private val reader: ArchiveReader) : Closeable by reader {
* Returns the package document where all the files are listed.
*/
fun getPackageDocument(ref: String): Document {
return getInputStream(ref)!!.use { Jsoup.parse(it, null, "") }
return getInputStream(ref)!!.use { Jsoup.parse(it, null, "", Parser.xmlParser()) }
}
/**

View File

@@ -19,7 +19,7 @@ class NetworkPreferences(
fun defaultUserAgent(): Preference<String> {
return preferenceStore.getString(
"default_user_agent",
"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36",
)
}
}

View File

@@ -9,6 +9,9 @@ import androidx.core.content.ContextCompat
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.Hash
import java.io.File
import java.nio.ByteBuffer
import java.nio.CharBuffer
import java.nio.charset.CodingErrorAction
object DiskUtil {
@@ -102,26 +105,84 @@ object DiskUtil {
}
/**
* Mutate the given filename to make it valid for a FAT filesystem,
* replacing any invalid characters with "_". This method doesn't allow hidden files (starting
* with a dot), but you can manually add it later.
* Transform a filename fragment to make it safe to use on almost
* all commonly used filesystems. You can pass an entire filename,
* or just part of one, in case you want a specific part of a long
* filename to be truncated, rather than the end of it.
*
* Characters that are potentially unsafe for some filesystems are
* replaced with underscores. This includes the standard ones from
* https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
* but does allow any other valid Unicode code point.
*
* Excessively long filenames are truncated, by default to 240
* bytes. Note that the truncation is based on bytes rather than
* characters (code points), because this is what is relevant to
* filesystem restrictions in most cases.
*
* Leading periods are stripped, to avoid the creation of hidden
* files by default. If a hidden file is desired, a period can be
* prepended to the return value from this function.
*
* If the optional argument disallowNonAscii is set to true,
* then ANYTHING outside the ASCII range is replaced not with underscores,
* but with its hexadecimal encoding. This is to make it so that distinct
* non-English titles of things remain distinct, since not all
* places where this function is used also take care of
* disambiguation.
*
* We could instead replace only non-ASCII characters known to
* be problematic, but so far nobody with a non-Unicode-compliant
* device has been able to provide either directions to reproduce
* their issue nor any documentation or tests that would allow us
* to determine which characters are problems and which are not.
*/
fun buildValidFilename(origName: String): String {
fun buildValidFilename(
origName: String,
maxBytes: Int = MAX_FILE_NAME_BYTES,
disallowNonAscii: Boolean = false,
): String {
val name = origName.trim('.', ' ')
if (name.isEmpty()) {
return "(invalid)"
}
val sb = StringBuilder(name.length)
name.forEach { c ->
if (isValidFatFilenameChar(c)) {
if (disallowNonAscii && c >= 0x80.toChar()) {
sb.append(
c.toString().toByteArray(Charsets.UTF_8).toHexString(
HexFormat {
upperCase = false
},
),
)
} else if (isValidFatFilenameChar(c)) {
sb.append(c)
} else {
sb.append('_')
}
}
// Even though vfat allows 255 UCS-2 chars, we might eventually write to
// ext4 through a FUSE layer, so use that limit minus 15 reserved characters.
return sb.toString().take(240)
return truncateToLength(sb.toString(), maxBytes)
}
/**
* Truncate a string to a maximum length, while maintaining valid Unicode encoding.
*/
fun truncateToLength(s: String, maxBytes: Int): String {
val charset = Charsets.UTF_8
val decoder = charset.newDecoder()
val sba = s.toByteArray(charset)
if (sba.size <= maxBytes) {
return s
}
// Ensure truncation by having byte buffer = maxBytes
val bb = ByteBuffer.wrap(sba, 0, maxBytes)
val cb = CharBuffer.allocate(maxBytes)
// Ignore an incomplete character
decoder.onMalformedInput(CodingErrorAction.IGNORE)
decoder.decode(bb, cb, true)
decoder.flush(cb)
return String(cb.array(), 0, cb.position())
}
/**
@@ -139,6 +200,8 @@ object DiskUtil {
const val NOMEDIA_FILE = ".nomedia"
// Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8)
const val MAX_FILE_NAME_BYTES = 250
// Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8).
// To allow for writing to ext4 through a FUSE layer in the future, also subtract 15
// reserved characters.
const val MAX_FILE_NAME_BYTES = 240
}

View File

@@ -83,6 +83,9 @@ fun WebView.setDefaultSettings() {
loadWithOverviewMode = true
cacheMode = WebSettings.LOAD_DEFAULT
// Handle popups properly
setSupportMultipleWindows(true)
// Allow zooming
setSupportZoom(true)
builtInZoomControls = true

View File

@@ -52,6 +52,7 @@ class UpdatesRepositoryImpl(
chapterId: Long,
chapterName: String,
scanlator: String?,
chapterUrl: String,
read: Boolean,
bookmark: Boolean,
lastPageRead: Long,
@@ -67,6 +68,7 @@ class UpdatesRepositoryImpl(
chapterId = chapterId,
chapterName = chapterName,
scanlator = scanlator,
chapterUrl = chapterUrl,
read = read,
bookmark = bookmark,
lastPageRead = lastPageRead,

View File

@@ -0,0 +1,24 @@
-- Add chapter urls to updates view
DROP VIEW IF EXISTS updatesView;
CREATE VIEW updatesView AS
SELECT
mangas._id AS mangaId,
mangas.title AS mangaTitle,
chapters._id AS chapterId,
chapters.name AS chapterName,
chapters.scanlator,
chapters.url AS chapterUrl,
chapters.read,
chapters.bookmark,
chapters.last_page_read,
mangas.source,
mangas.favorite,
mangas.thumbnail_url AS thumbnailUrl,
mangas.cover_last_modified AS coverLastModified,
chapters.date_upload AS dateUpload,
chapters.date_fetch AS datefetch
FROM mangas JOIN chapters
ON mangas._id = chapters.manga_id
WHERE favorite = 1
AND date_fetch > date_added
ORDER BY date_fetch DESC;

View File

@@ -0,0 +1,7 @@
-- Save the current remote_id as library_id, since old Kitsu tracker did not use this correctly
UPDATE manga_sync SET library_id = remote_id WHERE sync_id = 3;
-- Kitsu and Suwayomi aren't using the remote_id field properly, but for both the ID is present in the URL
-- This parses a url and gets the ID from the trailing path part, e.g. https://kitsu.app/manga/<id>
-- Based on https://stackoverflow.com/a/38330814
UPDATE manga_sync SET remote_id = replace(remote_url, rtrim(remote_url, replace(remote_url, '/', '')), '') WHERE sync_id IN (3, 9);

View File

@@ -5,6 +5,7 @@ SELECT
chapters._id AS chapterId,
chapters.name AS chapterName,
chapters.scanlator,
chapters.url AS chapterUrl,
chapters.read,
chapters.bookmark,
chapters.last_page_read,
@@ -31,4 +32,4 @@ SELECT *
FROM updatesView
WHERE read = :read
AND dateUpload > :after
LIMIT :limit;
LIMIT :limit;

View File

@@ -31,7 +31,7 @@ dependencies {
api(libs.sqldelight.android.paging)
compileOnly(libs.compose.stablemarker)
compileOnly(compose.runtime.annotation)
testImplementation(libs.bundles.test)
testImplementation(kotlinx.coroutines.test)

View File

@@ -37,6 +37,10 @@ class DownloadPreferences(
fun downloadNewUnreadChaptersOnly() = preferenceStore.getBoolean("download_new_unread_chapters_only", false)
fun parallelSourceLimit() = preferenceStore.getInt("download_parallel_source_limit", 5)
fun parallelPageLimit() = preferenceStore.getInt("download_parallel_page_limit", 5)
companion object {
private const val REMOVE_EXCLUDE_CATEGORIES_PREF_KEY = "remove_exclude_categories"
private const val DOWNLOAD_NEW_CATEGORIES_PREF_KEY = "download_new_categories"

View File

@@ -192,6 +192,8 @@ class LibraryPreferences(
fun updateMangaTitles() = preferenceStore.getBoolean("pref_update_library_manga_titles", false)
fun disallowNonAsciiFilenames() = preferenceStore.getBoolean("disallow_non_ascii_filenames", false)
// endregion
enum class ChapterSwipeAction {

View File

@@ -8,6 +8,7 @@ data class UpdatesWithRelations(
val chapterId: Long,
val chapterName: String,
val scanlator: String?,
val chapterUrl: String,
val read: Boolean,
val bookmark: Boolean,
val lastPageRead: Long,

View File

@@ -1,6 +1,6 @@
[versions]
agp_version = "8.12.0"
lifecycle_version = "2.9.2"
agp_version = "8.13.0"
lifecycle_version = "2.9.4"
paging_version = "3.3.6"
interpolator_version = "1.0.0"
@@ -11,7 +11,7 @@ annotation = "androidx.annotation:annotation:1.9.1"
appcompat = "androidx.appcompat:appcompat:1.7.1"
biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
constraintlayout = "androidx.constraintlayout:constraintlayout:2.2.1"
corektx = "androidx.core:core-ktx:1.16.0"
corektx = "androidx.core:core-ktx:1.17.0"
splashscreen = "androidx.core:core-splashscreen:1.0.1"
recyclerview = "androidx.recyclerview:recyclerview:1.4.0"
viewpager = "androidx.viewpager:viewpager:1.1.0"
@@ -21,14 +21,14 @@ lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref
lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle_version" }
lifecycle-runtimektx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle_version" }
workmanager = "androidx.work:work-runtime:2.10.3"
workmanager = "androidx.work:work-runtime:2.11.0"
paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging_version" }
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging_version" }
interpolator = { group = "androidx.interpolator", name = "interpolator", version.ref = "interpolator_version" }
benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.4.0"
benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.4.1"
test-ext = "androidx.test.ext:junit-ktx:1.3.0"
test-espresso-core = "androidx.test.espresso:espresso-core:3.7.0"
test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0"

View File

@@ -1,13 +1,14 @@
[versions]
compose-bom = "2025.07.00"
compose-bom = "2025.09.00"
[libraries]
activity = "androidx.activity:activity-compose:1.10.1"
activity = "androidx.activity:activity-compose:1.11.0"
bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
foundation = { module = "androidx.compose.foundation:foundation" }
animation = { module = "androidx.compose.animation:animation" }
animation-graphics = { module = "androidx.compose.animation:animation-graphics" }
runtime = { module = "androidx.compose.runtime:runtime" }
runtime-annotation = { module = "androidx.compose.runtime:runtime-annotation" }
ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
ui-util = { module = "androidx.compose.ui:ui-util" }

View File

@@ -1,12 +0,0 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ff8d269e2495c538cfa04b4b52d22286/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4dfe7aab2abf71db71537e9dca36c154/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ff8d269e2495c538cfa04b4b52d22286/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4dfe7aab2abf71db71537e9dca36c154/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9a7f49eb8ed1ea9722ebec95f4befa0e/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/2a0209399b0a7928a6e5fc680e1c0d35/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ff8d269e2495c538cfa04b4b52d22286/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4dfe7aab2abf71db71537e9dca36c154/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/a5678544b69a5c533a76c11213c7b7ed/redirect
toolchainVendor=ADOPTIUM
toolchainVersion=17

View File

@@ -1,7 +1,7 @@
[versions]
kotlin_version = "2.2.0"
kotlin_version = "2.2.21"
serialization_version = "1.9.0"
xml_serialization_version = "0.91.2"
xml_serialization_version = "0.91.3"
[libraries]
reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin_version" }

View File

@@ -1,17 +1,17 @@
[versions]
aboutlib_version = "12.2.4"
aboutlib_version = "13.1.0"
leakcanary = "2.14"
moko = "0.25.0"
okhttp_version = "5.1.0"
shizuku_version = "13.1.0"
moko = "0.25.1"
okhttp_version = "5.3.0"
shizuku_version = "13.1.5"
sqldelight = "2.1.0"
sqlite = "2.5.2"
sqlite = "2.6.1"
voyager = "1.1.0-beta03"
spotless = "7.2.1"
spotless = "8.0.0"
ktlint-core = "1.7.1"
firebase-bom = "34.0.0"
markdown = "0.35.0"
junit = "5.13.4"
firebase-bom = "34.5.0"
markdown = "0.38.1"
junit = "6.0.1"
[libraries]
desugar = "com.android.tools:desugar_jdk_libs:2.1.5"
@@ -23,13 +23,13 @@ okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp_ve
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp_version" }
okhttp-brotli = { module = "com.squareup.okhttp3:okhttp-brotli", version.ref = "okhttp_version" }
okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps", version.ref = "okhttp_version" }
okio = "com.squareup.okio:okio:3.16.0"
okio = "com.squareup.okio:okio:3.16.2"
conscrypt-android = "org.conscrypt:conscrypt-android:2.5.3"
quickjs-android = "app.cash.quickjs:quickjs-android:0.9.2"
quickjs-android = { group = "com.github.zhanghai.quickjs-java", name = "quickjs-android", version = "547f5b1597" }
jsoup = "org.jsoup:jsoup:1.21.1"
jsoup = "org.jsoup:jsoup:1.21.2"
disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:e0def6b3dc"
@@ -60,12 +60,10 @@ material = "com.google.android.material:material:1.12.0"
flexible-adapter-core = "com.github.arkon.FlexibleAdapter:flexible-adapter:c8013533"
photoview = "com.github.chrisbanes:PhotoView:2.3.0"
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
compose-materialmotion = "io.github.fornewid:material-motion-compose-core:2.0.1"
compose-webview = "io.github.kevinnzou:compose-webview:0.33.6"
compose-grid = "io.woong.compose.grid:grid:1.2.2"
compose-stablemarker = "com.github.skydoves:compose-stable-marker:1.0.5"
reorderable = { module = "sh.calvin.reorderable:reorderable", version = "2.5.1" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version = "3.0.0" }
swipe = "me.saket.swipe:swipe:1.3.0"
@@ -92,8 +90,8 @@ sqldelight-dialects-sql = { module = "app.cash.sqldelight:sqlite-3-38-dialect",
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
kotest-assertions = "io.kotest:kotest-assertions-core:5.9.1"
mockk = "io.mockk:mockk:1.14.5"
kotest-assertions = "io.kotest:kotest-assertions-core:6.0.4"
mockk = "io.mockk:mockk:1.14.6"
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" }
@@ -109,11 +107,11 @@ markdown-coil = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3",
stringSimilarity = { module = "com.aallam.similarity:string-similarity-kotlin", version = "0.1.0" }
[plugins]
google-services = { id = "com.google.gms.google-services", version = "4.4.3" }
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlib_version" }
google-services = { id = "com.google.gms.google-services", version = "4.4.4" }
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutlib_version" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" }
firebase-crashlytics = { id = "com.google.firebase.crashlytics", version = "3.0.5" }
firebase-crashlytics = { id = "com.google.firebase.crashlytics", version = "3.0.6" }
[bundles]
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-brotli", "okhttp-dnsoverhttps"]

View File

@@ -105,7 +105,7 @@
<item quantity="other">%d فصل تالٍ لم يُقرؤوا</item>
</plurals>
<plurals name="download_amount">
<item quantity="zero">الفصل التالي</item>
<item quantity="zero">لا فصل تالي</item>
<item quantity="one">الفصل التالي</item>
<item quantity="two">%d فصول تالية</item>
<item quantity="few">%d فصول تالية</item>
@@ -152,4 +152,28 @@
<item quantity="many">بعد %1$d أيام</item>
<item quantity="other">بعد %1$d أيام</item>
</plurals>
<plurals name="migrationListScreen.migrateDialog.migrateTitle">
<item quantity="zero">نقل %1$d مدخل</item>
<item quantity="one">نقل %1$d مدخل</item>
<item quantity="two">نقل %1$d مدخل</item>
<item quantity="few">نقل %1$d مدخل</item>
<item quantity="many">نقل %1$d مدخل</item>
<item quantity="other">نقل %1$d مدخل</item>
</plurals>
<plurals name="migrationListScreen.migrateDialog.copyTitle">
<item quantity="zero">نسخ %1$d مدخل؟</item>
<item quantity="one">نسخ %1$d مدخل؟</item>
<item quantity="two">نسخ %1$d مدخل؟</item>
<item quantity="few">نسخ %1$d مدخل؟</item>
<item quantity="many">نسخ %1$d مدخل؟</item>
<item quantity="other">نسخ %1$d مدخل؟</item>
</plurals>
<plurals name="migrationListScreen.migrateDialog.skipText">
<item quantity="zero">لا مدخل ثم تجاوزه</item>
<item quantity="one">مدخل ثم تجاوزه</item>
<item quantity="two">مدخلان ثم تجاوزه</item>
<item quantity="few">%1$d مدخل ثم تجاوزه</item>
<item quantity="many">%1$d مدخل ثم تجاوزه</item>
<item quantity="other">%1$d مدخل ثم تجاوزه</item>
</plurals>
</resources>

View File

@@ -65,7 +65,7 @@
<string name="pref_category_tracking">التتبع</string>
<string name="pref_category_advanced">اﻹعدادات المتقدمة</string>
<string name="pref_category_about">حول التطبيق</string>
<string name="pref_library_columns">حجم الشبكة</string>
<string name="pref_library_columns">العناصر لكل صف</string>
<string name="portrait">طوليٌّ</string>
<string name="landscape">عرضيٌّ</string>
<string name="pref_library_update_interval">التحديثات التلقائية</string>
@@ -137,10 +137,10 @@
<string name="restoring_backup">تُستعاد النسخة الاحتياطية</string>
<string name="creating_backup">تُنشأ النسخة الاحتياطية</string>
<string name="action_global_search">بحث شامل</string>
<string name="color_filter_r_value">R</string>
<string name="color_filter_g_value">G</string>
<string name="color_filter_b_value">B</string>
<string name="color_filter_a_value">A</string>
<string name="color_filter_r_value">احمر</string>
<string name="color_filter_g_value">اخضر</string>
<string name="color_filter_b_value">أزرق</string>
<string name="color_filter_a_value">ألفا</string>
<string name="pref_clear_chapter_cache">مسح الذاكرة المؤقتة للفصول</string>
<string name="used_cache">المستخدمة: %1$s</string>
<string name="cache_deleted">تم محو الذاكرة المؤقتة. %1$d ملف تم محوه</string>
@@ -149,7 +149,6 @@
<string name="cookies_cleared">تم محو ملفات تعريف الارتباط</string>
<string name="pref_clear_database">محو قاعدة البيانات</string>
<string name="pref_clear_database_summary">مسح سجلّ الإدخالات التي ليست محفوظة في مكتبتك</string>
<string name="clear_database_confirmation">أمتأكِّد؟ إن فعلتَ سوف تخسر الفصول المقروءة و التقدم فى المدخلات الغير محفوظة فى المكتبة</string>
<string name="clear_database_completed">تم حذف المدخلات</string>
<string name="version">اﻹصدار</string>
<string name="pref_enable_acra">إرسال تقارير الأعطال</string>
@@ -330,7 +329,7 @@
<string name="restore_in_progress">الاستعادة قيد التقدم بالفعل</string>
<string name="creating_backup_error">فشل النسخ الاحتياطي</string>
<string name="backup_in_progress">يُنسخ احتياطيًّا بالفعل</string>
<string name="restore_duration">%02d دقيقة و %02d ثانية</string>
<string name="restore_duration">%1$02dدقيقة،%2$02dثانية</string>
<string name="action_unpin">إلغاء التثبيت</string>
<string name="action_pin">تثبيت</string>
<string name="action_select_inverse">عكس التحديد</string>
@@ -556,7 +555,7 @@
<string name="clear_database_source_item_count">%1$d مدخلةً في قاعدة البيانات وليست في المكتبة</string>
<string name="extension_api_error">فشل الحصول على قائمة الملحقات</string>
<string name="ext_installer_shizuku_unavailable_dialog">ثبِّت «شيزوكو» وشغِّله لتستخدمه مثبِّت إضافات.</string>
<string name="download_queue_size_warning">تحذير: يمكن أن تؤدِّي التنزيلات كبيرة الحجم والعدد إلى إبطاء المصادر، وقد يُحظر Mihon منها بسبب ذلك. اضغط لمعرفة المزيد۔</string>
<string name="download_queue_size_warning">%sتحذير: يمكن أن تؤدِّي التنزيلات كبيرة الحجم والعدد إلى إبطاء المصادر، وقد يُحظر %s منها بسبب ذلك. اضغط لمعرفة المزيد۔</string>
<string name="action_show_manga">إظهار الدخول</string>
<string name="action_display_cover_only_grid">شبكة بالاغلفة</string>
<string name="skipped_reason_not_started">تُخُطِّيت بسبب عدم وجود فصول قُرئت</string>
@@ -677,9 +676,6 @@
<string name="pref_library_update_show_tab_badge">إظهار عدد الادخالات غير المقروءة في ايقونة التحديثات</string>
<string name="pref_skip_dupe_chapters">تجاوز الفصول المكررة</string>
<string name="enhanced_services_not_installed">متوفر ولكن المصدر غير مثبت: %s</string>
<string name="confirm_add_duplicate_manga">لديك إدخال في مكتبتك بنفس الاسم.
\n
\nهل مازلت ترغب في الاستمرار؟</string>
<string name="copied_to_clipboard_plain">نسخ الى الحافظة</string>
<string name="track_error">%1$s خطأ: %2$s</string>
<string name="information_required_plain">*مطلوب</string>
@@ -692,8 +688,8 @@
<string name="pref_page_rotate_invert">اعكس اتِّجاه الصفحات العريضة المدوَّرة</string>
<string name="pref_debug_info">معلومات التنقيح</string>
<string name="pref_double_tap_zoom">انقر مرتين لتكبِّر</string>
<string name="pref_chapter_swipe_end">إجراء التمرير الأيسر</string>
<string name="pref_chapter_swipe_start">إجراء التمرير الأيمن</string>
<string name="pref_chapter_swipe_end">إجراء التمرير الى اليمين</string>
<string name="pref_chapter_swipe_start">إجراء التمرير الى اليسار</string>
<string name="action_set_interval">عيِّد المدة</string>
<string name="action_filter_interval_custom">مدة جلب مخصَّصة</string>
<string name="manga_display_interval_title">قدِّر كلَّ</string>
@@ -741,7 +737,7 @@
<string name="action_menu_overflow_description">خيارات أكثر</string>
<string name="selected">محدَّد</string>
<string name="not_selected">غير مُحدَّد</string>
<string name="action_bar_up_description">إصعد</string>
<string name="action_bar_up_description">تصفح الاعلى</string>
<string name="pref_storage_location">مكان التخزين</string>
<string name="pref_storage_location_info">يُستخدَم في الاحتياط وتنزيل الفصول والمصدر المحليِّ.</string>
<string name="onboarding_storage_action_select">حدِّد مجلَّدًا</string>
@@ -809,10 +805,102 @@
<string name="pref_flash_with">يومض مع</string>
<string name="action_replace_repo_message">يحتوي المستودع %1$s على نفس بصمة مفتاح التوقيع الموجودة في %2$s.
\nإذا كان هذا متوقعًا، فسيتم استبدال %2$s، وإلا فاتصل بمشرف الرابطالخاص بك.</string>
<string name="action_migrate_duplicate">نقل مَدْخَل موجود</string>
<string name="pref_display_profile">ملف تعريف عرض خاص</string>
<string name="action_copy_link">نسخ الرابط</string>
<string name="extensionRepo_settings">مستودع الإضافات</string>
<string name="invalid_backup_file_unknown">ملف النسخة الإحتياطية معطل أو لا يعمل</string>
<string name="invalid_backup_file_json">لا يتم دعم النسخ الاحتياطي لـ JSON</string>
<string name="artist">فنان</string>
<string name="author">الكاتب</string>
<string name="label_auto">تلقائي</string>
<string name="onboarding_permission_analytics_description">أرسل بيانات بشكل مخفي للمساهمة في تطوير البرنامج.</string>
<string name="pref_security">الأمن</string>
<string name="pref_firebase">سجلات التحليلات و الأخطاء</string>
<string name="action_sort_random">عشوائي</string>
<string name="onboarding_permission_crashlytics">أرسل سجلات الخطأ</string>
<string name="onboarding_permission_crashlytics_description">أرسل سجلات الخطأ للمطورين بشكل خفي.</string>
<string name="onboarding_permission_analytics">السماح للتحليلات</string>
<string name="action_notes">ملاحظات</string>
<string name="action_edit_notes">تعديل الملاحظات</string>
<string name="action_display_unread_badge">فصول غير مقروءة</string>
<string name="theme_monochrome">أحادي اللون</string>
<string name="firebase_summary">سيسمح لنا إرسال سجلات الأعطال والتحليلات بتحديد المشكلات وإصلاحها وتحسين الأداء وجعل التحديثات المستقبلية أكثر ملاءمة لاحتياجاتك</string>
<string name="pref_mark_duplicate_read_chapter_read_existing">بعد قرائة الفصل</string>
<string name="storage_failed_to_create_directory">فشل في انشاء المكتبة:%s</string>
<string name="storage_failed_to_create_download_directory">فشل في تحميل المكتبة</string>
<string name="action_toggle_private_off">التتبع العلني</string>
<string name="pref_behavior">تصرف</string>
<string name="pref_mark_duplicate_read_chapter_read_new">بعد نقل الفصل الجديد</string>
<string name="ext_remove">احدف</string>
<string name="ext_confirm_remove">حدف الإمتداد؟</string>
<string name="add_repo_confirmation">هل تريد اضافة الفهرس \"%s\"؟</string>
<string name="pref_update_library_manga_titles">تحديث عنوان المانغا لتطابق المصدر</string>
<string name="logging_in">جاري الدخول…</string>
<string name="pref_update_library_manga_titles_summary">تحذير: إذا تمت إعادة تسمية المانجا، فسيتم إزالتها من قائمة التنزيل (إن وجدت)</string>
<string name="action_toggle_private_on">التتبع الخاص</string>
<string name="notes_placeholder">استمتعت بالجزء حين …</string>
<string name="trackers_updated_summary">تحديث المتابعات إلى الفصل %d</string>
<string name="pref_always_decode_long_strip_with_ssiv_2">استخدم فك التشفير القديم لقارئ الشرائط الطويلة</string>
<string name="pref_mark_duplicate_read_chapter_read">اعتبر الفصول المقروئة مرتين مقروئة</string>
<string name="remove_private_extension_message">هل تريد فعلا حدف الامتداد \"%s\"؟</string>
<string name="pref_hardware_bitmap_threshold_summary">ادا تم تحميل صورة فارغة قلل الحد تدريجيا لحل المشكلة.\nتم تحديد:%s</string>
<string name="library_list">قائمة المكتبات</string>
<string name="non_library_settings">جميع الإدخالات المقروئة</string>
<string name="pref_hardware_bitmap_threshold">عتبة خريطة الصورة النقطية للأجهزة المخصصة</string>
<string name="pref_hardware_bitmap_threshold_default">افتراضي (%d)</string>
<string name="possible_duplicates_summary">لديك مداخلات متشابهة في مكتبك بنفس الاسم.\nحدد واحدة للنقل أو الإضافة على أي حال</string>
<string name="possible_duplicates_title">التكرارات المحتملة</string>
<string name="confirm_tracker_update">تحديث المتابعات إلى الفصل %d؟</string>
<string name="pref_incognito_mode_extension_summary">إيقاف قراءة التاريخ مؤقتًا للتمديد</string>
<string name="label_donate">تبرع</string>
<string name="theme_catppuccin">كاتبوتشين</string>
<string name="pref_display_images_description">­تقديم الصور في أوصاف المانجا</string>
<string name="pref_hide_missing_chapter_indicators">إخفاء مؤشرات الفصل المفقودة</string>
<string name="pref_always_decode_long_strip_with_ssiv_summary">يؤثر على الأداء. فعّله فقط إذا لم يُحل تقليل عتبة الخريطة النقطية مشاكل الصورة الفارغة.</string>
<string name="pref_download_new_unread_chapters_only">تخطي تنزيل الفصول المقروءة المكررة</string>
<string name="pref_auto_update_manga_on_mark_read">تحديث التقدم عند وضع علامة عليه كمقروء</string>
<string name="export">يصدّر</string>
<string name="library_exported">تم تصدير المكتبة</string>
<string name="clear_database_text">أنت على وشك إزالة الإدخالات من قاعدة البيانات</string>
<string name="clear_database_history_warning">سيتم فقدان قراءة الفصول وتقدم الإدخالات غير الموجودة في المكتبة</string>
<string name="clear_db_exclude_read">الاحتفاظ بالإدخالات مع الفصول المقروءة</string>
<string name="tracked_privately">تم تعقبها بشكل خاص</string>
<string name="migrationConfigScreen.selectedHeader">مُختار</string>
<string name="migrationConfigScreen.availableHeader">متاح</string>
<string name="migrationConfigScreen.selectAllLabel">تحديد الكل</string>
<string name="migrationConfigScreen.selectNoneLabel">لا تحدد</string>
<string name="migrationConfigScreen.selectEnabledLabel">­حدد المصادر الممكنة</string>
<string name="migrationConfigScreen.selectPinnedLabel">تحديد المصادر المثبتة</string>
<string name="migrationConfigScreen.continueButtonText">اكمل</string>
<string name="migrationConfigScreen.dataToMigrateHeader">البيانات المراد نقلها</string>
<string name="migrationConfigScreen.removeDownloadsTitle">حذف التنزيلات الخاصة بالإدخال الحالي بعد الترحيل</string>
<string name="migrationConfigScreen.additionalSearchQueryLabel">­كلمات رئيسية إضافية (اختياري)</string>
<string name="migrationConfigScreen.additionalSearchQuerySupportingText">يساعد في تضييق نتائج البحث عن طريق إضافة كلمات رئيسية إضافية</string>
<string name="migrationConfigScreen.hideUnmatchedTitle">إخفاء الإدخالات التي لا تحتوي على تطابق</string>
<string name="migrationConfigScreen.hideWithoutUpdatesTitle">إخفاء الإدخالات بدون فصول أحدث</string>
<string name="migrationConfigScreen.hideWithoutUpdatesSubtitle">إظهار الإدخال فقط إذا كانت المباراة تحتوي على فصول إضافية</string>
<string name="migrationConfigScreen.enhancedOptionsWarning">هذه الخيارات بطيئة وخطيرة وقد تؤدي إلى فرض قيود من المصادر</string>
<string name="migrationConfigScreen.deepSearchModeTitle">وضع البحث المتقدم</string>
<string name="migrationConfigScreen.deepSearchModeSubtitle">يقسم العنوان إلى كلمات رئيسية لإجراء بحث أوسع</string>
<string name="migrationConfigScreen.prioritizeByChaptersTitle">المطابقة على أساس رقم الفصل</string>
<string name="migrationConfigScreen.prioritizeByChaptersSubtitle">في حال تفعيله، يتم اختيار المطابقة الأبعد. وإلا، يتم اختيار المطابقة الأولى حسب أولوية المصدر.</string>
<string name="migrationListScreenTitle">‎نقل</string>
<string name="migrationListScreenTitleWithProgress">نقل (%1$d/%2$d)</string>
<string name="migrationListScreen.copyActionLabel">قطع</string>
<string name="migrationListScreen.migrateActionLabel">نقل</string>
<string name="migrationListScreen.noMatchFoundText">لم يتم العثور على بدائل</string>
<string name="migrationListScreen.latestChapterLabel">الأحدث: %1$s</string>
<string name="migrationListScreen.unknownLatestChapter">مجهول</string>
<string name="migrationListScreen.searchManuallyActionLabel">البحث اليدوي</string>
<string name="migrationListScreen.skipActionLabel">لا تنقل</string>
<string name="migrationListScreen.migrateNowActionLabel">نقل الآن</string>
<string name="migrationListScreen.copyNowActionLabel">نسخ الآن</string>
<string name="migrationListScreen.exitDialogTitle">وقف النقل؟</string>
<string name="migrationListScreen.exitDialog.stopLabel">إيقاف</string>
<string name="migrationListScreen.exitDialog.cancelLabel">إلغاء</string>
<string name="migrationListScreen.migrateDialog.copyLabel">نسخ</string>
<string name="migrationListScreen.migrateDialog.migrateLabel">نقل</string>
<string name="migrationListScreen.migrateDialog.cancelLabel">إلغاء</string>
<string name="migrationListScreen.progressDialog.cancelLabel">إلغاء</string>
<string name="migrationListScreen.matchWithoutChapterToast">لم يتم العثور على فصول، لا يمكن استخدام هذا الإدخال للنقل</string>
</resources>

View File

@@ -76,4 +76,8 @@
<item quantity="one">অধ্যায়সমূহ %1$s আৰু 1 অধিক</item>
<item quantity="other">অধ্যায়সমূহ %1$s আৰু %2$d অধিক</item>
</plurals>
</resources>
<plurals name="migrationListScreen.migrateDialog.migrateTitle">
<item quantity="one">%1$d প্ৰৱেশ প্ৰব্ৰজন কৰক?</item>
<item quantity="other">%1$d প্ৰবিষ্টসমূহ প্ৰব্ৰজন কৰক?</item>
</plurals>
</resources>

View File

@@ -11,7 +11,6 @@
<string name="unlock_app_title">%s আনলক কৰক</string>
<string name="action_webview_forward">সামনে</string>
<string name="action_webview_refresh">পুনৰপ্ৰাপ্তি কৰক</string>
<string name="action_migrate_duplicate">বিদ্যমান প্ৰৱিষ্টি স্থানান্তৰ কৰক</string>
<string name="onboarding_action_next">পৰৱৰ্তী</string>
<string name="pref_category_general">সাধাৰণ</string>
<string name="pref_category_appearance">দেখাত</string>
@@ -524,7 +523,6 @@
<string name="pref_clear_database">ডাটাবেচ মচা</string>
<string name="pref_clear_database_summary">আপোনাৰ লাইব্ৰেৰিত সংৰক্ষণ কৰা হোৱা নথিৰ ইতিহাস মচা</string>
<string name="clear_database_source_item_count">ডাটাবেচত %1$d লাইব্ৰেৰিত নথিৰ সংখ্যা</string>
<string name="clear_database_confirmation">আপোনি নিশ্চিত? লাইব্ৰেৰিত নথিৰ অধ্যায় আৰু অগ্ৰগতি হেৰুৱাব</string>
<string name="clear_database_completed">নথি মচা হৈছে</string>
<string name="database_clean">মচাৰ বাবে কিবা নাই</string>
<string name="pref_clear_webview_data">WebView ডাটা মচা</string>
@@ -609,9 +607,6 @@
<string name="remove_from_library">লাইব্ৰেৰী পৰা মচি দিয়ক</string>
<string name="manga_removed_library">লাইব্ৰেৰী পৰা মচি দিয়া হৈছে</string>
<string name="unknown_title">অজানা শিৰোনাম</string>
<string name="confirm_add_duplicate_manga">আপোনাৰ লাইব্ৰেৰীত এই নামৰ এটা প্ৰৱিষ্ট আছে।
\n
\nআপুনি এতিয়াও আগবঢ়াব খুজিছে নেকি?</string>
<string name="manga_info_expand">অধিক</string>
<string name="manga_info_collapse">সৰু</string>
<string name="delete_downloads_for_manga">ডাউনলোড কৰা অধ্যায়সমূহ মচি দিব নেকি?</string>

View File

@@ -320,6 +320,8 @@
<string name="pref_mark_duplicate_read_chapter_read_new">After fetching new chapter</string>
<string name="pref_hide_missing_chapter_indicators">Hide missing chapter indicators</string>
<string name="pref_disallow_non_ascii_filenames">Disallow non-ASCII filenames</string>
<string name="pref_disallow_non_ascii_filenames_details">Ensures compatibility with certain storage media that don't support Unicode. When this is enabled, you'll need to manually rename source and manga folders by replacing non-ASCII characters with their lowercase UTF-8 hexadecimal representations. Chapter files don't need to be renamed.</string>
<!-- Extension section -->
<string name="multi_lang">Multi</string>
@@ -364,7 +366,7 @@
<string name="information_empty_repos">You have no repos set.</string>
<string name="action_add_repo">Add repo</string>
<string name="label_add_repo_input">Repo URL</string>
<string name="action_add_repo_message">Add additional repos to Mihon. This should be a URL that ends with \"index.min.json\".</string>
<string name="action_add_repo_message">Add additional repos to %s. This should be a URL that ends with \"index.min.json\".</string>
<string name="error_repo_exists">This repo already exists!</string>
<string name="action_delete_repo">Delete repo</string>
<string name="invalid_repo_name">Invalid repo URL</string>
@@ -521,6 +523,9 @@
<string name="save_chapter_as_cbz">Save as CBZ archive</string>
<string name="split_tall_images">Split tall images</string>
<string name="split_tall_images_summary">Improves reader performance</string>
<string name="pref_download_concurrent_sources">Concurrent source downloads</string>
<string name="pref_download_concurrent_pages">Concurrent page downloads</string>
<string name="pref_download_concurrent_pages_summary">Pages downloaded simultaneously per source</string>
<!-- Tracking section -->
<string name="tracking_guide">Tracking guide</string>
@@ -904,7 +909,7 @@
<!-- Downloads activity and service -->
<string name="download_queue_error">Couldn\'t download chapters. You can try again in the downloads section</string>
<string name="download_insufficient_space">Couldn\'t download chapters due to low storage space</string>
<string name="download_queue_size_warning">Warning: large bulk downloads may lead to sources becoming slower and/or blocking Mihon. Tap to learn more.</string>
<string name="download_queue_size_warning">Warning: large bulk downloads may lead to sources becoming slower and/or blocking %s. Tap to learn more.</string>
<!-- Library update service notifications -->
<string name="notification_updating_progress">Updating library… (%s)</string>

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="update_check_notification_ext_updates">
<item quantity="one">1 наличен ъпдейт за разширение</item>
<item quantity="other">%d налични ъпдейта за разширения</item>
<item quantity="one">1 налично обновление за разширение</item>
<item quantity="other">%d налични обновления за разширения</item>
</plurals>
<plurals name="notification_chapters_multiple_and_more">
<item quantity="one">Глави %1$s и още 1</item>
@@ -68,4 +68,12 @@
<item quantity="one">%d хранилище</item>
<item quantity="other">%d хранилища</item>
</plurals>
<plurals name="pref_pages">
<item quantity="one">1 страница</item>
<item quantity="other">%1$s страници</item>
</plurals>
<plurals name="upcoming_relative_time">
<item quantity="one">Утре</item>
<item quantity="other">След %1$d дни</item>
</plurals>
</resources>

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