Compare commits

..

111 Commits

Author SHA1 Message Date
AntsyLich
412815af06 Revert "Fix reader tap zones triggering after scrolling was stopped by the user" (#2670) 2025-11-07 13:07:14 +00:00
Weblate (bot)
f7fb68692a Translations update from Hosted Weblate (#2656)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/cs/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/
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/ru/
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: Doministo <doministo@seznam.cz>
Co-authored-by: Lyfja <45209212+lyfja@users.noreply.github.com>
Co-authored-by: Nataniel Dika Kurniawan <hikawaart2@gmail.com>
Co-authored-by: TheKingTermux <50316075+TheKingTermux@users.noreply.github.com>
Co-authored-by: ZerOriSama <godarms2010@live.com>
Co-authored-by: amigo browser <juniperforest1@proton.me>
2025-11-07 19:03:52 +06:00
AntsyLich
aa300cb53e Fix extra padding appearing in reader after user interactions (#2669) 2025-11-07 10:00:59 +00:00
Trevor Paley
855eea2ada Improve WebView multi-window UX (#2662)
- Navigation history for lower windows is preserved when a popup is opened
- Back gesture will close a popup window rather than the entire WebView activity when there is no previous page
- The leftmost close button closes the entire activity as before
- When a popup window is shown, a new button appears to close just that window
2025-11-07 15:26:04 +06:00
Mend Renovate
f4703ed83a Update dependency androidx.core:core-splashscreen to v1.2.0 (#2661) 2025-11-07 15:22:41 +06:00
NGB-Was-Taken
506d51a007 Fix flaky migration tests (#2663) 2025-11-07 15:21:17 +06:00
NGB-Was-Taken
9f9155121c Upload test report as artifact on failure (#2664) 2025-11-07 15:20:32 +06:00
AntsyLich
282110ef21 Release v0.19.3 2025-11-04 13:05:40 +06:00
AntsyLich
ace387f8bf Revert "Update dependency androidx.compose:compose-bom to v2025.10.01 (#2522)"
This reverts commit e8bdf58530.
2025-11-04 13:05:23 +06:00
Weblate (bot)
5e428071c9 Translations update from Hosted Weblate (#2646)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/nl/
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/jv/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ms/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/
Translation: Mihon/Mihon
Translation: Mihon/Mihon Plurals

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Nataniel Dika Kurniawan <hikawaart2@gmail.com>
Co-authored-by: Siebrenvde <siebren@siebrenvde.dev>
2025-11-04 13:04:46 +06:00
AntsyLich
0acd80dd95 Fix long strip reader not scrolling on consecutive taps (#2650) 2025-11-04 06:46:48 +00:00
bapeey
bdb0ce4779 Fix WebView crash introduced in v0.19.2 (#2649) 2025-11-04 11:43:40 +06:00
Mend Renovate
e8bdf58530 Update dependency androidx.compose:compose-bom to v2025.10.01 (#2522) 2025-11-03 10:52:45 +06:00
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
192 changed files with 5539 additions and 2552 deletions

View File

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

View File

@@ -30,7 +30,7 @@ body:
required: true required: true
- label: I have written a short but informative title. - label: I have written a short but informative title.
required: true required: true
- label: I have updated the app to version **[0.19.0](https://github.com/mihonapp/mihon/releases/latest)**. - label: I have updated the app to version **[0.19.3](https://github.com/mihonapp/mihon/releases/latest)**.
required: true required: true
- label: I will fill out all of the requested information in this form. - label: I will fill out all of the requested information in this form.
required: true required: true

View File

@@ -52,7 +52,7 @@ body:
label: Mihon version label: Mihon version
description: You can find your Mihon version in **More → About**. description: You can find your Mihon version in **More → About**.
placeholder: | placeholder: |
Example: "0.19.0" Example: "0.19.3"
validations: validations:
required: true required: true
@@ -95,7 +95,7 @@ body:
required: true required: true
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/). - label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
required: true required: true
- label: I have updated the app to version **[0.19.0](https://github.com/mihonapp/mihon/releases/latest)**. - label: I have updated the app to version **[0.19.3](https://github.com/mihonapp/mihon/releases/latest)**.
required: true required: true
- label: I have filled out all of the requested information in this form, including specific version numbers. - label: I have filled out all of the requested information in this form, including specific version numbers.
required: true required: true

71
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: Build & Test
on:
pull_request:
paths:
- '**'
- '!**.md'
- '!i18n/src/commonMain/moko-resources/**/strings.xml'
- '!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 || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build:
name: Build & Test App
runs-on: 'ubuntu-24.04'
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Dependency Review
if: github.event_name == 'pull_request'
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
- 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: Check code format
run: ./gradlew spotlessCheck
- name: Build app
run: ./gradlew assembleRelease -Pinclude-telemetry -Penable-updater
- name: Run unit tests
id: unit_tests
run: ./gradlew testReleaseUnitTest
- name: Upload test report
if: steps.unit_tests.outcome == 'failure'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: test-report-${{ github.sha }}
path: app/build/reports/tests/testReleaseUnitTest
- name: Upload APK
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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: mapping-${{ github.sha }}
path: app/build/outputs/mapping/release

View File

@@ -1,59 +0,0 @@
name: PR build check
on:
pull_request:
paths:
- '**'
- '!**.md'
- '!i18n/src/commonMain/moko-resources/**/strings.xml'
- '!i18n/src/commonMain/moko-resources/**/plurals.xml'
- 'i18n/src/commonMain/moko-resources/base/strings.xml'
- 'i18n/src/commonMain/moko-resources/base/plurals.xml'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build:
name: Build app
runs-on: 'ubuntu-24.04'
steps:
- name: Clone repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Dependency Review
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
- 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
- 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

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

@@ -10,7 +10,68 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- `Fixed` - for any bug fixes. - `Fixed` - for any bug fixes.
- `Other` - for technical stuff. - `Other` - for technical stuff.
## [Unreleased] ## [v0.19.3] - 2025-11-07
### Improved
- Improved various aspects of the WebView multi window support ([@TheUnlocked](https://github.com/TheUnlocked)) ([#2662](https://github.com/mihonapp/mihon/pull/2662))
### Removed
- Revert "Fix reader tap zones triggering after scrolling was stopped by the user" due to introduction of regression ([@AntsyLich](https://github.com/AntsyLich)) ([#2670](https://github.com/mihonapp/mihon/pull/2670))
### Fixed
- Fix WebView crash introduced in 0.19.2 ([@bapeey](https://github.com/bapeey)) ([#2649](https://github.com/mihonapp/mihon/pull/2649))
- Fix extra padding appearing in reader after user interactions ([@AntsyLich](https://github.com/AntsyLich)) ([#2669](https://github.com/mihonapp/mihon/pull/2669))
- Fix long strip reader not scrolling on consecutive taps ([@AntsyLich](https://github.com/AntsyLich)) ([#2670](https://github.com/mihonapp/mihon/pull/2670))
## [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 ## [v0.19.0] - 2025-08-04
### Added ### Added
@@ -50,7 +111,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)) - 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)) - 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 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 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)) - Fix page number not appearing when opening chapter ([@perokhe](https://github.com/perokhe)) ([#1936](https://github.com/mihonapp/mihon/pull/1936))
@@ -382,8 +443,11 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich)) - Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich))
- Minimum supported Android version to 8 ([@AntsyLich](https://github.com/AntsyLich)) ([`dfb3091`](https://github.com/mihonapp/mihon/commit/dfb3091e380dda3e9bfb64bf5c9a685cf3a03d0e)) - Minimum supported Android version to 8 ([@AntsyLich](https://github.com/AntsyLich)) ([`dfb3091`](https://github.com/mihonapp/mihon/commit/dfb3091e380dda3e9bfb64bf5c9a685cf3a03d0e))
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.19.0...main [unreleased]: https://github.com/mihonapp/mihon/compare/v0.19.3...main
[v0.18.0]: https://github.com/mihonapp/mihon/compare/v0.18.0...v0.19.0 [v0.19.3]: https://github.com/mihonapp/mihon/compare/v0.19.2...v0.19.3
[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.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.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 [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) [![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) [![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) [![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/) [![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 { defaultConfig {
applicationId = "app.mihon" applicationId = "app.mihon"
versionCode = 12 versionCode = 16
versionName = "0.19.0" versionName = "0.19.3"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"") buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
@@ -138,9 +138,9 @@ android {
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
buildConfig = true buildConfig = true
aidl = true
// Disable some unused things // Disable some unused things
aidl = false
renderScript = false renderScript = false
shaders = false shaders = false
} }
@@ -261,7 +261,6 @@ dependencies {
implementation(libs.directionalviewpager) { implementation(libs.directionalviewpager) {
exclude(group = "androidx.viewpager", module = "viewpager") exclude(group = "androidx.viewpager", module = "viewpager")
} }
implementation(libs.insetter)
implementation(libs.richeditor.compose) implementation(libs.richeditor.compose)
implementation(libs.aboutLibraries.compose) implementation(libs.aboutLibraries.compose)
implementation(libs.bundles.voyager) 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( downloadManager.isChapterDownloaded(
dbChapter.name, dbChapter.name,
dbChapter.scanlator, dbChapter.scanlator,
dbChapter.url,
manga.title, manga.title,
manga.source, manga.source,
) )
@@ -121,12 +122,14 @@ class SyncChaptersWithSource(
if (shouldRenameChapter) { if (shouldRenameChapter) {
downloadManager.renameChapter(source, manga, dbChapter, chapter) downloadManager.renameChapter(source, manga, dbChapter, chapter)
} }
var toChangeChapter = dbChapter.copy( var toChangeChapter = dbChapter.copy(
name = chapter.name, name = chapter.name,
chapterNumber = chapter.chapterNumber, chapterNumber = chapter.chapterNumber,
scanlator = chapter.scanlator, scanlator = chapter.scanlator,
sourceOrder = chapter.sourceOrder, sourceOrder = chapter.sourceOrder,
) )
if (chapter.dateUpload != 0L) { if (chapter.dateUpload != 0L) {
toChangeChapter = toChangeChapter.copy(dateUpload = chapter.dateUpload) toChangeChapter = toChangeChapter.copy(dateUpload = chapter.dateUpload)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,9 +2,6 @@ package eu.kanade.presentation.manga.components
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable 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.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -27,18 +24,15 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties 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.presentation.manga.EditCoverAction
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import soup.compose.material.motion.MotionConstants
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.PredictiveBack
import tachiyomi.presentation.core.util.clickableNoIndication import tachiyomi.presentation.core.util.clickableNoIndication
import kotlin.coroutines.cancellation.CancellationException
@Composable @Composable
fun MangaCoverDialog( fun MangaCoverDialog(
@@ -161,32 +152,10 @@ fun MangaCoverDialog(
val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() } val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() }
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() } val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clickableNoIndication(onClick = onDismissRequest) .clickableNoIndication(onClick = onDismissRequest),
.graphicsLayer {
scaleX = scale
scaleY = scale
},
) { ) {
AndroidView( AndroidView(
factory = { factory = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -323,6 +323,11 @@ object SettingsAdvancedScreen : SearchableSettings {
title = stringResource(MR.strings.pref_update_library_manga_titles), title = stringResource(MR.strings.pref_update_library_manga_titles),
subtitle = stringResource(MR.strings.pref_update_library_manga_titles_summary), 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 allCategories by getCategories.subscribe().collectAsState(initial = emptyList())
val downloadPreferences = remember { Injekt.get<DownloadPreferences>() } val downloadPreferences = remember { Injekt.get<DownloadPreferences>() }
val parallelSourceLimit by downloadPreferences.parallelSourceLimit().collectAsState()
val parallelPageLimit by downloadPreferences.parallelPageLimit().collectAsState()
return listOf( return listOf(
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
preference = downloadPreferences.downloadOnlyOverWifi(), preference = downloadPreferences.downloadOnlyOverWifi(),
@@ -51,6 +53,19 @@ object SettingsDownloadScreen : SearchableSettings {
title = stringResource(MR.strings.split_tall_images), title = stringResource(MR.strings.split_tall_images),
subtitle = stringResource(MR.strings.split_tall_images_summary), 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( getDeleteChaptersGroup(
downloadPreferences = downloadPreferences, downloadPreferences = downloadPreferences,
categories = allCategories, categories = allCategories,

View File

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

View File

@@ -183,7 +183,7 @@ private fun SearchResult(
emptySequence() emptySequence()
} }
} }
is Preference.PreferenceItem<*> -> sequenceOf(null to p) is Preference.PreferenceItem<*, *> -> sequenceOf(null to p)
} }
} }
// Don't show info preference // 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.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.extension.ExtensionManager
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@@ -27,6 +28,7 @@ class ExtensionReposScreenModel(
private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(), private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(),
private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(), private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(),
private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(), private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) { ) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE) private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
@@ -53,6 +55,7 @@ class ExtensionReposScreenModel(
fun createRepo(baseUrl: String) { fun createRepo(baseUrl: String) {
screenModelScope.launchIO { screenModelScope.launchIO {
when (val result = createExtensionRepo.await(baseUrl)) { when (val result = createExtensionRepo.await(baseUrl)) {
CreateExtensionRepo.Result.Success -> extensionManager.findAvailableExtensions()
CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl) CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
CreateExtensionRepo.Result.RepoAlreadyExists -> _events.send(RepoEvent.RepoAlreadyExists) CreateExtensionRepo.Result.RepoAlreadyExists -> _events.send(RepoEvent.RepoAlreadyExists)
is CreateExtensionRepo.Result.DuplicateFingerprint -> { is CreateExtensionRepo.Result.DuplicateFingerprint -> {
@@ -93,6 +96,7 @@ class ExtensionReposScreenModel(
fun deleteRepo(baseUrl: String) { fun deleteRepo(baseUrl: String) {
screenModelScope.launchIO { screenModelScope.launchIO {
deleteExtensionRepo.await(baseUrl) deleteExtensionRepo.await(baseUrl)
extensionManager.findAvailableExtensions()
} }
} }

View File

@@ -57,7 +57,7 @@ fun ExtensionRepoCreateDialog(
}, },
text = { text = {
Column { 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( OutlinedTextField(
modifier = Modifier modifier = Modifier

View File

@@ -99,7 +99,7 @@ class DebugInfoScreen : Screen() {
} }
private fun getDeviceInfoGroup(): Preference.PreferenceGroup { 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( it.add(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = "Model", title = "Model",

View File

@@ -6,6 +6,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -15,9 +16,10 @@ import androidx.compose.ui.unit.sp
import eu.kanade.presentation.theme.TachiyomiPreviewTheme import eu.kanade.presentation.theme.TachiyomiPreviewTheme
@Composable @Composable
fun PageIndicatorText( fun ReaderPageIndicator(
currentPage: Int, currentPage: Int,
totalPages: Int, totalPages: Int,
modifier: Modifier = Modifier,
) { ) {
if (currentPage <= 0 || totalPages <= 0) return if (currentPage <= 0 || totalPages <= 0) return
@@ -36,6 +38,7 @@ fun PageIndicatorText(
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = modifier,
) { ) {
Text( Text(
text = text, text = text,
@@ -50,10 +53,10 @@ fun PageIndicatorText(
@PreviewLightDark @PreviewLightDark
@Composable @Composable
private fun PageIndicatorTextPreview() { private fun ReaderPageIndicatorPreview() {
TachiyomiPreviewTheme { TachiyomiPreviewTheme {
Surface { 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.AnimatedVisibility
import androidx.compose.animation.core.tween 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.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons import androidx.compose.foundation.layout.navigationBars
import androidx.compose.material.icons.outlined.Bookmark import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp 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.presentation.reader.components.ChapterNavigator
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer 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.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 @Composable
fun ReaderAppBars( fun ReaderAppBars(
visible: Boolean, visible: Boolean,
fullscreen: Boolean,
mangaTitle: String?, mangaTitle: String?,
chapterTitle: String?, chapterTitle: String?,
@@ -71,83 +70,26 @@ fun ReaderAppBars(
.surfaceColorAtElevation(3.dp) .surfaceColorAtElevation(3.dp)
.copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f) .copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f)
val modifierWithInsetsPadding = if (fullscreen) { Column(modifier = Modifier.fillMaxHeight()) {
Modifier.systemBarsPadding()
} else {
Modifier
}
Column(
modifier = Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = slideInVertically( enter = slideInVertically(initialOffsetY = { -it }, animationSpec = readerBarsSlideAnimationSpec) +
initialOffsetY = { -it }, fadeIn(animationSpec = readerBarsFadeAnimationSpec),
animationSpec = animationSpec, exit = slideOutVertically(targetOffsetY = { -it }, animationSpec = readerBarsSlideAnimationSpec) +
), fadeOut(animationSpec = readerBarsFadeAnimationSpec),
exit = slideOutVertically(
targetOffsetY = { -it },
animationSpec = animationSpec,
),
) { ) {
AppBar( ReaderTopBar(
modifier = modifierWithInsetsPadding modifier = Modifier
.background(backgroundColor)
.clickable(onClick = onClickTopAppBar), .clickable(onClick = onClickTopAppBar),
backgroundColor = backgroundColor, mangaTitle = mangaTitle,
title = mangaTitle, chapterTitle = chapterTitle,
subtitle = chapterTitle,
navigateUp = navigateUp, navigateUp = navigateUp,
actions = { bookmarked = bookmarked,
AppBarActions( onToggleBookmarked = onToggleBookmarked,
actions = persistentListOf<AppBar.AppBarAction>().builder() onOpenInWebView = onOpenInWebView,
.apply { onOpenInBrowser = onOpenInBrowser,
add( onShare = onShare,
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(),
)
},
) )
} }
@@ -155,19 +97,12 @@ fun ReaderAppBars(
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = slideInVertically( enter = slideInVertically(initialOffsetY = { it }, animationSpec = readerBarsSlideAnimationSpec) +
initialOffsetY = { it }, fadeIn(animationSpec = readerBarsFadeAnimationSpec),
animationSpec = animationSpec, exit = slideOutVertically(targetOffsetY = { it }, animationSpec = readerBarsSlideAnimationSpec) +
), fadeOut(animationSpec = readerBarsFadeAnimationSpec),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = animationSpec,
),
) { ) {
Column( Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) {
modifier = modifierWithInsetsPadding,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
ChapterNavigator( ChapterNavigator(
isRtl = isRtl, isRtl = isRtl,
onNextChapter = onNextChapter, onNextChapter = onNextChapter,
@@ -178,8 +113,12 @@ fun ReaderAppBars(
totalPages = totalPages, totalPages = totalPages,
onPageIndexChange = onPageIndexChange, onPageIndexChange = onPageIndexChange,
) )
BottomReaderBar( ReaderBottomBar(
backgroundColor = backgroundColor, modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.padding(horizontal = MaterialTheme.padding.small)
.windowInsetsPadding(WindowInsets.navigationBars),
readingMode = readingMode, readingMode = readingMode,
onClickReadingMode = onClickReadingMode, onClickReadingMode = onClickReadingMode,
orientation = orientation, orientation = orientation,

View File

@@ -1,10 +1,7 @@
package eu.kanade.presentation.reader.appbars package eu.kanade.presentation.reader.appbars
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -12,9 +9,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.res.painterResource
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
@@ -22,8 +18,7 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@Composable @Composable
fun BottomReaderBar( fun ReaderBottomBar(
backgroundColor: Color,
readingMode: ReadingMode, readingMode: ReadingMode,
onClickReadingMode: () -> Unit, onClickReadingMode: () -> Unit,
orientation: ReaderOrientation, orientation: ReaderOrientation,
@@ -31,12 +26,11 @@ fun BottomReaderBar(
cropEnabled: Boolean, cropEnabled: Boolean,
onClickCropBorder: () -> Unit, onClickCropBorder: () -> Unit,
onClickSettings: () -> Unit, onClickSettings: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = Modifier modifier = modifier
.fillMaxWidth() .pointerInput(Unit) {},
.background(backgroundColor)
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically, 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 package eu.kanade.presentation.reader.settings
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -8,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.util.system.hasDisplayCutout
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CheckboxItem import tachiyomi.presentation.core.components.CheckboxItem
import tachiyomi.presentation.core.components.SettingsChipRow import tachiyomi.presentation.core.components.SettingsChipRow
@@ -64,10 +66,11 @@ internal fun ColumnScope.GeneralPage(screenModel: ReaderSettingsScreenModel) {
pref = screenModel.preferences.fullscreen(), 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( CheckboxItem(
label = stringResource(MR.strings.pref_cutout_short), 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, value = flashMillis / ReaderPreferences.MILLI_CONVERSION,
valueRange = 1..15, valueRange = 1..15,
label = stringResource(MR.strings.pref_flash_duration), 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) }, onChange = { flashMillisPref.set(it * ReaderPreferences.MILLI_CONVERSION) },
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
) )
@@ -108,7 +111,7 @@ internal fun ColumnScope.GeneralPage(screenModel: ReaderSettingsScreenModel) {
value = flashInterval, value = flashInterval,
valueRange = 1..10, valueRange = 1..10,
label = stringResource(MR.strings.pref_flash_page_interval), 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 = { onChange = {
flashIntervalPref.set(it) flashIntervalPref.set(it)
}, },

View File

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

View File

@@ -1,46 +1,13 @@
package eu.kanade.presentation.util package eu.kanade.presentation.util
import android.annotation.SuppressLint import androidx.activity.compose.BackHandler
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform 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.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.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.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier 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.ScreenModel
import cafe.adriel.voyager.core.model.ScreenModelStore import cafe.adriel.voyager.core.model.ScreenModelStore
import cafe.adriel.voyager.core.screen.Screen 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.core.stack.StackEvent
import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.ScreenTransitionContent import cafe.adriel.voyager.transitions.ScreenTransitionContent
import eu.kanade.tachiyomi.util.view.getWindowRadius
import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import soup.compose.material.motion.animation.materialSharedAxisXIn import soup.compose.material.motion.animation.materialSharedAxisX
import soup.compose.material.motion.animation.materialSharedAxisXOut
import soup.compose.material.motion.animation.rememberSlideDistance 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 * For invoking back press to the parent activity
*/ */
@SuppressLint("ComposeCompositionLocalUsage")
val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null } val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null }
interface Tab : cafe.adriel.voyager.navigator.tab.Tab { interface Tab : cafe.adriel.voyager.navigator.tab.Tab {
@@ -98,278 +55,41 @@ interface AssistContentScreen {
fun onProvideAssistUrl(): String? fun onProvideAssistUrl(): String?
} }
@OptIn(InternalVoyagerApi::class)
@Composable @Composable
fun DefaultNavigatorScreenTransition( fun DefaultNavigatorScreenTransition(
navigator: Navigator, navigator: Navigator,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val screenCandidatesToDispose = rememberSaveable(saver = screenCandidatesToDisposeSaver()) { val slideDistance = rememberSlideDistance()
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)
ScreenTransition( ScreenTransition(
navigator = navigator, navigator = navigator,
transition = {
materialSharedAxisX(
forward = navigator.lastEvent != StackEvent.Pop,
slideDistance = slideDistance,
)
},
modifier = modifier, 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 @Composable
fun ScreenTransition( fun ScreenTransition(
navigator: Navigator, navigator: Navigator,
transition: AnimatedContentTransitionScope<Screen>.() -> ContentTransform,
modifier: Modifier = Modifier, 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() }, content: ScreenTransitionContent = { it.Content() },
) { ) {
val view = LocalView.current AnimatedContent(
val viewConfig = LocalViewConfiguration.current targetState = navigator.lastItem,
val scope = rememberCoroutineScope() transitionSpec = transition,
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(
modifier = modifier, modifier = modifier,
transitionSpec = { label = "transition",
val pop = navigator.lastEvent == StackEvent.Pop || state.isPredictiveBack ) { screen ->
ContentTransform( navigator.saveableState("transition", screen) {
targetContentEnter = if (pop) { content(screen)
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)
} }
} }
}
BackHandler(enabled = navigator.canPop, onBack = navigator::pop)
@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()) },
)
} }

View File

@@ -2,8 +2,10 @@ package eu.kanade.presentation.webview
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Message
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebView import android.webkit.WebView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -19,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -26,17 +29,23 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp 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.AccompanistWebViewClient
import com.kevinnzou.web.LoadingState import com.kevinnzou.web.LoadingState
import com.kevinnzou.web.WebContent
import com.kevinnzou.web.WebView import com.kevinnzou.web.WebView
import com.kevinnzou.web.rememberWebViewNavigator import com.kevinnzou.web.WebViewNavigator
import com.kevinnzou.web.rememberWebViewState import com.kevinnzou.web.WebViewState
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.WarningBanner import eu.kanade.presentation.components.WarningBanner
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getHtml import eu.kanade.tachiyomi.util.system.getHtml
import eu.kanade.tachiyomi.util.system.setDefaultSettings import eu.kanade.tachiyomi.util.system.setDefaultSettings
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -44,6 +53,18 @@ import kotlinx.coroutines.launch
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
class WebViewWindow(webContent: WebContent, val navigator: WebViewNavigator) {
var state by mutableStateOf(WebViewState(webContent))
var popupMessage: Message? = null
private set
var webView: WebView? = null
constructor(popupMessage: Message, navigator: WebViewNavigator) : this(WebContent.NavigatorOnly, navigator) {
this.popupMessage = popupMessage
}
}
@Composable @Composable
fun WebViewScreenContent( fun WebViewScreenContent(
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
@@ -55,8 +76,20 @@ fun WebViewScreenContent(
headers: Map<String, String> = emptyMap(), headers: Map<String, String> = emptyMap(),
onUrlChange: (String) -> Unit = {}, onUrlChange: (String) -> Unit = {},
) { ) {
val state = rememberWebViewState(url = url, additionalHttpHeaders = headers) val coroutineScope = rememberCoroutineScope()
val navigator = rememberWebViewNavigator()
val windowStack = remember {
mutableStateStackOf(
WebViewWindow(
WebContent.Url(url = url, additionalHttpHeaders = headers),
WebViewNavigator(coroutineScope),
),
)
}
val currentWindow = windowStack.lastItemOrNull!!
val navigator = currentWindow.navigator
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -97,31 +130,67 @@ fun WebViewScreenContent(
view: WebView?, view: WebView?,
request: WebResourceRequest?, request: WebResourceRequest?,
): Boolean { ): Boolean {
request?.let { val url = request?.url?.toString() ?: return false
// Don't attempt to open blobs as webpages
if (it.url.toString().startsWith("blob:http")) {
return false
}
// Ignore intents urls // Ignore intents urls
if (it.url.toString().startsWith("intent://")) { if (url.startsWith("intent://")) return true
// Only open valid web urls
if (url.startsWith("http") || url.startsWith("https")) {
if (url != view?.url) {
view?.loadUrl(url, headers)
return true return true
} }
// Continue with request, but with custom headers
view?.loadUrl(it.url.toString(), headers)
} }
return super.shouldOverrideUrlLoading(view, request)
return false
} }
} }
} }
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, WebViewNavigator(coroutineScope)))
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
}
val popState = remember<() -> Unit> {
{
if (windowStack.size == 1) {
onNavigateUp()
} else {
windowStack.pop()
}
}
}
BackHandler(windowStack.size > 1, popState)
Scaffold( Scaffold(
topBar = { topBar = {
Box { Box {
Column { Column {
AppBar( AppBar(
title = state.pageTitle ?: initialTitle, title = currentWindow.state.pageTitle ?: initialTitle,
subtitle = currentUrl, subtitle = currentUrl,
navigateUp = onNavigateUp, navigateUp = onNavigateUp,
navigationIcon = Icons.Outlined.Close, navigationIcon = Icons.Outlined.Close,
@@ -164,7 +233,18 @@ fun WebViewScreenContent(
title = stringResource(MR.strings.pref_clear_cookies), title = stringResource(MR.strings.pref_clear_cookies),
onClick = { onClearCookies(currentUrl) }, onClick = { onClearCookies(currentUrl) },
), ),
), ).builder().apply {
if (windowStack.size > 1) {
add(
0,
AppBar.Action(
title = stringResource(MR.strings.action_webview_close_tab),
icon = ImageVector.vectorResource(R.drawable.ic_tab_close_24px),
onClick = popState,
),
)
}
}.build(),
) )
}, },
) )
@@ -186,7 +266,7 @@ fun WebViewScreenContent(
} }
} }
} }
when (val loadingState = state.loadingState) { when (val loadingState = currentWindow.state.loadingState) {
is LoadingState.Initializing -> LinearProgressIndicator( is LoadingState.Initializing -> LinearProgressIndicator(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -203,27 +283,55 @@ fun WebViewScreenContent(
} }
}, },
) { contentPadding -> ) { contentPadding ->
WebView( // We need to key the WebView composable to the window object since simply updating the WebView composable will
state = state, // not cause it to re-invoke the WebView factory and render the new current window's WebView. This lets us
modifier = Modifier // completely reset the WebView composable when the current window switches.
.fillMaxSize() key(currentWindow) {
.padding(contentPadding), WebView(
navigator = navigator, state = currentWindow.state,
onCreated = { webView -> modifier = Modifier
webView.setDefaultSettings() .fillMaxSize()
.padding(contentPadding),
navigator = navigator,
onCreated = { webView ->
webView.setDefaultSettings()
// Debug mode (chrome://inspect/#devices) // Debug mode (chrome://inspect/#devices)
if (BuildConfig.DEBUG && if (BuildConfig.DEBUG &&
0 != webView.context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE 0 != webView.context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE
) { ) {
WebView.setWebContentsDebuggingEnabled(true) WebView.setWebContentsDebuggingEnabled(true)
} }
headers["user-agent"]?.let { headers["user-agent"]?.let {
webView.settings.userAgentString = it webView.settings.userAgentString = it
} }
}, },
client = webClient, 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.content = 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( val pendingIntent = PendingIntent.getBroadcast(
this@App, this@App,
0, 0,
Intent(ACTION_DISABLE_INCOGNITO_MODE), Intent(ACTION_DISABLE_INCOGNITO_MODE).setPackage(BuildConfig.APPLICATION_ID),
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
) )
setContentIntent(pendingIntent) setContentIntent(pendingIntent)
@@ -220,8 +220,8 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
// Override the value passed as X-Requested-With in WebView requests // Override the value passed as X-Requested-With in WebView requests
val stackTrace = Looper.getMainLooper().thread.stackTrace val stackTrace = Looper.getMainLooper().thread.stackTrace
val isChromiumCall = stackTrace.any { trace -> val isChromiumCall = stackTrace.any { trace ->
trace.className.equals("org.chromium.base.BuildInfo", ignoreCase = true) && trace.className.lowercase() in setOf("org.chromium.base.buildinfo", "org.chromium.base.apkinfo") &&
setOf("getAll", "getPackageName", "<init>").any { trace.methodName.equals(it, ignoreCase = true) } trace.methodName.lowercase() in setOf("getall", "getpackagename", "<init>")
} }
if (isChromiumCall) return WebViewUtil.spoofedPackageName(applicationContext) 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.source.model.Page
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.saveTo import eu.kanade.tachiyomi.util.storage.saveTo
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import logcat.LogPriority import logcat.LogPriority
import okhttp3.Response import okhttp3.Response
@@ -115,7 +114,7 @@ class ChapterCache(
fun isImageInCache(imageUrl: String): Boolean { fun isImageInCache(imageUrl: String): Boolean {
return try { return try {
diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)).use { it != null } diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)).use { it != null }
} catch (e: IOException) { } catch (_: IOException) {
false false
} }
} }
@@ -147,7 +146,7 @@ class ChapterCache(
try { try {
// Get editor from md5 key. // Get editor from md5 key.
val key = DiskUtil.hashKeyForDisk(imageUrl) 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. // Get OutputStream and write image with Okio.
response.body.source().saveTo(editor.newOutputStream(0)) 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 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 the url of the chapter to query
* @param mangaTitle the title of the manga to query. * @param mangaTitle the title of the manga to query.
* @param sourceId the id of the source of the chapter. * @param sourceId the id of the source of the chapter.
* @param skipCache whether to skip the directory cache and check in the filesystem. * @param skipCache whether to skip the directory cache and check in the filesystem.
@@ -135,13 +136,14 @@ class DownloadCache(
fun isChapterDownloaded( fun isChapterDownloaded(
chapterName: String, chapterName: String,
chapterScanlator: String?, chapterScanlator: String?,
chapterUrl: String,
mangaTitle: String, mangaTitle: String,
sourceId: Long, sourceId: Long,
skipCache: Boolean, skipCache: Boolean,
): Boolean { ): Boolean {
if (skipCache) { if (skipCache) {
val source = sourceManager.getOrStub(sourceId) val source = sourceManager.getOrStub(sourceId)
return provider.findChapterDir(chapterName, chapterScanlator, mangaTitle, source) != null return provider.findChapterDir(chapterName, chapterScanlator, chapterUrl, mangaTitle, source) != null
} }
renewCache() renewCache()
@@ -153,6 +155,7 @@ class DownloadCache(
return provider.getValidChapterDirNames( return provider.getValidChapterDirNames(
chapterName, chapterName,
chapterScanlator, chapterScanlator,
chapterUrl,
).any { it in mangaDir.chapterDirs } ).any { it in mangaDir.chapterDirs }
} }
} }
@@ -233,7 +236,7 @@ class DownloadCache(
rootDownloadsDirMutex.withLock { rootDownloadsDirMutex.withLock {
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: 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) { if (it in mangaDir.chapterDirs) {
mangaDir.chapterDirs -= it mangaDir.chapterDirs -= it
} }
@@ -254,7 +257,7 @@ class DownloadCache(
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return
chapters.forEach { chapter -> chapters.forEach { chapter ->
provider.getValidChapterDirNames(chapter.name, chapter.scanlator).forEach { provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).forEach {
if (it in mangaDir.chapterDirs) { if (it in mangaDir.chapterDirs) {
mangaDir.chapterDirs -= it mangaDir.chapterDirs -= it
} }

View File

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

View File

@@ -3,12 +3,14 @@ package eu.kanade.tachiyomi.data.download
import android.content.Context import android.content.Context
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.lang.Hash.md5
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.displayablePath import tachiyomi.core.common.storage.displayablePath
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.storage.service.StorageManager import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -25,6 +27,7 @@ import java.io.IOException
class DownloadProvider( class DownloadProvider(
private val context: Context, private val context: Context,
private val storageManager: StorageManager = Injekt.get(), private val storageManager: StorageManager = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(),
) { ) {
private val downloadsDir: UniFile? private val downloadsDir: UniFile?
@@ -96,9 +99,15 @@ class DownloadProvider(
* @param mangaTitle the title of the manga to query. * @param mangaTitle the title of the manga to query.
* @param source the source of the chapter. * @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) val mangaDir = findMangaDir(mangaTitle, source)
return getValidChapterDirNames(chapterName, chapterScanlator).asSequence() return getValidChapterDirNames(chapterName, chapterScanlator, chapterUrl).asSequence()
.mapNotNull { mangaDir?.findFile(it) } .mapNotNull { mangaDir?.findFile(it) }
.firstOrNull() .firstOrNull()
} }
@@ -113,7 +122,7 @@ class DownloadProvider(
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): Pair<UniFile?, List<UniFile>> { fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): Pair<UniFile?, List<UniFile>> {
val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList() val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList()
return mangaDir to chapters.mapNotNull { chapter -> return mangaDir to chapters.mapNotNull { chapter ->
getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence() getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).asSequence()
.mapNotNull { mangaDir.findFile(it) } .mapNotNull { mangaDir.findFile(it) }
.firstOrNull() .firstOrNull()
} }
@@ -125,7 +134,10 @@ class DownloadProvider(
* @param source the source to query. * @param source the source to query.
*/ */
fun getSourceDirName(source: Source): String { 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. * @param mangaTitle the title of the manga to query.
*/ */
fun getMangaDirName(mangaTitle: String): String { 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. * Returns the chapter directory name for a chapter.
* *
* @param chapterName the name of the chapter to query. * @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 { fun getChapterDirName(
val newChapterName = sanitizeChapterName(chapterName) chapterName: String,
return DiskUtil.buildValidFilename( 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 { when {
!chapterScanlator.isNullOrBlank() -> "${chapterScanlator}_$newChapterName" !chapterScanlator.isNullOrBlank() -> "${chapterScanlator}_$sanitizedChapterName"
else -> newChapterName 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 { fun isChapterDirNameChanged(oldChapter: Chapter, newChapter: Chapter): Boolean {
return oldChapter.name != newChapter.name || return getChapterDirName(oldChapter.name, oldChapter.scanlator, oldChapter.url) !=
oldChapter.scanlator?.takeIf { it.isNotBlank() } != newChapter.scanlator?.takeIf { it.isNotBlank() } getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
} }
/** /**
* Returns valid downloaded chapter directory names. * Returns valid downloaded chapter directory names.
* *
* @param chapterName the name of the chapter to query. * @param chapter the domain chapter object.
* @param chapterScanlator scanlator of the chapter to query
*/ */
fun getValidChapterDirNames(chapterName: String, chapterScanlator: String?): List<String> { fun getValidChapterDirNames(chapterName: String, chapterScanlator: String?, chapterUrl: String): List<String> {
val chapterDirName = getChapterDirName(chapterName, chapterScanlator) val chapterDirName = getChapterDirName(chapterName, chapterScanlator, chapterUrl)
return buildList(2) { val legacyChapterDirNames = getLegacyChapterDirNames(chapterName, chapterScanlator, chapterUrl)
return buildList {
// Folder of images // Folder of images
add(chapterDirName) add(chapterDirName)
// Archived chapters // Archived chapters
add("$chapterDirName.cbz") add("$chapterDirName.cbz")
// any legacy names
legacyChapterDirNames.forEach {
add(it)
add("$it.cbz")
}
} }
} }
} }

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,8 @@ data class KitsuListSearchResult(
val manga = included[0].attributes val manga = included[0].attributes
return TrackSearch.create(TrackerManager.KITSU).apply { return TrackSearch.create(TrackerManager.KITSU).apply {
remote_id = userData.id remote_id = included[0].id
library_id = userData.id
title = manga.canonicalTitle title = manga.canonicalTitle
total_chapters = manga.chapterCount ?: 0 total_chapters = manga.chapterCount ?: 0
cover_url = manga.posterImage?.original ?: "" 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 { 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.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
return track return track
@@ -88,14 +88,13 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
override suspend fun match(manga: DomainManga): TrackSearch? = override suspend fun match(manga: DomainManga): TrackSearch? =
try { try {
api.getTrackSearch(manga.url) api.getTrackSearch(manga.url.getMangaId())
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean = source?.let { override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean =
accept(it) track.remoteUrl == manga.url && source?.let { accept(it) } == true
} == true
override fun migrateTrack(track: DomainTrack, manga: DomainManga, newSource: Source): DomainTrack? = override fun migrateTrack(track: DomainTrack, manga: DomainManga, newSource: Source): DomainTrack? =
if (accept(newSource)) { if (accept(newSource)) {
@@ -103,4 +102,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
} else { } else {
null null
} }
private fun String.getMangaId(): Long =
this.substringAfterLast('/').toLong()
} }

View File

@@ -1,22 +1,22 @@
package eu.kanade.tachiyomi.data.track.suwayomi 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.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Credentials import kotlinx.serialization.json.addAll
import okhttp3.Dns import kotlinx.serialization.json.buildJsonObject
import okhttp3.FormBody import kotlinx.serialization.json.put
import okhttp3.Headers import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@@ -25,79 +25,147 @@ import java.security.MessageDigest
class SuwayomiApi(private val trackId: Long) { class SuwayomiApi(private val trackId: Long) {
private val network: NetworkHelper by injectLazy()
private val json: Json by injectLazy() private val json: Json by injectLazy()
private val client: OkHttpClient = private val sourceManager: SourceManager by injectLazy()
network.client.newBuilder() private val source: HttpSource by lazy { (sourceManager.get(sourceId) as HttpSource) }
.dns(Dns.SYSTEM) // don't use DNS over HTTPS as it breaks IP addressing private val client: OkHttpClient by lazy { source.client }
.build() 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 { suspend fun getTrackSearch(mangaId: Long): TrackSearch = withIOContext {
if (basePassword.isNotEmpty() && baseLogin.isNotEmpty()) { val query = """
val credentials = Credentials.basic(baseLogin, basePassword) |query GetManga(${'$'}mangaId: Int!) {
add("Authorization", credentials) | 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) { val manga = with(json) {
client.newCall(GET("$url/full", headers)) client.newCall(
POST(
apiUrl,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess() .awaitSuccess()
.parseAs<MangaDataClass>() .parseAs<GetMangaResult>()
.data
.entry
} }
TrackSearch.create(trackId).apply { TrackSearch.create(trackId).apply {
remote_id = mangaId
title = manga.title title = manga.title
cover_url = "$url/thumbnail" cover_url = "$baseUrl/${manga.thumbnailUrl}"
summary = manga.description.orEmpty() summary = manga.description.orEmpty()
tracking_url = url tracking_url = "$baseUrl/manga/$mangaId"
total_chapters = manga.chapterCount total_chapters = manga.chapters.totalCount.toLong()
publishing_status = manga.status publishing_status = manga.status.name
last_chapter_read = manga.lastChapterRead?.chapterNumber ?: 0.0 last_chapter_read = manga.latestReadChapter?.chapterNumber ?: 0.0
status = when (manga.unreadCount) { status = when (manga.unreadCount) {
manga.chapterCount -> Suwayomi.UNREAD manga.chapters.totalCount -> Suwayomi.UNREAD
0L -> Suwayomi.COMPLETED 0 -> Suwayomi.COMPLETED
else -> Suwayomi.READING else -> Suwayomi.READING
} }
} }
} }
suspend fun updateProgress(track: Track): Track { suspend fun updateProgress(track: Track): Track {
val url = track.tracking_url val mangaId = track.remote_id
val chapters = with(json) {
client.newCall(GET("$url/chapters", headers)) val chaptersQuery = """
.awaitSuccess() |query GetMangaUnreadChapters(${'$'}mangaId: Int!) {
.parseAs<List<ChapterDataClass>>() | 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( val markQuery = """
PUT( |mutation MarkChaptersRead(${'$'}chapters: [Int!]!) {
"$url/chapter/$lastChapterIndex", | updateChapters(input: {ids: ${'$'}chapters, patch: {isRead: true}}) {
headers, | chapters {
FormBody.Builder(Charset.forName("utf8")) | id
.add("markPrevRead", "true") | }
.add("read", "true") | }
.build(), |}
), """.trimMargin()
).awaitSuccess() 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 { 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 (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 { companion object {
Injekt.get<Application>().getSharedPreferences("source_$sourceId", Context.MODE_PRIVATE) 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 package eu.kanade.tachiyomi.data.track.suwayomi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable 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 @Serializable
data class SourceDataClass( public data class MangaFragment(
val id: String, public val artist: String?,
val name: String, public val author: String?,
val lang: String, public val description: String?,
val iconUrl: 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 */ @Serializable
val supportsLatest: Boolean, public data class LatestUploadedChapter(
public val uploadDate: Long,
)
/** The Source implements [ConfigurableSource] */ @Serializable
val isConfigurable: Boolean, public data class LatestFetchedChapter(
public val fetchedAt: Long,
)
/** The Source class has a @Nsfw annotation */ @Serializable
val isNsfw: Boolean, public data class LatestReadChapter(
public val lastReadAt: Long,
public val chapterNumber: Double,
)
}
/** A nicer version of [name] */ @Serializable
val displayName: String, public data class GetMangaResult(
public val data: GetMangaData,
) )
@Serializable @Serializable
data class MangaDataClass( public data class GetMangaData(
val id: Int, @SerialName("manga")
val sourceId: String, public val entry: MangaFragment,
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?,
) )
@Serializable @Serializable
data class ChapterDataClass( public data class GetMangaUnreadChaptersEntry(
val id: Int, public val nodes: List<GetMangaUnreadChaptersNode>,
val url: String, )
val name: String,
val uploadDate: Long, @Serializable
val chapterNumber: Double, public data class GetMangaUnreadChaptersNode(
val scanlator: String?, public val id: Int,
val mangaId: Int, public val chapterNumber: Double,
)
/** chapter is read */
val read: Boolean, @Serializable
public data class GetMangaUnreadChaptersResult(
/** chapter is bookmarked */ public val data: GetMangaUnreadChaptersData,
val bookmarked: Boolean, )
/** last read page, zero means not read/no data */ @Serializable
val lastPageRead: Int, public data class GetMangaUnreadChaptersData(
@SerialName("chapters")
/** last read page, zero means not read/no data */ public val entry: GetMangaUnreadChaptersEntry,
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>,
) )

View File

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

View File

@@ -1,27 +1,73 @@
package eu.kanade.tachiyomi.extension.installer package eu.kanade.tachiyomi.extension.installer
import android.app.Service 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.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.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.getUriSize
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import mihon.app.shizuku.IShellInterface
import mihon.app.shizuku.ShellInterface
import rikka.shizuku.Shizuku import rikka.shizuku.Shizuku
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import java.io.BufferedReader
import java.io.InputStream
class ShizukuInstaller(private val service: Service) : Installer(service) { class ShizukuInstaller(private val service: Service) : Installer(service) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) 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 { private val shizukuDeadListener = Shizuku.OnBinderDeadListener {
logcat { "Shizuku was killed prematurely" } logcat { "Shizuku was killed prematurely" }
service.stopSelf() service.stopSelf()
@@ -31,8 +77,8 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) { override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) { if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) {
if (grantResult == PackageManager.PERMISSION_GRANTED) { if (grantResult == PackageManager.PERMISSION_GRANTED) {
ready = true
checkQueue() checkQueue()
Shizuku.bindUserService(shizukuArgs, connection)
} else { } else {
service.stopSelf() 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 var ready = false
override fun processEntry(entry: Entry) { override fun processEntry(entry: Entry) {
super.processEntry(entry) super.processEntry(entry)
scope.launch { try {
var sessionId: String? = null shellInterface?.install(
try { service.contentResolver.openAssetFileDescriptor(entry.uri, "r"),
val size = service.getUriSize(entry.uri) ?: throw IllegalStateException() )
service.contentResolver.openInputStream(entry.uri)!!.use { } catch (e: Exception) {
val userId = Process.myUserHandle().hashCode() logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
val createCommand = "pm install-create --user $userId -r -i ${service.packageName} -S $size" continueQueue(InstallStep.Error)
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)
}
} }
} }
@@ -84,41 +124,26 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
override fun onDestroy() { override fun onDestroy() {
Shizuku.removeBinderDeadListener(shizukuDeadListener) Shizuku.removeBinderDeadListener(shizukuDeadListener)
Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener) Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener)
Shizuku.unbindUserService(shizukuArgs, connection, true)
service.unregisterReceiver(receiver)
logcat { "ShizukuInstaller destroy" }
scope.cancel() scope.cancel()
super.onDestroy() 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 { init {
Shizuku.addBinderDeadListener(shizukuDeadListener) Shizuku.addBinderDeadListener(shizukuDeadListener)
ready = if (Shizuku.pingBinder()) {
if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { ContextCompat.registerReceiver(
true service,
} else { receiver,
Shizuku.addRequestPermissionResultListener(shizukuPermissionListener) IntentFilter(ACTION_INSTALL_RESULT),
Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE) ContextCompat.RECEIVER_EXPORTED,
false )
}
} else { initShizuku()
logcat(LogPriority.ERROR) { "Shizuku is not ready to use" }
service.toast(MR.strings.ext_installer_shizuku_stopped)
service.stopSelf()
false
}
} }
} }
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 14045 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) { fun cancelInstallUpdateExtension(extension: Extension) {
extensionManager.cancelInstallUpdateExtension(extension) extensionManager.cancelInstallUpdateExtension(extension)
removeDownloadState(extension)
} }
private fun addDownloadState(extension: Extension, installStep: InstallStep) { private fun addDownloadState(extension: Extension, installStep: InstallStep) {

View File

@@ -1,10 +1,8 @@
package eu.kanade.tachiyomi.ui.home 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.AnimatedContent
import androidx.compose.animation.AnimatedVisibility 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.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
@@ -24,19 +22,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.lerp
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator 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.combine
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import soup.compose.material.motion.MotionConstants
import soup.compose.material.motion.animation.materialFadeThroughIn import soup.compose.material.motion.animation.materialFadeThroughIn
import soup.compose.material.motion.animation.materialFadeThroughOut import soup.compose.material.motion.animation.materialFadeThroughOut
import tachiyomi.domain.library.service.LibraryPreferences 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.NavigationRail
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.util.PredictiveBack
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import kotlin.coroutines.cancellation.CancellationException
object HomeScreen : Screen() { object HomeScreen : Screen() {
@@ -93,8 +82,6 @@ object HomeScreen : Screen() {
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
var scale by remember { mutableFloatStateOf(1f) }
TabNavigator( TabNavigator(
tab = LibraryTab, tab = LibraryTab,
key = TabNavigatorKey, key = TabNavigatorKey,
@@ -134,11 +121,7 @@ object HomeScreen : Screen() {
Box( Box(
modifier = Modifier modifier = Modifier
.padding(contentPadding) .padding(contentPadding)
.consumeWindowInsets(contentPadding) .consumeWindowInsets(contentPadding),
.graphicsLayer {
scaleX = scale
scaleY = scale
},
) { ) {
AnimatedContent( AnimatedContent(
targetState = tabNavigator.current, targetState = tabNavigator.current,
@@ -158,31 +141,7 @@ object HomeScreen : Screen() {
val goToLibraryTab = { tabNavigator.current = LibraryTab } val goToLibraryTab = { tabNavigator.current = LibraryTab }
var handlingBack by remember { mutableStateOf(false) } BackHandler(enabled = tabNavigator.current != LibraryTab, onBack = goToLibraryTab)
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
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch { launch {

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.reader package eu.kanade.tachiyomi.ui.reader
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.app.assist.AssistContent import android.app.assist.AssistContent
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
@@ -16,40 +15,45 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View
import android.view.View.LAYER_TYPE_HARDWARE import android.view.View.LAYER_TYPE_HARDWARE
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row 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.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.graphics.ColorUtils import androidx.core.graphics.Insets
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.transition.doOnEnd import androidx.core.transition.doOnEnd
import androidx.core.view.WindowCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.google.android.material.elevation.SurfaceColors
import com.google.android.material.transition.platform.MaterialContainerTransform import com.google.android.material.transition.platform.MaterialContainerTransform
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.core.util.ifSourcesLoaded import eu.kanade.core.util.ifSourcesLoaded
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.presentation.reader.DisplayRefreshHost import eu.kanade.presentation.reader.DisplayRefreshHost
import eu.kanade.presentation.reader.OrientationSelectDialog import eu.kanade.presentation.reader.OrientationSelectDialog
import eu.kanade.presentation.reader.PageIndicatorText
import eu.kanade.presentation.reader.ReaderContentOverlay import eu.kanade.presentation.reader.ReaderContentOverlay
import eu.kanade.presentation.reader.ReaderPageActionsDialog import eu.kanade.presentation.reader.ReaderPageActionsDialog
import eu.kanade.presentation.reader.ReaderPageIndicator
import eu.kanade.presentation.reader.ReadingModeSelectDialog import eu.kanade.presentation.reader.ReadingModeSelectDialog
import eu.kanade.presentation.reader.appbars.ReaderAppBars import eu.kanade.presentation.reader.appbars.ReaderAppBars
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog 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.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.webview.WebViewActivity 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.isNightMode
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.setComposeContent import eu.kanade.tachiyomi.util.view.setComposeContent
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -121,8 +124,6 @@ class ReaderActivity : BaseActivity() {
val viewModel by viewModels<ReaderViewModel>() val viewModel by viewModels<ReaderViewModel>()
private var assistUrl: String? = null private var assistUrl: String? = null
private val hasCutout by lazy { hasDisplayCutout() }
/** /**
* Configuration at reader level, like background color or forced orientation. * Configuration at reader level, like background color or forced orientation.
*/ */
@@ -132,7 +133,7 @@ class ReaderActivity : BaseActivity() {
private var readingModeToast: Toast? = null private var readingModeToast: Toast? = null
private val displayRefreshHost = DisplayRefreshHost() 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 private var loadingIndicator: ReaderProgressIndicator? = null
@@ -146,7 +147,7 @@ class ReaderActivity : BaseActivity() {
registerSecureActivity(this) registerSecureActivity(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
overrideActivityTransition( overrideActivityTransition(
Activity.OVERRIDE_TRANSITION_OPEN, OVERRIDE_TRANSITION_OPEN,
R.anim.shared_axis_x_push_enter, R.anim.shared_axis_x_push_enter,
R.anim.shared_axis_x_push_exit, 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) 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) super.onCreate(savedInstanceState)
binding = ReaderActivityBinding.inflate(layoutInflater) binding = ReaderActivityBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
binding.setComposeOverlay()
if (viewModel.needsInit()) { if (viewModel.needsInit()) {
val manga = intent.extras?.getLong("manga", -1) ?: -1L val manga = intent.extras?.getLong("manga", -1) ?: -1L
@@ -181,7 +189,7 @@ class ReaderActivity : BaseActivity() {
} }
config = ReaderConfig() config = ReaderConfig()
initializeMenu() setMenuVisibility(viewModel.state.value.menuVisible)
// Finish when incognito mode is disabled // Finish when incognito mode is disabled
preferences.incognitoMode().changes() preferences.incognitoMode().changes()
@@ -238,6 +246,92 @@ class ReaderActivity : BaseActivity() {
.launchIn(lifecycleScope) .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. * Called when the activity is destroyed. Cleans up the viewer, configuration and any view.
*/ */
@@ -289,7 +383,7 @@ class ReaderActivity : BaseActivity() {
super.finish() super.finish()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
overrideActivityTransition( overrideActivityTransition(
Activity.OVERRIDE_TRANSITION_CLOSE, OVERRIDE_TRANSITION_CLOSE,
R.anim.shared_axis_x_pop_enter, R.anim.shared_axis_x_pop_enter,
R.anim.shared_axis_x_pop_exit, R.anim.shared_axis_x_pop_exit,
) )
@@ -327,180 +421,82 @@ class ReaderActivity : BaseActivity() {
return handled || super.dispatchGenericMotionEvent(event) return handled || super.dispatchGenericMotionEvent(event)
} }
/** @Composable
* Initializes the reader menu. It sets up click listeners and the initial visibility. private fun ContentOverlay(state: ReaderViewModel.State) {
*/ val flashOnPageChange by readerPreferences.flashOnPageChange().collectAsState()
private fun initializeMenu() {
binding.pageNumber.setComposeContent {
val state by viewModel.state.collectAsState()
val showPageNumber by viewModel.readerPreferences.showPageNumber().collectAsState()
if (!state.menuVisible && showPageNumber) { val colorOverlayEnabled by readerPreferences.colorFilter().collectAsState()
PageIndicatorText( val colorOverlay by readerPreferences.colorFilterValue().collectAsState()
currentPage = state.currentPage, val colorOverlayMode by readerPreferences.colorFilterMode().collectAsState()
totalPages = state.totalPages, val colorOverlayBlendMode = remember(colorOverlayMode) {
) ReaderPreferences.ColorFilterMode.getOrNull(colorOverlayMode)?.second
}
} }
binding.dialogRoot.setComposeContent { ReaderContentOverlay(
val state by viewModel.state.collectAsState() brightness = state.brightnessOverlayValue,
val settingsScreenModel = remember { color = colorOverlay.takeIf { colorOverlayEnabled },
ReaderSettingsScreenModel( colorBlendMode = colorOverlayBlendMode,
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
) )
@Suppress("DEPRECATION")
window.statusBarColor = toolbarColor if (flashOnPageChange) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { DisplayRefreshHost(hostState = displayRefreshHost)
@Suppress("DEPRECATION") }
window.navigationBarColor = toolbarColor }
@Composable
fun AppBars(state: ReaderViewModel.State) {
if (!ifSourcesLoaded()) {
return
} }
// Set initial visibility val isHttpSource = viewModel.getSource() is HttpSource
setMenuVisibility(viewModel.state.value.menuVisible)
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) viewModel.showMenus(visible)
if (visible) { if (visible) {
windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) } else if (readerPreferences.fullscreen().get()) {
} else { windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
if (readerPreferences.fullscreen().get()) {
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
windowInsetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} }
} }
@@ -542,7 +533,7 @@ class ReaderActivity : BaseActivity() {
binding.viewerContainer.removeAllViews() binding.viewerContainer.removeAllViews()
} }
viewModel.onViewerLoaded(newViewer) viewModel.onViewerLoaded(newViewer)
updateViewerInset(readerPreferences.fullscreen().get()) updateViewerInset(readerPreferences.fullscreen().get(), readerPreferences.drawUnderCutout().get())
binding.viewerContainer.addView(newViewer.getView()) binding.viewerContainer.addView(newViewer.getView())
if (readerPreferences.showReadingMode().get()) { if (readerPreferences.showReadingMode().get()) {
@@ -593,7 +584,7 @@ class ReaderActivity : BaseActivity() {
try { try {
readingModeToast?.cancel() readingModeToast?.cancel()
readingModeToast = toast(ReadingMode.fromPreference(mode).stringRes) readingModeToast = toast(ReadingMode.fromPreference(mode).stringRes)
} catch (e: ArrayIndexOutOfBoundsException) { } catch (_: ArrayIndexOutOfBoundsException) {
logcat(LogPriority.ERROR) { "Unknown reading mode: $mode" } logcat(LogPriority.ERROR) { "Unknown reading mode: $mode" }
} }
} }
@@ -785,16 +776,32 @@ class ReaderActivity : BaseActivity() {
/** /**
* Updates viewer inset depending on fullscreen reader preferences. * Updates viewer inset depending on fullscreen reader preferences.
*/ */
private fun updateViewerInset(fullscreen: Boolean) { private fun updateViewerInset(fullscreen: Boolean, drawUnderCutout: Boolean) {
viewModel.state.value.viewer?.getView()?.applyInsetter { if (!::binding.isInitialized) return
if (!fullscreen) { val view = binding.viewerContainer
type(navigationBars = true, statusBars = true) {
padding() 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. * Class that handles the user preferences of the reader.
*/ */
@@ -847,10 +854,6 @@ class ReaderActivity : BaseActivity() {
.onEach { setDisplayProfile(it) } .onEach { setDisplayProfile(it) }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
readerPreferences.cutoutShort().changes()
.onEach(::setCutoutShort)
.launchIn(lifecycleScope)
readerPreferences.keepScreenOn().changes() readerPreferences.keepScreenOn().changes()
.onEach(::setKeepScreenOn) .onEach(::setKeepScreenOn)
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
@@ -859,14 +862,21 @@ class ReaderActivity : BaseActivity() {
.onEach(::setCustomBrightness) .onEach(::setCustomBrightness)
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
merge(readerPreferences.grayscale().changes(), readerPreferences.invertedColors().changes()) combine(
.onEach { setLayerPaint(readerPreferences.grayscale().get(), readerPreferences.invertedColors().get()) } readerPreferences.grayscale().changes(),
readerPreferences.invertedColors().changes(),
) { grayscale, invertedColors -> grayscale to invertedColors }
.onEach { (grayscale, invertedColors) ->
setLayerPaint(grayscale, invertedColors)
}
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
readerPreferences.fullscreen().changes() combine(
.onEach { readerPreferences.fullscreen().changes(),
WindowCompat.setDecorFitsSystemWindows(window, !it) readerPreferences.drawUnderCutout().changes(),
updateViewerInset(it) ) { fullscreen, drawUnderCutout -> fullscreen to drawUnderCutout }
.onEach { (fullscreen, drawUnderCutout) ->
updateViewerInset(fullscreen, drawUnderCutout)
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
} }
@@ -901,18 +911,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]. * 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.chapter.removeDuplicates
import eu.kanade.tachiyomi.util.editCover import eu.kanade.tachiyomi.util.editCover
import eu.kanade.tachiyomi.util.lang.byteSize 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.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir import eu.kanade.tachiyomi.util.storage.cacheImageDir
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -175,6 +174,7 @@ class ReaderViewModel @JvmOverloads constructor(
!downloadManager.isChapterDownloaded( !downloadManager.isChapterDownloaded(
it.name, it.name,
it.scanlator, it.scanlator,
it.url,
manga.title, manga.title,
manga.source, manga.source,
) )
@@ -184,6 +184,7 @@ class ReaderViewModel @JvmOverloads constructor(
downloadManager.isChapterDownloaded( downloadManager.isChapterDownloaded(
it.name, it.name,
it.scanlator, it.scanlator,
it.url,
manga.title, manga.title,
manga.source, manga.source,
) )
@@ -397,6 +398,7 @@ class ReaderViewModel @JvmOverloads constructor(
val isDownloaded = downloadManager.isChapterDownloaded( val isDownloaded = downloadManager.isChapterDownloaded(
dbChapter.name, dbChapter.name,
dbChapter.scanlator, dbChapter.scanlator,
dbChapter.url,
manga.title, manga.title,
manga.source, manga.source,
skipCache = true, skipCache = true,
@@ -473,6 +475,7 @@ class ReaderViewModel @JvmOverloads constructor(
val isNextChapterDownloaded = downloadManager.isChapterDownloaded( val isNextChapterDownloaded = downloadManager.isChapterDownloaded(
nextChapter.name, nextChapter.name,
nextChapter.scanlator, nextChapter.scanlator,
nextChapter.url,
manga.title, manga.title,
manga.source, manga.source,
) )
@@ -757,7 +760,8 @@ class ReaderViewModel @JvmOverloads constructor(
val chapter = page.chapter.chapter val chapter = page.chapter.chapter
val filenameSuffix = " - ${page.number}" val filenameSuffix = " - ${page.number}"
return DiskUtil.buildValidFilename( 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 ) + filenameSuffix
} }

View File

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

View File

@@ -33,7 +33,13 @@ internal class DownloadPageLoader(
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
val dbChapter = chapter.chapter 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) { return if (chapterPath?.isFile == true) {
getPagesFromArchive(chapterPath) getPagesFromArchive(chapterPath)
} else { } else {

View File

@@ -31,7 +31,7 @@ class ReaderPreferences(
fun fullscreen() = preferenceStore.getBoolean("fullscreen", true) 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) fun keepScreenOn() = preferenceStore.getBoolean("pref_keep_screen_on_key", false)

View File

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

View File

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

View File

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

View File

@@ -15,5 +15,5 @@ fun List<Chapter>.filterDownloaded(manga: Manga): List<Chapter> {
val downloadCache: DownloadCache = Injekt.get() 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.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.view.View
import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.UiPreferences
import eu.kanade.domain.ui.model.TabletUiMode import eu.kanade.domain.ui.model.TabletUiMode
import uy.kohesive.injekt.Injekt 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.). * 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 { fun Activity.hasDisplayCutout(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && return window.decorView.hasDisplayCutout()
window.decorView.rootWindowInsets?.displayCutout != null }
/**
* 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.content.res.Resources
import android.graphics.Rect import android.graphics.Rect
import android.os.Build
import android.view.Gravity import android.view.Gravity
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.RoundedCorner
import android.view.View import android.view.View
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -97,22 +95,3 @@ fun View?.isVisibleOnScreen(): Boolean {
Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels) Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels)
return actualPosition.intersect(screen) 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 // Update categories
if (MigrationFlag.CHAPTER in flags) { if (MigrationFlag.CATEGORY in flags) {
val categoryIds = getCategories.await(current.id).map { it.id } val categoryIds = getCategories.await(current.id).map { it.id }
setMangaCategories.await(target.id, categoryIds) 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(), private val sourceManager: SourceManager = Injekt.get(),
) : StateScreenModel<ScreenModel.State>(State()) { ) : StateScreenModel<ScreenModel.State>(State()) {
init {
screenModelScope.launchIO {
initSources()
mutableState.update { it.copy(isLoading = false) }
}
}
private val sourcesComparator = { includedSources: List<Long> -> private val sourcesComparator = { includedSources: List<Long> ->
compareBy<MigrationSource>( compareBy<MigrationSource>(
{ !it.isSelected }, { !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>) { private fun updateSources(save: Boolean = true, action: (List<MigrationSource>) -> List<MigrationSource>) {
mutableState.update { state -> mutableState.update { state ->
val updatedSources = action(state.sources) 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -49,9 +50,14 @@ internal fun Screen.MigrateMangaDialog(
) { ) {
val scope = rememberCoroutineScope() 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() val state by screenModel.state.collectAsState()
if (state.isMigrated) return
if (state.isMigrating) { if (state.isMigrating) {
LoadingScreen( LoadingScreen(
modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)), modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
@@ -118,15 +124,13 @@ internal fun Screen.MigrateMangaDialog(
} }
private class MigrateDialogScreenModel( private class MigrateDialogScreenModel(
private val current: Manga,
private val target: Manga,
private val sourcePreference: SourcePreferences = Injekt.get(), private val sourcePreference: SourcePreferences = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(), private val coverCache: CoverCache = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(),
private val migrateManga: MigrateMangaUseCase = Injekt.get(), private val migrateManga: MigrateMangaUseCase = Injekt.get(),
) : StateScreenModel<MigrateDialogScreenModel.State>(State()) { ) : StateScreenModel<MigrateDialogScreenModel.State>(State()) {
init { fun init(current: Manga, target: Manga) {
val applicableFlags = buildList { val applicableFlags = buildList {
MigrationFlag.entries.forEach { MigrationFlag.entries.forEach {
val applicable = when (it) { val applicable = when (it) {
@@ -140,7 +144,14 @@ private class MigrateDialogScreenModel(
} }
} }
val selectedFlags = sourcePreference.migrationFlags().get() 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) { fun toggleSelection(flag: MigrationFlag) {
@@ -153,15 +164,21 @@ private class MigrateDialogScreenModel(
} }
suspend fun migrateManga(replace: Boolean) { 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) } mutableState.update { it.copy(isMigrating = true) }
migrateManga(current, target, replace) migrateManga(current, target, replace)
mutableState.update { it.copy(isMigrating = false) } mutableState.update { it.copy(isMigrating = false, isMigrated = true) }
} }
data class State( data class State(
val current: Manga? = null,
val target: Manga? = null,
val applicableFlags: List<MigrationFlag> = emptyList(), val applicableFlags: List<MigrationFlag> = emptyList(),
val selectedFlags: Set<MigrationFlag> = emptySet(), val selectedFlags: Set<MigrationFlag> = emptySet(),
val isMigrating: Boolean = false, 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.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
@@ -200,10 +199,10 @@ fun MigrationListItem(
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
0f to Color.Transparent, 0f to Color.Transparent,
1f to Color(0xAA000000), 1f to MaterialTheme.colorScheme.background,
), ),
) )
.fillMaxHeight(0.33f) .fillMaxHeight(0.4f)
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter), .align(Alignment.BottomCenter),
) )
@@ -214,8 +213,7 @@ fun MigrationListItem(
text = manga.title, text = manga.title,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
maxLines = 2, maxLines = 2,
style = MaterialTheme.typography.labelMedium style = MaterialTheme.typography.labelMedium,
.merge(shadow = Shadow(color = Color.Black, blurRadius = 4f)),
) )
BadgeGroup(modifier = Modifier.padding(4.dp)) { BadgeGroup(modifier = Modifier.padding(4.dp)) {
Badge(text = "$chapterCount") Badge(text = "$chapterCount")

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M476,540L560,456L644,540L700,484L616,400L700,316L644,260L560,344L476,260L420,316L504,400L420,484L476,540ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM320,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640L320,640Q320,640 320,640Q320,640 320,640L320,160Q320,160 320,160Q320,160 320,160Z"/>
</vector>

View File

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

View File

@@ -1,9 +1,11 @@
package mihon.core.migration package mihon.core.migration
import io.kotest.assertions.nondeterministic.eventually
import io.mockk.slot import io.mockk.slot
import io.mockk.spyk import io.mockk.spyk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.newSingleThreadContext
@@ -17,6 +19,7 @@ import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.seconds
class MigratorTest { class MigratorTest {
@@ -26,7 +29,7 @@ class MigratorTest {
lateinit var migrationStrategyFactory: MigrationStrategyFactory lateinit var migrationStrategyFactory: MigrationStrategyFactory
@BeforeEach @BeforeEach
fun initilize() { fun initialize() {
migrationContext = MigrationContext(false) migrationContext = MigrationContext(false)
migrationJobFactory = spyk(MigrationJobFactory(migrationContext, CoroutineScope(Dispatchers.Main + Job()))) migrationJobFactory = spyk(MigrationJobFactory(migrationContext, CoroutineScope(Dispatchers.Main + Job())))
migrationCompletedListener = spyk<MigrationCompletedListener>(block = {}) migrationCompletedListener = spyk<MigrationCompletedListener>(block = {})
@@ -45,7 +48,7 @@ class MigratorTest {
verify { migrationJobFactory.create(capture(migrations)) } verify { migrationJobFactory.create(capture(migrations)) }
assertEquals(1, migrations.captured.size) assertEquals(1, migrations.captured.size)
verify { migrationCompletedListener() } eventually(2.seconds) { verify { migrationCompletedListener() } }
} }
@Test @Test
@@ -86,7 +89,7 @@ class MigratorTest {
verify { migrationJobFactory.create(capture(migrations)) } verify { migrationJobFactory.create(capture(migrations)) }
assertEquals(2, migrations.captured.size) assertEquals(2, migrations.captured.size)
verify { migrationCompletedListener() } eventually(2.seconds) { verify { migrationCompletedListener() } }
} }
@Test @Test
@@ -114,7 +117,7 @@ class MigratorTest {
verify { migrationJobFactory.create(capture(migrations)) } verify { migrationJobFactory.create(capture(migrations)) }
assertEquals(10, migrations.captured.size) assertEquals(10, migrations.captured.size)
verify { migrationCompletedListener() } eventually(2.seconds) { verify { migrationCompletedListener() } }
} }
@Test @Test
@@ -135,11 +138,12 @@ class MigratorTest {
verify { migrationJobFactory.create(capture(migrations)) } verify { migrationJobFactory.create(capture(migrations)) }
assertEquals(2, migrations.captured.size) assertEquals(2, migrations.captured.size)
verify { migrationCompletedListener() } eventually(2.seconds) { verify { migrationCompletedListener() } }
} }
companion object { companion object {
@OptIn(DelicateCoroutinesApi::class)
val mainThreadSurrogate = newSingleThreadContext("UI thread") val mainThreadSurrogate = newSingleThreadContext("UI thread")
@BeforeAll @BeforeAll

View File

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

View File

@@ -2,6 +2,7 @@ package mihon.core.archive
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.parser.Parser
import java.io.Closeable import java.io.Closeable
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
@@ -39,7 +40,7 @@ class EpubReader(private val reader: ArchiveReader) : Closeable by reader {
fun getPackageHref(): String { fun getPackageHref(): String {
val meta = getInputStream(resolveZipPath("META-INF", "container.xml")) val meta = getInputStream(resolveZipPath("META-INF", "container.xml"))
if (meta != null) { 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") val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
if (path != null) { if (path != null) {
return path 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. * Returns the package document where all the files are listed.
*/ */
fun getPackageDocument(ref: String): Document { 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> { fun defaultUserAgent(): Preference<String> {
return preferenceStore.getString( return preferenceStore.getString(
"default_user_agent", "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 com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.lang.Hash
import java.io.File import java.io.File
import java.nio.ByteBuffer
import java.nio.CharBuffer
import java.nio.charset.CodingErrorAction
object DiskUtil { object DiskUtil {
@@ -102,26 +105,84 @@ object DiskUtil {
} }
/** /**
* Mutate the given filename to make it valid for a FAT filesystem, * Transform a filename fragment to make it safe to use on almost
* replacing any invalid characters with "_". This method doesn't allow hidden files (starting * all commonly used filesystems. You can pass an entire filename,
* with a dot), but you can manually add it later. * 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('.', ' ') val name = origName.trim('.', ' ')
if (name.isEmpty()) { if (name.isEmpty()) {
return "(invalid)" return "(invalid)"
} }
val sb = StringBuilder(name.length) val sb = StringBuilder(name.length)
name.forEach { c -> 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) sb.append(c)
} else { } else {
sb.append('_') sb.append('_')
} }
} }
// Even though vfat allows 255 UCS-2 chars, we might eventually write to return truncateToLength(sb.toString(), maxBytes)
// ext4 through a FUSE layer, so use that limit minus 15 reserved characters. }
return sb.toString().take(240)
/**
* 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" const val NOMEDIA_FILE = ".nomedia"
// Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8) // Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8).
const val MAX_FILE_NAME_BYTES = 250 // 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 loadWithOverviewMode = true
cacheMode = WebSettings.LOAD_DEFAULT cacheMode = WebSettings.LOAD_DEFAULT
// Handle popups properly
setSupportMultipleWindows(true)
// Allow zooming // Allow zooming
setSupportZoom(true) setSupportZoom(true)
builtInZoomControls = true builtInZoomControls = true

View File

@@ -52,6 +52,7 @@ class UpdatesRepositoryImpl(
chapterId: Long, chapterId: Long,
chapterName: String, chapterName: String,
scanlator: String?, scanlator: String?,
chapterUrl: String,
read: Boolean, read: Boolean,
bookmark: Boolean, bookmark: Boolean,
lastPageRead: Long, lastPageRead: Long,
@@ -67,6 +68,7 @@ class UpdatesRepositoryImpl(
chapterId = chapterId, chapterId = chapterId,
chapterName = chapterName, chapterName = chapterName,
scanlator = scanlator, scanlator = scanlator,
chapterUrl = chapterUrl,
read = read, read = read,
bookmark = bookmark, bookmark = bookmark,
lastPageRead = lastPageRead, 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._id AS chapterId,
chapters.name AS chapterName, chapters.name AS chapterName,
chapters.scanlator, chapters.scanlator,
chapters.url AS chapterUrl,
chapters.read, chapters.read,
chapters.bookmark, chapters.bookmark,
chapters.last_page_read, chapters.last_page_read,
@@ -31,4 +32,4 @@ SELECT *
FROM updatesView FROM updatesView
WHERE read = :read WHERE read = :read
AND dateUpload > :after AND dateUpload > :after
LIMIT :limit; LIMIT :limit;

View File

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

View File

@@ -37,6 +37,10 @@ class DownloadPreferences(
fun downloadNewUnreadChaptersOnly() = preferenceStore.getBoolean("download_new_unread_chapters_only", false) 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 { companion object {
private const val REMOVE_EXCLUDE_CATEGORIES_PREF_KEY = "remove_exclude_categories" private const val REMOVE_EXCLUDE_CATEGORIES_PREF_KEY = "remove_exclude_categories"
private const val DOWNLOAD_NEW_CATEGORIES_PREF_KEY = "download_new_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 updateMangaTitles() = preferenceStore.getBoolean("pref_update_library_manga_titles", false)
fun disallowNonAsciiFilenames() = preferenceStore.getBoolean("disallow_non_ascii_filenames", false)
// endregion // endregion
enum class ChapterSwipeAction { enum class ChapterSwipeAction {

View File

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

View File

@@ -1,6 +1,6 @@
[versions] [versions]
agp_version = "8.12.0" agp_version = "8.13.0"
lifecycle_version = "2.9.2" lifecycle_version = "2.9.4"
paging_version = "3.3.6" paging_version = "3.3.6"
interpolator_version = "1.0.0" interpolator_version = "1.0.0"
@@ -11,8 +11,8 @@ annotation = "androidx.annotation:annotation:1.9.1"
appcompat = "androidx.appcompat:appcompat:1.7.1" appcompat = "androidx.appcompat:appcompat:1.7.1"
biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05" biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
constraintlayout = "androidx.constraintlayout:constraintlayout:2.2.1" 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" splashscreen = "androidx.core:core-splashscreen:1.2.0"
recyclerview = "androidx.recyclerview:recyclerview:1.4.0" recyclerview = "androidx.recyclerview:recyclerview:1.4.0"
viewpager = "androidx.viewpager:viewpager:1.1.0" viewpager = "androidx.viewpager:viewpager:1.1.0"
profileinstaller = "androidx.profileinstaller:profileinstaller:1.4.1" profileinstaller = "androidx.profileinstaller:profileinstaller:1.4.1"
@@ -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-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle_version" }
lifecycle-runtimektx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", 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-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging_version" }
paging-compose = { module = "androidx.paging:paging-compose", 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" } 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-ext = "androidx.test.ext:junit-ktx:1.3.0"
test-espresso-core = "androidx.test.espresso:espresso-core:3.7.0" test-espresso-core = "androidx.test.espresso:espresso-core:3.7.0"
test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0" test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0"

View File

@@ -1,13 +1,14 @@
[versions] [versions]
compose-bom = "2025.07.00" compose-bom = "2025.09.00"
[libraries] [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" } bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
foundation = { module = "androidx.compose.foundation:foundation" } foundation = { module = "androidx.compose.foundation:foundation" }
animation = { module = "androidx.compose.animation:animation" } animation = { module = "androidx.compose.animation:animation" }
animation-graphics = { module = "androidx.compose.animation:animation-graphics" } animation-graphics = { module = "androidx.compose.animation:animation-graphics" }
runtime = { module = "androidx.compose.runtime:runtime" } runtime = { module = "androidx.compose.runtime:runtime" }
runtime-annotation = { module = "androidx.compose.runtime:runtime-annotation" }
ui-tooling = { module = "androidx.compose.ui:ui-tooling" } ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
ui-util = { module = "androidx.compose.ui:ui-util" } 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] [versions]
kotlin_version = "2.2.0" kotlin_version = "2.2.21"
serialization_version = "1.9.0" serialization_version = "1.9.0"
xml_serialization_version = "0.91.2" xml_serialization_version = "0.91.3"
[libraries] [libraries]
reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin_version" } reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin_version" }

View File

@@ -1,17 +1,17 @@
[versions] [versions]
aboutlib_version = "12.2.4" aboutlib_version = "13.1.0"
leakcanary = "2.14" leakcanary = "2.14"
moko = "0.25.0" moko = "0.25.1"
okhttp_version = "5.1.0" okhttp_version = "5.3.0"
shizuku_version = "13.1.0" shizuku_version = "13.1.5"
sqldelight = "2.1.0" sqldelight = "2.1.0"
sqlite = "2.5.2" sqlite = "2.6.1"
voyager = "1.1.0-beta03" voyager = "1.1.0-beta03"
spotless = "7.2.1" spotless = "8.0.0"
ktlint-core = "1.7.1" ktlint-core = "1.7.1"
firebase-bom = "34.0.0" firebase-bom = "34.5.0"
markdown = "0.35.0" markdown = "0.38.1"
junit = "5.13.4" junit = "6.0.1"
[libraries] [libraries]
desugar = "com.android.tools:desugar_jdk_libs:2.1.5" 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-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp_version" }
okhttp-brotli = { module = "com.squareup.okhttp3:okhttp-brotli", 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" } 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" 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" disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:e0def6b3dc" 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" flexible-adapter-core = "com.github.arkon.FlexibleAdapter:flexible-adapter:c8013533"
photoview = "com.github.chrisbanes:PhotoView:2.3.0" photoview = "com.github.chrisbanes:PhotoView:2.3.0"
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.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-materialmotion = "io.github.fornewid:material-motion-compose-core:2.0.1"
compose-webview = "io.github.kevinnzou:compose-webview:0.33.6" compose-webview = "io.github.kevinnzou:compose-webview:0.33.6"
compose-grid = "io.woong.compose.grid:grid:1.2.2" 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 = "3.0.0" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version = "2.5.1" }
swipe = "me.saket.swipe:swipe:1.3.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-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
kotest-assertions = "io.kotest:kotest-assertions-core:5.9.1" kotest-assertions = "io.kotest:kotest-assertions-core:6.0.4"
mockk = "io.mockk:mockk:1.14.5" mockk = "io.mockk:mockk:1.14.6"
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", 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" } stringSimilarity = { module = "com.aallam.similarity:string-similarity-kotlin", version = "0.1.0" }
[plugins] [plugins]
google-services = { id = "com.google.gms.google-services", version = "4.4.3" } google-services = { id = "com.google.gms.google-services", version = "4.4.4" }
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlib_version" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutlib_version" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" } 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] [bundles]
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-brotli", "okhttp-dnsoverhttps"] okhttp = ["okhttp-core", "okhttp-logging", "okhttp-brotli", "okhttp-dnsoverhttps"]

View File

@@ -105,7 +105,7 @@
<item quantity="other">%d فصل تالٍ لم يُقرؤوا</item> <item quantity="other">%d فصل تالٍ لم يُقرؤوا</item>
</plurals> </plurals>
<plurals name="download_amount"> <plurals name="download_amount">
<item quantity="zero">الفصل التالي</item> <item quantity="zero">لا فصل تالي</item>
<item quantity="one">الفصل التالي</item> <item quantity="one">الفصل التالي</item>
<item quantity="two">%d فصول تالية</item> <item quantity="two">%d فصول تالية</item>
<item quantity="few">%d فصول تالية</item> <item quantity="few">%d فصول تالية</item>
@@ -152,4 +152,28 @@
<item quantity="many">بعد %1$d أيام</item> <item quantity="many">بعد %1$d أيام</item>
<item quantity="other">بعد %1$d أيام</item> <item quantity="other">بعد %1$d أيام</item>
</plurals> </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> </resources>

View File

@@ -65,7 +65,7 @@
<string name="pref_category_tracking">التتبع</string> <string name="pref_category_tracking">التتبع</string>
<string name="pref_category_advanced">اﻹعدادات المتقدمة</string> <string name="pref_category_advanced">اﻹعدادات المتقدمة</string>
<string name="pref_category_about">حول التطبيق</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="portrait">طوليٌّ</string>
<string name="landscape">عرضيٌّ</string> <string name="landscape">عرضيٌّ</string>
<string name="pref_library_update_interval">التحديثات التلقائية</string> <string name="pref_library_update_interval">التحديثات التلقائية</string>
@@ -137,10 +137,10 @@
<string name="restoring_backup">تُستعاد النسخة الاحتياطية</string> <string name="restoring_backup">تُستعاد النسخة الاحتياطية</string>
<string name="creating_backup">تُنشأ النسخة الاحتياطية</string> <string name="creating_backup">تُنشأ النسخة الاحتياطية</string>
<string name="action_global_search">بحث شامل</string> <string name="action_global_search">بحث شامل</string>
<string name="color_filter_r_value">R</string> <string name="color_filter_r_value">احمر</string>
<string name="color_filter_g_value">G</string> <string name="color_filter_g_value">اخضر</string>
<string name="color_filter_b_value">B</string> <string name="color_filter_b_value">أزرق</string>
<string name="color_filter_a_value">A</string> <string name="color_filter_a_value">ألفا</string>
<string name="pref_clear_chapter_cache">مسح الذاكرة المؤقتة للفصول</string> <string name="pref_clear_chapter_cache">مسح الذاكرة المؤقتة للفصول</string>
<string name="used_cache">المستخدمة: %1$s</string> <string name="used_cache">المستخدمة: %1$s</string>
<string name="cache_deleted">تم محو الذاكرة المؤقتة. %1$d ملف تم محوه</string> <string name="cache_deleted">تم محو الذاكرة المؤقتة. %1$d ملف تم محوه</string>
@@ -149,7 +149,6 @@
<string name="cookies_cleared">تم محو ملفات تعريف الارتباط</string> <string name="cookies_cleared">تم محو ملفات تعريف الارتباط</string>
<string name="pref_clear_database">محو قاعدة البيانات</string> <string name="pref_clear_database">محو قاعدة البيانات</string>
<string name="pref_clear_database_summary">مسح سجلّ الإدخالات التي ليست محفوظة في مكتبتك</string> <string name="pref_clear_database_summary">مسح سجلّ الإدخالات التي ليست محفوظة في مكتبتك</string>
<string name="clear_database_confirmation">أمتأكِّد؟ إن فعلتَ سوف تخسر الفصول المقروءة و التقدم فى المدخلات الغير محفوظة فى المكتبة</string>
<string name="clear_database_completed">تم حذف المدخلات</string> <string name="clear_database_completed">تم حذف المدخلات</string>
<string name="version">اﻹصدار</string> <string name="version">اﻹصدار</string>
<string name="pref_enable_acra">إرسال تقارير الأعطال</string> <string name="pref_enable_acra">إرسال تقارير الأعطال</string>
@@ -330,7 +329,7 @@
<string name="restore_in_progress">الاستعادة قيد التقدم بالفعل</string> <string name="restore_in_progress">الاستعادة قيد التقدم بالفعل</string>
<string name="creating_backup_error">فشل النسخ الاحتياطي</string> <string name="creating_backup_error">فشل النسخ الاحتياطي</string>
<string name="backup_in_progress">يُنسخ احتياطيًّا بالفعل</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_unpin">إلغاء التثبيت</string>
<string name="action_pin">تثبيت</string> <string name="action_pin">تثبيت</string>
<string name="action_select_inverse">عكس التحديد</string> <string name="action_select_inverse">عكس التحديد</string>
@@ -556,7 +555,7 @@
<string name="clear_database_source_item_count">%1$d مدخلةً في قاعدة البيانات وليست في المكتبة</string> <string name="clear_database_source_item_count">%1$d مدخلةً في قاعدة البيانات وليست في المكتبة</string>
<string name="extension_api_error">فشل الحصول على قائمة الملحقات</string> <string name="extension_api_error">فشل الحصول على قائمة الملحقات</string>
<string name="ext_installer_shizuku_unavailable_dialog">ثبِّت «شيزوكو» وشغِّله لتستخدمه مثبِّت إضافات.</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_show_manga">إظهار الدخول</string>
<string name="action_display_cover_only_grid">شبكة بالاغلفة</string> <string name="action_display_cover_only_grid">شبكة بالاغلفة</string>
<string name="skipped_reason_not_started">تُخُطِّيت بسبب عدم وجود فصول قُرئت</string> <string name="skipped_reason_not_started">تُخُطِّيت بسبب عدم وجود فصول قُرئت</string>
@@ -677,9 +676,6 @@
<string name="pref_library_update_show_tab_badge">إظهار عدد الادخالات غير المقروءة في ايقونة التحديثات</string> <string name="pref_library_update_show_tab_badge">إظهار عدد الادخالات غير المقروءة في ايقونة التحديثات</string>
<string name="pref_skip_dupe_chapters">تجاوز الفصول المكررة</string> <string name="pref_skip_dupe_chapters">تجاوز الفصول المكررة</string>
<string name="enhanced_services_not_installed">متوفر ولكن المصدر غير مثبت: %s</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="copied_to_clipboard_plain">نسخ الى الحافظة</string>
<string name="track_error">%1$s خطأ: %2$s</string> <string name="track_error">%1$s خطأ: %2$s</string>
<string name="information_required_plain">*مطلوب</string> <string name="information_required_plain">*مطلوب</string>
@@ -692,8 +688,8 @@
<string name="pref_page_rotate_invert">اعكس اتِّجاه الصفحات العريضة المدوَّرة</string> <string name="pref_page_rotate_invert">اعكس اتِّجاه الصفحات العريضة المدوَّرة</string>
<string name="pref_debug_info">معلومات التنقيح</string> <string name="pref_debug_info">معلومات التنقيح</string>
<string name="pref_double_tap_zoom">انقر مرتين لتكبِّر</string> <string name="pref_double_tap_zoom">انقر مرتين لتكبِّر</string>
<string name="pref_chapter_swipe_end">إجراء التمرير الأيسر</string> <string name="pref_chapter_swipe_end">إجراء التمرير الى اليمين</string>
<string name="pref_chapter_swipe_start">إجراء التمرير الأيمن</string> <string name="pref_chapter_swipe_start">إجراء التمرير الى اليسار</string>
<string name="action_set_interval">عيِّد المدة</string> <string name="action_set_interval">عيِّد المدة</string>
<string name="action_filter_interval_custom">مدة جلب مخصَّصة</string> <string name="action_filter_interval_custom">مدة جلب مخصَّصة</string>
<string name="manga_display_interval_title">قدِّر كلَّ</string> <string name="manga_display_interval_title">قدِّر كلَّ</string>
@@ -741,7 +737,7 @@
<string name="action_menu_overflow_description">خيارات أكثر</string> <string name="action_menu_overflow_description">خيارات أكثر</string>
<string name="selected">محدَّد</string> <string name="selected">محدَّد</string>
<string name="not_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">مكان التخزين</string>
<string name="pref_storage_location_info">يُستخدَم في الاحتياط وتنزيل الفصول والمصدر المحليِّ.</string> <string name="pref_storage_location_info">يُستخدَم في الاحتياط وتنزيل الفصول والمصدر المحليِّ.</string>
<string name="onboarding_storage_action_select">حدِّد مجلَّدًا</string> <string name="onboarding_storage_action_select">حدِّد مجلَّدًا</string>
@@ -809,10 +805,102 @@
<string name="pref_flash_with">يومض مع</string> <string name="pref_flash_with">يومض مع</string>
<string name="action_replace_repo_message">يحتوي المستودع %1$s على نفس بصمة مفتاح التوقيع الموجودة في %2$s. <string name="action_replace_repo_message">يحتوي المستودع %1$s على نفس بصمة مفتاح التوقيع الموجودة في %2$s.
\nإذا كان هذا متوقعًا، فسيتم استبدال %2$s، وإلا فاتصل بمشرف الرابطالخاص بك.</string> \nإذا كان هذا متوقعًا، فسيتم استبدال %2$s، وإلا فاتصل بمشرف الرابطالخاص بك.</string>
<string name="action_migrate_duplicate">نقل مَدْخَل موجود</string>
<string name="pref_display_profile">ملف تعريف عرض خاص</string> <string name="pref_display_profile">ملف تعريف عرض خاص</string>
<string name="action_copy_link">نسخ الرابط</string> <string name="action_copy_link">نسخ الرابط</string>
<string name="extensionRepo_settings">مستودع الإضافات</string> <string name="extensionRepo_settings">مستودع الإضافات</string>
<string name="invalid_backup_file_unknown">ملف النسخة الإحتياطية معطل أو لا يعمل</string> <string name="invalid_backup_file_unknown">ملف النسخة الإحتياطية معطل أو لا يعمل</string>
<string name="invalid_backup_file_json">لا يتم دعم النسخ الاحتياطي لـ JSON</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> </resources>

View File

@@ -76,4 +76,8 @@
<item quantity="one">অধ্যায়সমূহ %1$s আৰু 1 অধিক</item> <item quantity="one">অধ্যায়সমূহ %1$s আৰু 1 অধিক</item>
<item quantity="other">অধ্যায়সমূহ %1$s আৰু %2$d অধিক</item> <item quantity="other">অধ্যায়সমূহ %1$s আৰু %2$d অধিক</item>
</plurals> </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="unlock_app_title">%s আনলক কৰক</string>
<string name="action_webview_forward">সামনে</string> <string name="action_webview_forward">সামনে</string>
<string name="action_webview_refresh">পুনৰপ্ৰাপ্তি কৰক</string> <string name="action_webview_refresh">পুনৰপ্ৰাপ্তি কৰক</string>
<string name="action_migrate_duplicate">বিদ্যমান প্ৰৱিষ্টি স্থানান্তৰ কৰক</string>
<string name="onboarding_action_next">পৰৱৰ্তী</string> <string name="onboarding_action_next">পৰৱৰ্তী</string>
<string name="pref_category_general">সাধাৰণ</string> <string name="pref_category_general">সাধাৰণ</string>
<string name="pref_category_appearance">দেখাত</string> <string name="pref_category_appearance">দেখাত</string>
@@ -524,7 +523,6 @@
<string name="pref_clear_database">ডাটাবেচ মচা</string> <string name="pref_clear_database">ডাটাবেচ মচা</string>
<string name="pref_clear_database_summary">আপোনাৰ লাইব্ৰেৰিত সংৰক্ষণ কৰা হোৱা নথিৰ ইতিহাস মচা</string> <string name="pref_clear_database_summary">আপোনাৰ লাইব্ৰেৰিত সংৰক্ষণ কৰা হোৱা নথিৰ ইতিহাস মচা</string>
<string name="clear_database_source_item_count">ডাটাবেচত %1$d লাইব্ৰেৰিত নথিৰ সংখ্যা</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="clear_database_completed">নথি মচা হৈছে</string>
<string name="database_clean">মচাৰ বাবে কিবা নাই</string> <string name="database_clean">মচাৰ বাবে কিবা নাই</string>
<string name="pref_clear_webview_data">WebView ডাটা মচা</string> <string name="pref_clear_webview_data">WebView ডাটা মচা</string>
@@ -609,9 +607,6 @@
<string name="remove_from_library">লাইব্ৰেৰী পৰা মচি দিয়ক</string> <string name="remove_from_library">লাইব্ৰেৰী পৰা মচি দিয়ক</string>
<string name="manga_removed_library">লাইব্ৰেৰী পৰা মচি দিয়া হৈছে</string> <string name="manga_removed_library">লাইব্ৰেৰী পৰা মচি দিয়া হৈছে</string>
<string name="unknown_title">অজানা শিৰোনাম</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_expand">অধিক</string>
<string name="manga_info_collapse">সৰু</string> <string name="manga_info_collapse">সৰু</string>
<string name="delete_downloads_for_manga">ডাউনলোড কৰা অধ্যায়সমূহ মচি দিব নেকি?</string> <string name="delete_downloads_for_manga">ডাউনলোড কৰা অধ্যায়সমূহ মচি দিব নেকি?</string>

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