Compare commits

...

113 Commits

Author SHA1 Message Date
Jobobby04 917f20894b Bump version code 2026-02-27 22:08:28 -05:00
Jobobby04 3a3b719b8b Copy last page read in migrate 2026-02-27 22:07:59 -05:00
Jobobby04 1903437ecf Cleanup 2026-02-27 22:07:42 -05:00
Jobobby04 5c26bb3a52 Add recommended proguard rules 2026-02-27 22:07:31 -05:00
Jobobby04 07599ade3a Fix blank page on cloudflare guard 2026-02-27 13:49:26 -05:00
Jobobby04 0a9f36402b Remove migrate button from merged manga 2026-02-27 13:38:28 -05:00
Weblate (bot) d2b325cd02 Translations update from Hosted Weblate (#2997)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/vi/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/
Translation: Mihon/Mihon

Co-authored-by: Filip Jaruška <filip.jaruska@gmail.com>
Co-authored-by: Nguyễn Trung Đức <vaicato16@gmail.com>
Co-authored-by: ɴᴇᴋᴏ <s99095lkjjim@gmail.com>
(cherry picked from commit cc3f640faa7df9f2a64976d2639e6dfd60ebe213)
2026-02-27 13:33:34 -05:00
AntsyLich cdc64aceb7 Fix extension install/update stuck at pending (#3000)
Co-authored-by: p
(cherry picked from commit 84265febf3ce24d71994ced2b81215f858430d4e)

# Conflicts:
#	CHANGELOG.md
2026-02-27 13:33:28 -05:00
Mend Renovate 4bfd6e4026 Update dependency io.kotest:kotest-assertions-core to v6.1.4 (#2998)
(cherry picked from commit 171a06a8baccae7fb21d2c60150639941747d90c)
2026-02-27 13:33:01 -05:00
Weblate (bot) 50eebdf7d3 Translations update from Hosted Weblate (#2980)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/
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/hr/
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/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/
Translation: Mihon/Mihon

Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: Jakub Szafranek13 Fabijan <jakubfabijan@tuta.io>
Co-authored-by: KraXen <dpelech1@gmail.com>
Co-authored-by: Lyfja <45209212+lyfja@users.noreply.github.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Swyter <swyterzone@gmail.com>
Co-authored-by: ZerOriSama <godarms2010@live.com>
(cherry picked from commit 565379779f82ee7dfdad45e251d82ff73dc3b7b2)
2026-02-27 13:32:56 -05:00
AntsyLich f843de28d7 Run automatic library updates even when connected to a VPN (#2996)
Co-authored-by: jeremiejig <3978761+jeremiejig@users.noreply.github.com>
(cherry picked from commit 039471427448347a1c12c39a488a1127a3ea1497)

# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt
2026-02-27 13:32:48 -05:00
Cuong-Tran d250a9a680 Going back now first clears search query on browse extension tab (#2906)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit 75b445fa8fd42b882266e27d9b979b22ca37d42a)

# Conflicts:
#	CHANGELOG.md
2026-02-27 13:30:07 -05:00
AntsyLich 4130db3920 Add all pages of adjacent chapters in the UI instead of only the first or last three (#2995)
(cherry picked from commit 0cc724108b4f29a3d1d33ac4666a14873460a657)

# Conflicts:
#	CHANGELOG.md
2026-02-27 13:29:44 -05:00
Mend Renovate f2cbff04ab Update dependency io.coil-kt.coil3:coil-bom to v3.4.0 (#2992)
(cherry picked from commit 5b85084d0a90b241fdf1d3ad018a27b49db15f12)
2026-02-27 13:29:19 -05:00
AntsyLich 061e9359e8 Fix migration dialog not showing for consecutive prompts from the same screen (#2994)
(cherry picked from commit 8b2d35f3068a3f9c5e1bba77ed825ae51531c91c)

# Conflicts:
#	CHANGELOG.md
2026-02-27 13:29:16 -05:00
AntsyLich 73258e9e05 Fix migration's selected sources order not preserved (#2993)
(cherry picked from commit 47816d4b218e6a62ef9fcd3097b6b0b8f2f95b17)

# Conflicts:
#	CHANGELOG.md
2026-02-27 13:28:55 -05:00
NarwhalHorns 73e4982ffb Utilize tracker for library duplicate detection (#2978)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit 89bbdb17fb4ed1cbe99c14f389940e0f91093a10)

# Conflicts:
#	CHANGELOG.md
2026-02-27 13:28:38 -05:00
NarwhalHorns 185cd923c0 Add option for bookmarked chapters to download dropdown (#2891)
(cherry picked from commit 3c6f0f1697ccab055ee7af47da84b2161d406f0c)

# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt
2026-02-27 13:28:16 -05:00
NarwhalHorns 3cfc53bf11 Optimize tracked library filter (#2977)
Co-authored-by: NarwhalHorns <onefailedgamer@gamil.com>
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit ab214526c6f24466a0432b5c5c7d254a244cd958)

# Conflicts:
#	CHANGELOG.md
2026-02-27 13:21:29 -05:00
Weblate (bot) 1301acfdb7 Translations update from Hosted Weblate (#2843)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ca/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/
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/ko/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/my/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pl/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/
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

Co-authored-by: Ai Ai Ecchi <aiaiecchi9og@gmail.com>
Co-authored-by: Alexandre Dhooge <alexandre.dhooge@zaclys.net>
Co-authored-by: Cuong Tran <cuongtran.tm@gmail.com>
Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: Eduard Ereza Martínez <eduard@ereza.cat>
Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: Hualiang <642615676@qq.com>
Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Co-authored-by: Jakub Szafranek13 Fabijan <jakubfabijan@tuta.io>
Co-authored-by: Lyfja <45209212+lyfja@users.noreply.github.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: MuhamadSyabitHidayattulloh <tebepc@gmail.com>
Co-authored-by: Swyter <swyterzone@gmail.com>
Co-authored-by: Throw Away <throwawayacc4gulshan@gmail.com>
Co-authored-by: ZerOriSama <godarms2010@live.com>
Co-authored-by: akir45 <akkn0708@gmail.com>
Co-authored-by: clukki <nguyenhuuminh16911@gmail.com>
Co-authored-by: momoehab <momoehab@gmail.com>
Co-authored-by: yumekon <konrad.nowicki91@gmail.com>
Co-authored-by: ɴᴇᴋᴏ <s99095lkjjim@gmail.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
(cherry picked from commit 7c7d35e2c64d4f85b0db431fbe18408698af9c6e)
2026-02-27 13:21:06 -05:00
Cuong-Tran 9d9dbea48d Remove redundant userSelected from selection methods (#2976)
(cherry picked from commit 9867c160f781b52cd297f7b1202cc6963b375df0)

# Conflicts:
#	app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt
2026-02-27 13:19:34 -05:00
Cuong-Tran c1df3eb1d0 Don't wrap an intent-chooser inside another intent-chooser (#2921)
(cherry picked from commit 20c899f2cd83fdd37dfc14d58ded6ae7581d3d65)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt
2026-02-27 13:19:12 -05:00
Jobobby04 3154c97aee Fix some build warnings 2026-02-27 13:17:48 -05:00
Mend Renovate ffe1b160de Update moko to v0.26.0 (#2967)
(cherry picked from commit 27749fc583527ab1360594a69ef586a4311c93f5)
2026-02-27 13:10:37 -05:00
Mend Renovate 23272375b7 Update dependency com.google.firebase:firebase-bom to v34.9.0 (#2964)
(cherry picked from commit 2248bdac546914492b0adf75425915b899749900)
2026-02-27 13:10:30 -05:00
Mend Renovate 863b6ee784 Update dependency org.junit.jupiter:junit-jupiter to v6.0.3 (#2963)
(cherry picked from commit 42c7329a9e17ab15c0dc1c1f33eabf54286f9339)
2026-02-27 13:10:25 -05:00
Mend Renovate c4c8d4b9c3 Update dependency androidx.compose:compose-bom to v2026.02.00 (#2962)
(cherry picked from commit 5be5a4e81978954713da6f7faa24cc9956c823e9)
2026-02-27 13:10:18 -05:00
Mend Renovate b2bbbca585 Update dependency androidx.activity:activity-compose to v1.12.4 (#2959)
(cherry picked from commit b924d582f459835f533fa54badbe129eede4388f)
2026-02-27 13:10:12 -05:00
Mend Renovate df3b879cf6 Update kotlin monorepo to v2.3.10 (#2960)
(cherry picked from commit 6f601a18c5beb78a70a94471abc2960fb37199b4)
2026-02-27 13:10:05 -05:00
Mend Renovate 47c4f2cc8c Update paging.version to v3.4.1 (#2961)
(cherry picked from commit 1ff9a5625c442be1c9504df0f0eabdaf24aa2fda)
2026-02-27 13:10:00 -05:00
Luca Auer 905a1c1230 Add missing indexes to improve database query performance (#2950)
(cherry picked from commit cae9fbf3213987e7d263845431dfac10a2ecb3b0)

# Conflicts:
#	data/src/main/sqldelight/tachiyomi/migrations/10.sqm
2026-02-27 13:09:55 -05:00
Mend Renovate bcaf7f6415 Update dependency io.kotest:kotest-assertions-core to v6.1.3 (#2939)
(cherry picked from commit d17976c91068944be1a5cba9a959c3763f589590)
2026-02-27 13:02:50 -05:00
Mend Renovate 4639b3ecc3 Update dependency com.materialkolor:material-kolor to v5.0.0-alpha06 (#2938)
(cherry picked from commit b609166702e0788160a557fa1470c6dd8e585169)
2026-02-27 13:02:44 -05:00
MajorTanya 2034971cc0 Clean up some build warnings (#2929)
* Replace deprecated rememberPlainTooltipPositionProvider

* Remove superfluous when branch

This when is marked as exhaustive.

* Replace deprecated LibrariesContainer call

AboutLibraries now wants us to produce the libraries ourselves.

* Replace deprecated ClipboardManager with Clipboard

Clipboard uses suspend functions, hence the coroutine scope addition.

* Use multi-dollar strs to simplify GraphQL queries

These have been available since Kotlin 2.1.

* Remove various redundant casts & conversions

- WebViewScreenContent: loadingState is in the LoadingState.Loading
  branch, no need to cast at all
- Bangumi: username is not modified, make val
- Kavita: token is already a String
- PagerViewerAdapter: insertPageLastPage is already null-checked
- PagerViewerAdapter: use reified filterIsInstance
- ReaderViewModel: chapter IDs are already Longs
- CloudflareInterceptor: webview is smart-cast to non-null here

* Replace deprecated MenuAnchorType

Literally just a typealias for ExposedDropdownMenuAnchorType anyway.

* OptimizeNonSkippingGroups is enabled by default

* Suppress shadowing warning

This is explicitly intentional according to the KDocs.

* Migrate Context Receivers to Context Parameters

Requires changing the compiler arg, but that is part of the migration:

https://blog.jetbrains.com/kotlin/2025/04/update-on-context-parameters

Apparently, the only visible change is that names are required now.
"_" can be used for anonymous context parameters.

* Fix expression bodies with explicit return

Naming conflict resolved by aliasing.

From 2.4/2.5 onward, these will only be allowed with explicit return
types, or have to be turned into a block body. I opted for the latter
since the function is reasonably dense already.

see: https://youtrack.jetbrains.com/issue/KTLC-288

* Suppress deprecation of non-AutoMirrored icons

We use these arrows for navigation in the Upcoming screen.
I strongly doubt the AutoMirrored versions would make sense for our
use-case.

* Explicitly opt-in to new annotation default rules

affects the following annotated value-parameters:
- Preference.SliderPreference.steps (`@IntRange`)
- ReaderViewModel.State.brightnessOverlayValue (`@IntRange`)
- ReadingMode.iconRes (`@DrawableRes`)
- MigrationListScreenModel.Dialog.Progress.progress (`@FloatRange`)

see: https://youtrack.jetbrains.com/issue/KT-73255
see: https://github.com/Kotlin/KEEP/blob/change-defaulting-rule/proposals/annotation-target-in-properties.md

Warning message was the following:

    This annotation is currently applied to the value parameter only, but in the future it will also be applied to field.
    - To opt in to applying to both value parameter and field, add '-Xannotation-default-target=param-property' to your compiler arguments.
    - To keep applying to the value parameter only, use the '@param:' annotation target.

(cherry picked from commit b543bc089a442c5e93b0fb6c83bc4037740b1eb5)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt
#	core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt
#	core/common/src/main/kotlin/mihon/core/common/archive/ArchiveInputStream.kt
2026-02-27 13:02:31 -05:00
Mend Renovate bb8698b2a6 Update dependency androidx.compose:compose-bom to v2026.01.01 (#2913)
(cherry picked from commit 100cea0757c930b153e50ca2ba52e4c06f14800d)
2026-02-27 12:52:37 -05:00
Mend Renovate cd69b09dd0 Update dependency androidx.work:work-runtime to v2.11.1 (#2914)
(cherry picked from commit e092b4208a0f8476c81ed58a2b0efd3a7c5996cf)
2026-02-27 12:52:29 -05:00
Mend Renovate 462b2164e8 Update paging.version to v3.4.0 (#2916)
(cherry picked from commit 1bf4eff931781eff11e3c1d59ca3747ffb069203)
2026-02-27 12:52:23 -05:00
Mend Renovate fb1a4ad828 Update dependency androidx.activity:activity-compose to v1.12.3 (#2917)
(cherry picked from commit b70edfac58e8e5722a51be398212ac025b818258)
2026-02-27 12:52:17 -05:00
Mend Renovate 3bd89cee26 Update markdown to v0.39.2 (#2923)
(cherry picked from commit 9c0eebb55ceb6a7edc50e414b4be19faf256bcda)
2026-02-27 12:52:11 -05:00
Mend Renovate 6f43e98fff Update dependency io.kotest:kotest-assertions-core to v6.1.2 (#2908)
(cherry picked from commit f6f1d13addea504ff7ed6ec87276d1cfdfcf2013)
2026-02-27 12:52:06 -05:00
Mend Renovate 6feeb4b1ee Update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.2.1 (#2909)
(cherry picked from commit 31a9ff261e5e195fa2e27615bae0a86956a3c90a)
2026-02-27 12:52:00 -05:00
Mend Renovate fcfe750fcf Update dependency io.mockk:mockk to v1.14.9 (#2904)
(cherry picked from commit c0a1203541931a9090e6769acdb596c9ea4a011b)
2026-02-27 12:51:55 -05:00
Cuong-Tran 6e314e3643 Fix Add Repo input not taking up the full dialog width (#2816)
(cherry picked from commit 82ffc8efa69cb86229fe8b677f12942c80b88a15)

# Conflicts:
#	CHANGELOG.md
2026-02-27 12:51:48 -05:00
Mend Renovate 487ca49c11 Update serialization.version to v1.10.0 (#2879)
(cherry picked from commit d6e17b04c39c9d800c57d75e29714a51fbf902f8)
2026-02-27 12:51:27 -05:00
Mend Renovate 698abe8667 Update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.2.0 (#2892)
(cherry picked from commit e6c67003511b601d49bc175b83596568ddfe09d9)
2026-02-27 12:51:20 -05:00
Mend Renovate 13c9daf9a9 Update dependency io.kotest:kotest-assertions-core to v6.1.1 (#2893)
(cherry picked from commit ede2f56bc6531f69553d0f95cf422b7f6d15b90b)
2026-02-27 12:51:14 -05:00
Mend Renovate eb21454d6d Update Gradle to v8.14.4 (#2894)
(cherry picked from commit 2037ec45006b54da0e64b3e07bae4c87e261ee59)
2026-02-27 12:51:07 -05:00
Cuong-Tran 56347e6d52 Fix memoization in manga bottom action menus (#2886)
(cherry picked from commit 533a578bdb438fe4a23e069386a6616c7daedcc4)
2026-02-27 12:50:46 -05:00
MajorTanya 5c085a36e8 Reword download index message (#2874)
I'm tired of people thinking the current wording is an error. Improved wordings welcome, this was just my first decent guess.

(cherry picked from commit 05d90ea4d652cd7ead385ec954e0ae2dc332a012)

# Conflicts:
#	CHANGELOG.md
2026-02-27 12:50:38 -05:00
Mend Renovate 65ab676946 Update dependency io.kotest:kotest-assertions-core to v6.1.0 (#2870)
(cherry picked from commit 49c4d08b22b6a440b93b58bd78280807550d6198)
2026-02-27 12:50:19 -05:00
MajorTanya 1f51569a35 Add Filters to Updates screen (#2851)
* Add Filters to Updates screen

Behaves basically like the filters in the library:

- Unread: Show/Don't show unread chapters
- Downloaded: Show/Don't show downloaded chapters
- Started: Show/Don't show chapters that have some progress but aren't
  fully Read
- Bookmarked: Show/Don't show chapters that have been bookmarked

Started behaves differently from its Library counterpart because the
actual manga data is not available at this point in time and I thought
calling getManga for each entry without caching would be a pretty bad
idea.

I have modelled this closely on the filter control flow in the
Library, but I'm sure this can be simplified/adjusted in some way.

* Move most filtering logic to SQL

Unread, Started, and Bookmarked filters are now part of the SQL query.

Download state cannot be filtered in the database so it remains in
Kotlin.

Because the Downloaded filter has to be run in Kotlin, the combine
flow uses the preferences flow twice, once to get the SQL query params
and once for the Kotlin filters (only Downloaded at this time).

* Add "Hide excluded scanlators" to update filters

Based on the work done in #1623 but integrated with the other filters
in this PR. Added the user as a co-author for credit.

Co-authored-by: Dani <17619547+shabnix@users.noreply.github.com>

---------

Co-authored-by: Dani <17619547+shabnix@users.noreply.github.com>
(cherry picked from commit bbe9aa8561360f030072fbc49f79748e71c6535e)

# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt
#	data/src/main/java/tachiyomi/data/updates/UpdatesRepositoryImpl.kt
#	data/src/main/sqldelight/tachiyomi/migrations/9.sqm
#	domain/src/main/java/tachiyomi/domain/updates/interactor/GetUpdates.kt
2026-02-27 12:35:44 -05:00
Jobobby04 b0d6e16ca3 Fix build 2026-02-27 11:51:08 -05:00
Mend Renovate 85cf54ccc8 Update dependency com.google.firebase:firebase-bom to v34.8.0 (#2856)
(cherry picked from commit 13975d6f7eea21b3ba4be31736038ef2f855bfea)
2026-02-27 11:46:14 -05:00
Mend Renovate 602df5a729 Update dependency androidx.compose:compose-bom to v2026 (#2853)
(cherry picked from commit e659e90c26814c9a29bf668545b4f10294f3c6b9)
2026-02-27 11:46:08 -05:00
Mend Renovate c8102836ce Update dependency com.materialkolor:material-kolor to v5.0.0-alpha05 (#2849)
(cherry picked from commit 99e6fa3c06e1dc24255a73aa5741a72c82d94d91)
2026-02-27 11:46:02 -05:00
Mend Renovate e641575941 Update markdown to v0.39.1 (#2850)
(cherry picked from commit cf13012629571e2097796b50df238f69e62843b3)
2026-02-27 11:45:49 -05:00
NGB-Was-Taken 83afcee4d1 Fix crash when trying to install/update extensions while shizuku isn't running (#2837)
(cherry picked from commit 4ce249c1a0ee9f3a20d91214fd09145e0924b2e4)

# Conflicts:
#	CHANGELOG.md
2026-02-27 11:45:04 -05:00
MajorTanya 2102e0594e Fix nullability of MAL authors breaking search (#2834)
One of these days I'll get through a tracker change without
nullability problems...

(cherry picked from commit edcf84d9022e7436606a0b8c493c1035888ac60a)
2026-02-27 11:44:38 -05:00
Constantin Piber 14c91da6b3 Add a small increment to chapter number before comparison to fix progress sync issues for Suwayomi (#2675)
Due to a `Float->Double->Float` conversion somewhere inside Mihon, the
tracker sees 2.1 as 2.0999999046325684, which means this filter ignores
the 2.1 chapter (which we just tried to mark as read). This small
epsilon is small enough to never bother any serious usage, but large
enough to ignore any such conversion errors.

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit bd5c4d48f980d2d3dcc1112fe499dba17ef8e507)

# Conflicts:
#	CHANGELOG.md
2026-02-27 11:44:26 -05:00
MajorTanya 46c1c6463a Add authors/artists to MAL search results (#2833)
(cherry picked from commit 51b3ab3fd19bdf6a7c3bd2085104392a9c412622)

# Conflicts:
#	CHANGELOG.md
2026-02-27 11:44:10 -05:00
Weblate (bot) 89a521b836 Translations update from Hosted Weblate (#2806)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ca/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ceb/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/fil/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/it/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ru/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bn/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ca/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ceb/
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/it/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ko/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/
Translation: Mihon/Mihon
Translation: Mihon/Mihon Plurals

Co-authored-by: Ahmed TOUCHANE <ahmedtouchane0@gmail.com>
Co-authored-by: Anderhale <anderhale@users.noreply.hosted.weblate.org>
Co-authored-by: Eduard Ereza Martínez <eduard@ereza.cat>
Co-authored-by: Eugene <e.shlyapkin99@gmail.com>
Co-authored-by: Gino Cicatiello <ginocic@gmail.com>
Co-authored-by: Hiroshi <borlonjhayron1119@gmail.com>
Co-authored-by: Luis Antonio <getcyonic+zaorinu@gmail.com>
Co-authored-by: NormalRandomPeople <normal.scribe833@silomails.com>
(cherry picked from commit e6f72000ba62302c3e4817a5f17057e5a8d7eafc)
2026-02-27 11:43:50 -05:00
MajorTanya 65c6ed21ab Optimise MAL search queries by ~11x (#2832)
Previously, the app made one request for the search, and then fired
off 1 request per search result to obtain additional data, such as
each title's synopsis, etc.

However, MAL's search allows field selection during the initial query,
which will return all the data in that first response, avoiding the
massive bunch of requests (and alleviating some pressure on MAL from
our userbase).

By combining the selected fields into one constant, I was able to also
get rid of the MALUserListSearch entirely because it was redundant.
This allows for a unified MALManga->TrackSearch helper, further
reducing complexity.

I got to my "11x" improvement because on page of search results has 10
elements, and this change turns 11 (1+10 for results) requests into 1.

(cherry picked from commit 9bf2d78a421213b1885456f5b54c3286edc539e1)

# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt
2026-02-27 11:42:54 -05:00
Mend Renovate 1b911e7e15 Update dependency org.junit.jupiter:junit-jupiter to v6.0.2 (#2830)
(cherry picked from commit 89c4e3bb39b7450a9aedcf9d720c74c04cd560ff)
2026-02-27 11:41:41 -05:00
Mend Renovate 0535e41051 Update dependency org.jsoup:jsoup to v1.22.1 (#2826)
(cherry picked from commit 47fe792ddc5a342ecc4685d57143cfb40f0682f7)
2026-02-27 11:41:32 -05:00
AntsyLich 3fc802f837 Remember descriptionAnnotator across composition
Closes #2510

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
(cherry picked from commit 906d6f3cdbd6f7168d9cdea0c3eb8f9c663a09c2)
2026-02-27 11:41:17 -05:00
AntsyLich 976b5cc03e Cleanup extension screen search query predicate
(cherry picked from commit e059190fabe3cbe8498fc3cec7e39b0350f3c289)
2026-02-27 11:41:10 -05:00
AntsyLich a9fe971337 Switch to M3E ExtendedFloatingActionButton
(cherry picked from commit a39b5a56e853a9c11c0984b0385a2e60b0addc3a)
2026-02-27 11:40:06 -05:00
AntsyLich 5d1dbcb390 Switch to MaterialExpressiveTheme
(cherry picked from commit 3e6afee13b9fd5716f7f2d547b3edcd3e17915db)
2026-02-27 11:28:24 -05:00
AntsyLich 8d11ef3244 Use materilalKolor for monet compat color scheme
(cherry picked from commit 9a11ec8ead41cb7199e10f3c7464790a0bd3b1ad)
2026-02-27 11:28:18 -05:00
Weblate (bot) 724a61f513 Translations update from Hosted Weblate (#1536)
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/ko/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/es/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/fil/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/fr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/hr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/it/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ko/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ru/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/zh_Hant/
Translation: Mihon/TachiyomiSY
Translation: Mihon/TachiyomiSY Plurals

Co-authored-by: Champ0999 <champ0999@users.noreply.hosted.weblate.org>
Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: Erick Alejandro <erickux@gmail.com>
Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Co-authored-by: Luis Antonio <getcyonic+zaorinu@gmail.com>
Co-authored-by: Metin <durmus38metin@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: NormalRandomPeople <normal.scribe833@silomails.com>
Co-authored-by: Swyter <swyterzone@gmail.com>
Co-authored-by: ZerOriSama <godarms2010@live.com>
Co-authored-by: momoehab <momoehab@gmail.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
2026-02-27 11:25:48 -05:00
renovate[bot] 724c774dc9 Update dependency net.zetetic:sqlcipher-android to v4.13.0 (#1544)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-27 11:21:21 -05:00
renovate[bot] 29e0b2e4a5 Update actions/upload-artifact action to v7 (#1557)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-27 11:21:12 -05:00
KaiserBh 2776e41127 feat: Add sync events to SyncYomi (#1558)
* feat: Add sync events to SyncYomi

Now it will send the events back to `SyncYomi` server and then forward those to the notifications services that are enabled, such as discord, telegram, and etc.

* chore: fix build error.
2026-02-27 11:20:59 -05:00
Jobobby04 af1f77418f This is SY not Mihon Crashing 2026-02-13 22:41:04 -05:00
MajorTanya c1df5da062 This is SY not Mihon (#1549) 2026-01-31 11:24:37 -05:00
Jobobby04 f8f645772d Crashfix
Co-authored-by: name <arkon@users.noreply.github.com>
2026-01-01 12:26:13 -05:00
Jobobby04 b1e6fa65d6 Or 2025-12-26 23:31:31 -05:00
Jobobby04 01e8c6cc12 Use ComposeStars from RatingBar library. 2025-12-26 14:56:38 -05:00
Jobobby04 b4668c6829 Lint 2025-12-25 17:43:46 -05:00
Jobobby04 08d6c604bc Cleanup 2025-12-25 17:42:40 -05:00
Constantin Piber 02cec06535 Implement automatic removal of downloads on Suwayomi after reading, configurable via extension settings (#2673)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit 1263df9d4111511e49a43463c9808060433ce76d)

# Conflicts:
#	CHANGELOG.md
2025-12-25 17:26:02 -05:00
Weblate (bot) ebdb3f7478 Translations update from Hosted Weblate (#2711)
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ka/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bn/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ka/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ko/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/vi/
Translation: Mihon/Mihon
Translation: Mihon/Mihon Plurals

Co-authored-by: Anderhale <anderhale@users.noreply.hosted.weblate.org>
Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: Hasanur Rahman Biplob <hrbiplob10@gmail.com>
Co-authored-by: Jakub Szafranek13 Fabijan <jakubfabijan@tuta.io>
Co-authored-by: Nguyễn Trung Đức <vaicato16@gmail.com>
Co-authored-by: Temuri Doghonadze <temuri.doghonadze@gmail.com>
Co-authored-by: ابْنُ السَدِيمِ <amarlubs2@gmail.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
(cherry picked from commit c96b6ae562cee1220b9fec74708d447413ab8c35)
2025-12-25 17:25:49 -05:00
Mend Renovate 3724d79825 Update dependency androidx.compose:compose-bom to v2025.12.01 (#2651)
(cherry picked from commit 23c427cf60701e46e470fee8b0a7564804ec599e)
2025-12-25 17:25:46 -05:00
Mend Renovate c3e2eb6672 Update markdown to v0.39.0 (#2804)
(cherry picked from commit e3260d56f713d4f5411ae00dfd3da2aba87f4cf2)
2025-12-25 17:25:39 -05:00
Mend Renovate fa91695add Update aboutlib.version to v13.2.1 (#2803)
(cherry picked from commit f37afbcec9f21823cd894036b6ca7f464eb34481)
2025-12-25 17:25:35 -05:00
MajorTanya e7786bd16f Fix pre-1970 upload date display in chapter list (#2779)
A user in #2777 was using the ComicInfo.xml Year/Month/Day fields to
indicate date of publication for some American comics, which often
predate the UNIX Epoch of 1970.

They were seeing "N/A" displays because this line of code discarded
date information for any time before Jan 1st, 1970.

The `toRelativeString` extension function used in the other
`relativeDateText` function already accounts for very distant dates
(anything >7 days away turns into full date, not relative, regardless
of setting, though disabling the relative timestamp setting
circumvents this with the same result). Removing this line should not
cause any issues as it is purely a display difference and the use case
of backdating comics to pre-1970 is worth it in my opinion.

(cherry picked from commit 7a1c8a1b61e07d2e1a402b5daf0e7c04c232f655)

# Conflicts:
#	CHANGELOG.md
2025-12-25 17:25:31 -05:00
Mend Renovate 3d70476b9f Update dependency androidx.activity:activity-compose to v1.12.2 (#2797)
(cherry picked from commit 532b5cf290b448814ce5370bde461d9d5c8f086a)
2025-12-25 17:25:07 -05:00
Mend Renovate e74e0de8f5 Update kotlin monorepo to v2.3.0 (#2794)
(cherry picked from commit 3cb1b2e17a5f77d6133e6bf244304c2d6719aa04)
2025-12-25 17:24:59 -05:00
Luca Auer a2f552d6d2 Minimize memory usage by reducing in-memory cover cache size (#2266)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit 4c9cfd8da5f9c67daa4b6401a910f979fd79179f)

# Conflicts:
#	CHANGELOG.md
2025-12-25 17:24:53 -05:00
AntsyLich a6bd0bbd2a Fix reader not saving read duration when changing chapter (#2784)
(cherry picked from commit 2e0786f699cc6d4863eb20331739c8325a451e63)

# Conflicts:
#	CHANGELOG.md
2025-12-25 17:24:36 -05:00
Mend Renovate fd42bba188 Update dependency com.google.firebase:firebase-bom to v34.7.0 (#2782)
(cherry picked from commit e7e4d9b6b35033a0568f40e32e94aaf336c96c39)
2025-12-25 17:24:06 -05:00
AntsyLich a0ec735066 Use AGP provided NDK and Build Tools version
(cherry picked from commit 5fe7dd9f0612412afabd7646f89ba38b230fb7e7)

# Conflicts:
#	buildSrc/src/main/kotlin/mihon/buildlogic/AndroidConfig.kt
2025-12-25 17:23:59 -05:00
Mend Renovate 89f5fce19d Update dependency com.android.tools.build:gradle to v8.13.2 (#2780)
(cherry picked from commit 4cb05cc738862be52c5f53e43c943f20712c6153)
2025-12-25 17:23:18 -05:00
Jobobby04 bf711a995c Fix build 2025-12-25 17:23:01 -05:00
AntsyLich d977614b7a Update tracker icons (#2773)
(cherry picked from commit 876c3f951b7e9782054d8f788ab39772ae6cf440)

# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt
2025-12-25 17:22:53 -05:00
Mend Renovate d282df6973 Update dependency androidx.activity:activity-compose to v1.12.1 (#2760)
(cherry picked from commit 08a61a42e9b01f5591615e298c2fecc9f59762ac)
2025-12-25 16:55:40 -05:00
Mend Renovate db5b3a69cc Update dependency io.mockk:mockk to v1.14.7 (#2771)
(cherry picked from commit cadd36ad9a5721d19ea51b68910b8b3276965b3d)
2025-12-25 16:55:35 -05:00
Mend Renovate c70c5dff25 Update dependency io.kotest:kotest-assertions-core to v6.0.7 (#2749)
(cherry picked from commit 556371e1c89bfb0961b63629e79d3fed6a8e7999)
2025-12-25 16:55:24 -05:00
AntsyLich 25ace80419 Cleanup BaseOAuthLoginActivity and TrackLoginActivity (#2748)
(cherry picked from commit c222a28bd14c989b6fa0d53d7497bf063887c9ec)
2025-12-25 16:55:13 -05:00
Jobobby04 b8b468cea7 Minor fixes 2025-12-25 16:49:01 -05:00
NGB-Was-Taken 0ffc798e9a Add preference to toggle chapter URL hash for downloads (#1533) 2025-12-25 16:47:56 -05:00
renovate[bot] ad5a76741a Update actions/upload-artifact action to v6 (#1530)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-25 16:46:49 -05:00
Weblate (bot) 003c5ad39a Translations update from Hosted Weblate (#1529)
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/es/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/fil/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/id/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/it/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ru/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/zh_Hant/
Translation: Mihon/TachiyomiSY

Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Co-authored-by: Gino Cicatiello <ginocic@gmail.com>
Co-authored-by: Hiroshi <borlonjhayron1119@gmail.com>
Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Co-authored-by: MuhamadSyabitHidayattulloh <tebepc@gmail.com>
Co-authored-by: Nataniel Dika Kurniawan <hikawaart2@gmail.com>
Co-authored-by: Swyter <swyterzone@gmail.com>
Co-authored-by: ZerOriSama <godarms2010@live.com>
2025-12-25 16:46:06 -05:00
NGB-Was-Taken 582d0ef121 Add handling for previously unhandled preferences (delegated MD) (#1524)
* Include romanized titles of the original language in description

* Implement handling for `finalChapterInDesc` preference.

* Handle `preferExtensionLangTitle` preference when fetching manga details.

* Address some warnings, clean up unused code and spotless apply.
2025-12-11 13:58:56 -05:00
NGB-Was-Taken 5566db160b fix deletion of duplicate downloaded chapters when automatically marked as read (#1500) 2025-12-11 13:56:57 -05:00
renovate[bot] 6fb6838656 Update actions/checkout action to v6 (#1522)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 13:48:09 -05:00
renovate[bot] 1e5d490c22 Update actions/upload-artifact action to v5 (#1513)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 13:48:02 -05:00
renovate[bot] 276aeb0f59 Update gradle/actions action to v5 (#1508)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 13:47:56 -05:00
renovate[bot] c62d9d1446 Update actions/github-script action to v8 (#1497)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 13:47:44 -05:00
renovate[bot] 4ff18364d9 Update actions/setup-java action to v5 (#1493)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 13:47:36 -05:00
renovate[bot] 6c8e4e951a Update dependency net.zetetic:sqlcipher-android to v4.12.0 (#1485)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 13:47:28 -05:00
renovate[bot] dc1fde628d Update koin to v4.1.1 (#1466)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 13:47:09 -05:00
Weblate (bot) 241b70e5ce Translations update from Hosted Weblate (#1465)
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/es/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/ne/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/vi/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ar/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/de/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/eo/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/es/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/fil/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/fr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/hr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/hu/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/id/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/it/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ja/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ne/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/pt/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ru/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ta/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/uk/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/vi/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/zh_Hant/
Translation: Mihon/TachiyomiSY
Translation: Mihon/TachiyomiSY Plurals

Co-authored-by: Acelith <joel.jon@moix.me>
Co-authored-by: Adolfo Jayme Barrientos <fitojb@ubuntu.com>
Co-authored-by: Anderhale <anderhale@users.noreply.hosted.weblate.org>
Co-authored-by: Champ0999 <champ0999@users.noreply.hosted.weblate.org>
Co-authored-by: Conrad Mateman <conradmateme001@gmail.com>
Co-authored-by: Crazyom <naxom@laposte.net>
Co-authored-by: Deleted User <noreply+48943@weblate.org>
Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: Dika <hikawaart2@gmail.com>
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: Infy's Tagalog Translations <ced.paltep10@gmail.com>
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: MajorTanya <github@majortanya.eu>
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: Nguyễn Trung Đức <vaicato16@gmail.com>
Co-authored-by: Omgeta <anooptiger@hotmail.com>
Co-authored-by: Rahim Kansous <rahimkansous18@gmail.com>
Co-authored-by: Swyter <swyterzone@gmail.com>
Co-authored-by: TheKingTermux <50316075+TheKingTermux@users.noreply.github.com>
Co-authored-by: WarriorDan <Danpgl@live.it>
Co-authored-by: ZerOriSama <godarms2010@live.com>
Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Co-authored-by: dianisaac <muhandreop@gmail.com>
Co-authored-by: f_pluz <pedroh.lobo20@gmail.com>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: Đào Ngọc Đang Khoa <daongocdangkhoa2510@gmail.com>
Co-authored-by: ابْنُ السَدِيمِ <amarlubs2@gmail.com>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
2025-12-11 13:46:18 -05:00
216 changed files with 4879 additions and 1928 deletions
+1 -1
View File
@@ -100,5 +100,5 @@ body:
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
- label: I understand that **Mihon does not have or fix any extensions**, and I **will not receive help** for any issues related to sources or extensions. - label: I understand that **TachiyomiSY does not have or fix any extensions**, and I **will not receive help** for any issues related to sources or extensions.
required: true required: true
+4 -4
View File
@@ -12,22 +12,22 @@ jobs:
steps: steps:
- name: Clone repo - name: Clone repo
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
java-version: 17 java-version: 17
distribution: temurin distribution: temurin
- name: Set up gradle - name: Set up gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
- name: Build app - name: Build app
run: ./gradlew spotlessCheck assembleDevDebug run: ./gradlew spotlessCheck assembleDevDebug
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: TachiyomiSY-${{ github.sha }}.apk name: TachiyomiSY-${{ github.sha }}.apk
path: app/build/outputs/apk/dev/debug/app-dev-debug.apk path: app/build/outputs/apk/dev/debug/app-dev-debug.apk
+3 -3
View File
@@ -15,20 +15,20 @@ jobs:
steps: steps:
- name: Clone repo - name: Clone repo
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Android SDK - name: Setup Android SDK
run: | run: |
${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager "build-tools;29.0.3" ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager "build-tools;29.0.3"
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
java-version: 17 java-version: 17
distribution: temurin distribution: temurin
- name: Set up gradle - name: Set up gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
# SY --> # SY -->
- name: Write google-services.json - name: Write google-services.json
+3 -3
View File
@@ -12,16 +12,16 @@ jobs:
steps: steps:
- name: Clone repo - name: Clone repo
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
java-version: 17 java-version: 17
distribution: temurin distribution: temurin
- name: Set up gradle - name: Set up gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v5
- name: Create Tag - name: Create Tag
run: | run: |
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
steps: steps:
- name: Check PR and Add Label - name: Check PR and Add Label
uses: actions/github-script@v7 uses: actions/github-script@v8
with: with:
script: | script: |
const prAuthor = context.payload.pull_request.user.login; const prAuthor = context.payload.pull_request.user.login;
-1
View File
@@ -60,7 +60,6 @@ Additional features for some extensions, features include custom description, op
* Mangadex * Mangadex
* NHentai * NHentai
* Puruin * Puruin
* Tsumino
* LANraragi * LANraragi
## Download ## Download
+4 -1
View File
@@ -31,7 +31,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "eu.kanade.tachiyomi.sy" applicationId = "eu.kanade.tachiyomi.sy"
versionCode = 75 versionCode = 76
versionName = "1.12.0" versionName = "1.12.0"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
@@ -150,12 +150,14 @@ kotlin {
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi", "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi", "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi", "-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview", "-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlinx.coroutines.InternalCoroutinesApi", "-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi", "-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-Xannotation-default-target=param-property",
) )
} }
} }
@@ -265,6 +267,7 @@ dependencies {
implementation(libs.compose.grid) implementation(libs.compose.grid)
implementation(libs.reorderable) implementation(libs.reorderable)
implementation(libs.bundles.markdown) implementation(libs.bundles.markdown)
implementation(libs.materialKolor)
// Logging // Logging
implementation(libs.logcat) implementation(libs.logcat)
+2
View File
@@ -299,3 +299,5 @@
-dontwarn org.ietf.jgss.GSSManager -dontwarn org.ietf.jgss.GSSManager
-dontwarn org.ietf.jgss.GSSName -dontwarn org.ietf.jgss.GSSName
-dontwarn org.ietf.jgss.Oid -dontwarn org.ietf.jgss.Oid
-dontwarn com.google.re2j.Matcher
-dontwarn com.google.re2j.Pattern
@@ -60,6 +60,7 @@ import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.interactor.SetSortModeForCategory import tachiyomi.domain.category.interactor.SetSortModeForCategory
import tachiyomi.domain.category.interactor.UpdateCategory import tachiyomi.domain.category.interactor.UpdateCategory
import tachiyomi.domain.category.repository.CategoryRepository import tachiyomi.domain.category.repository.CategoryRepository
import tachiyomi.domain.chapter.interactor.GetBookmarkedChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetChapter import tachiyomi.domain.chapter.interactor.GetChapter
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
@@ -156,6 +157,7 @@ class DomainModule : InjektModule {
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) } addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
addFactory { GetChapter(get()) } addFactory { GetChapter(get()) }
addFactory { GetChaptersByMangaId(get()) } addFactory { GetChaptersByMangaId(get()) }
addFactory { GetBookmarkedChaptersByMangaId(get(), get(), get()) }
addFactory { GetChapterByUrlAndMangaId(get()) } addFactory { GetChapterByUrlAndMangaId(get()) }
addFactory { UpdateChapter(get()) } addFactory { UpdateChapter(get()) }
addFactory { SetReadStatus(get(), get(), get(), get(), get()) } addFactory { SetReadStatus(get(), get(), get(), get(), get()) }
@@ -30,8 +30,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import com.gowtham.ratingbar.RatingBar import com.gowtham.ratingbar.ComposeStars
import com.gowtham.ratingbar.RatingBarConfig import com.gowtham.ratingbar.RatingBarStyle
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.presentation.manga.components.MangaCover import eu.kanade.presentation.manga.components.MangaCover
import exh.metadata.MetadataUtil import exh.metadata.MetadataUtil
@@ -222,17 +222,18 @@ fun BrowseSourceEHentaiListItem(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
) { ) {
RatingBar( ComposeStars(
value = rating, value = rating,
onValueChange = {}, numOfStars = 5,
onRatingChanged = {}, size = 18.dp,
config = RatingBarConfig().apply { spaceBetween = 2.dp,
isIndicator(true) hideInactiveStars = false,
numStars(5) style = RatingBarStyle.Fill(
size(18.dp) activeColor = Color(0xFF005ED7),
activeColor(Color(0xFF005ED7)) inActiveColor = Color(0xE1E2ECFF),
inactiveColor(Color(0xE1E2ECFF)) ),
}, painterEmpty = null,
painterFilled = null,
) )
val color = genre?.first?.color val color = genre?.first?.color
val res = genre?.second val res = genre?.second
@@ -3,12 +3,12 @@ package eu.kanade.presentation.browse.components
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@Composable @Composable
@@ -17,7 +17,7 @@ fun BrowseSourceFloatingActionButton(
onFabClick: () -> Unit, onFabClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ExtendedFloatingActionButton( SmallExtendedFloatingActionButton(
modifier = modifier, modifier = modifier,
text = { text = {
Text( Text(
@@ -4,11 +4,11 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.shouldExpandFAB import tachiyomi.presentation.core.util.shouldExpandFAB
@@ -18,7 +18,7 @@ fun CategoryFloatingActionButton(
onCreate: () -> Unit, onCreate: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ExtendedFloatingActionButton( SmallExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.action_add)) }, text = { Text(text = stringResource(MR.strings.action_add)) },
icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) }, icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) },
onClick = onCreate, onClick = onCreate,
@@ -21,8 +21,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TooltipDefaults.rememberTooltipPositionProvider
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
@@ -195,7 +196,7 @@ fun AppBarActions(
actions.filterIsInstance<AppBar.Action>().map { actions.filterIsInstance<AppBar.Action>().map {
TooltipBox( TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { tooltip = {
PlainTooltip { PlainTooltip {
Text(it.title) Text(it.title)
@@ -220,7 +221,7 @@ fun AppBarActions(
val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>() val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>()
if (overflowActions.isNotEmpty()) { if (overflowActions.isNotEmpty()) {
TooltipBox( TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { tooltip = {
PlainTooltip { PlainTooltip {
Text(stringResource(MR.strings.action_menu_overflow_description)) Text(stringResource(MR.strings.action_menu_overflow_description))
@@ -349,7 +350,7 @@ fun SearchToolbar(
// Don't show search action // Don't show search action
} else if (searchQuery == null) { } else if (searchQuery == null) {
TooltipBox( TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { tooltip = {
PlainTooltip { PlainTooltip {
Text(stringResource(MR.strings.action_search)) Text(stringResource(MR.strings.action_search))
@@ -369,7 +370,7 @@ fun SearchToolbar(
} }
} else if (searchQuery.isNotEmpty()) { } else if (searchQuery.isNotEmpty()) {
TooltipBox( TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { tooltip = {
PlainTooltip { PlainTooltip {
Text(stringResource(MR.strings.action_reset)) Text(stringResource(MR.strings.action_reset))
@@ -22,7 +22,7 @@ fun relativeDateText(
Instant.ofEpochMilli(dateEpochMillis), Instant.ofEpochMilli(dateEpochMillis),
ZoneId.systemDefault(), ZoneId.systemDefault(),
) )
.takeIf { dateEpochMillis > 0L }, .takeIf { dateEpochMillis != 0L },
) )
} }
@@ -1,6 +1,5 @@
package eu.kanade.presentation.components package eu.kanade.presentation.components
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -14,11 +13,11 @@ import tachiyomi.presentation.core.i18n.stringResource
@Composable @Composable
fun DownloadDropdownMenu( fun DownloadDropdownMenu(
modifier: Modifier = Modifier,
expanded: Boolean, expanded: Boolean,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onDownloadClicked: (DownloadAction) -> Unit, onDownloadClicked: (DownloadAction) -> Unit,
offset: DpOffset? = null, offset: DpOffset? = null,
modifier: Modifier = Modifier,
) { ) {
if (offset != null) { if (offset != null) {
DropdownMenu( DropdownMenu(
@@ -49,7 +48,7 @@ fun DownloadDropdownMenu(
} }
@Composable @Composable
private fun ColumnScope.DownloadDropdownMenuItems( private fun DownloadDropdownMenuItems(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onDownloadClicked: (DownloadAction) -> Unit, onDownloadClicked: (DownloadAction) -> Unit,
) { ) {
@@ -59,6 +58,7 @@ private fun ColumnScope.DownloadDropdownMenuItems(
DownloadAction.NEXT_10_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 10, 10), DownloadAction.NEXT_10_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 10, 10),
DownloadAction.NEXT_25_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 25, 25), DownloadAction.NEXT_25_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 25, 25),
DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread), DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread),
DownloadAction.BOOKMARKED_CHAPTERS to stringResource(MR.strings.download_bookmarked),
) )
options.map { (downloadAction, string) -> options.map { (downloadAction, string) ->
@@ -1,10 +1,7 @@
package eu.kanade.presentation.manga package eu.kanade.presentation.manga
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -27,9 +24,11 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -101,7 +100,6 @@ import tachiyomi.domain.source.model.StubSource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.TwoPanelBox import tachiyomi.presentation.core.components.TwoPanelBox
import tachiyomi.presentation.core.components.VerticalFastScroller import tachiyomi.presentation.core.components.VerticalFastScroller
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.PullRefresh import tachiyomi.presentation.core.components.material.PullRefresh
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
@@ -167,7 +165,7 @@ fun MangaScreen(
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit, onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
// Chapter selection // Chapter selection
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit, onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
) { ) {
@@ -331,7 +329,7 @@ private fun MangaScreenSmallImpl(
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit, onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
// Chapter selection // Chapter selection
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit, onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
) { ) {
@@ -418,25 +416,23 @@ private fun MangaScreenSmallImpl(
val isFABVisible = remember(chapters) { val isFABVisible = remember(chapters) {
chapters.fastAny { !it.chapter.read } && !isAnySelected chapters.fastAny { !it.chapter.read } && !isAnySelected
} }
AnimatedVisibility( SmallExtendedFloatingActionButton(
visible = isFABVisible, text = {
enter = fadeIn(), val isReading = remember(state.chapters) {
exit = fadeOut(), state.chapters.fastAny { it.chapter.read }
) { }
ExtendedFloatingActionButton( Text(
text = { text = stringResource(if (isReading) MR.strings.action_resume else MR.strings.action_start),
val isReading = remember(state.chapters) { )
state.chapters.fastAny { it.chapter.read } },
} icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
Text( onClick = onContinueReading,
text = stringResource(if (isReading) MR.strings.action_resume else MR.strings.action_start), expanded = chapterListState.shouldExpandFAB(),
) modifier = Modifier.animateFloatingActionButton(
}, visible = isFABVisible,
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, alignment = Alignment.BottomEnd,
onClick = onContinueReading, ),
expanded = chapterListState.shouldExpandFAB(), )
)
}
}, },
) { contentPadding -> ) { contentPadding ->
val topPadding = contentPadding.calculateTopPadding() val topPadding = contentPadding.calculateTopPadding()
@@ -529,7 +525,7 @@ private fun MangaScreenSmallImpl(
// SY --> // SY -->
doSearch = onSearch, doSearch = onSearch,
searchMetadataChips = remember(state.meta, state.source.id, state.manga.genre) { searchMetadataChips = remember(state.meta, state.source.id, state.manga.genre) {
SearchMetadataChips(state.meta, state.source, state.manga.genre) SearchMetadataChips(state.meta, state.source.id, state.manga.genre)
}, },
// SY <-- // SY <--
) )
@@ -654,7 +650,7 @@ fun MangaScreenLargeImpl(
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit, onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
// Chapter selection // Chapter selection
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit, onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
) { ) {
@@ -737,27 +733,25 @@ fun MangaScreenLargeImpl(
val isFABVisible = remember(chapters) { val isFABVisible = remember(chapters) {
chapters.fastAny { !it.chapter.read } && !isAnySelected chapters.fastAny { !it.chapter.read } && !isAnySelected
} }
AnimatedVisibility( SmallExtendedFloatingActionButton(
visible = isFABVisible, text = {
enter = fadeIn(), val isReading = remember(state.chapters) {
exit = fadeOut(), state.chapters.fastAny { it.chapter.read }
) { }
ExtendedFloatingActionButton( Text(
text = { text = stringResource(
val isReading = remember(state.chapters) { if (isReading) MR.strings.action_resume else MR.strings.action_start,
state.chapters.fastAny { it.chapter.read } ),
} )
Text( },
text = stringResource( icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
if (isReading) MR.strings.action_resume else MR.strings.action_start, onClick = onContinueReading,
), expanded = chapterListState.shouldExpandFAB(),
) modifier = Modifier.animateFloatingActionButton(
}, visible = isFABVisible,
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, alignment = Alignment.BottomEnd,
onClick = onContinueReading, ),
expanded = chapterListState.shouldExpandFAB(), )
)
}
}, },
) { contentPadding -> ) { contentPadding ->
PullRefresh( PullRefresh(
@@ -824,7 +818,7 @@ fun MangaScreenLargeImpl(
// SY --> // SY -->
doSearch = onSearch, doSearch = onSearch,
searchMetadataChips = remember(state.meta, state.source.id, state.manga.genre) { searchMetadataChips = remember(state.meta, state.source.id, state.manga.genre) {
SearchMetadataChips(state.meta, state.source, state.manga.genre) SearchMetadataChips(state.meta, state.source.id, state.manga.genre)
}, },
// SY <-- // SY <--
) )
@@ -953,7 +947,7 @@ private fun LazyListScope.sharedChapterItems(
// SY <-- // SY <--
onChapterClicked: (Chapter) -> Unit, onChapterClicked: (Chapter) -> Unit,
onDownloadChapter: ((List<ChapterList.Item>, ChapterDownloadAction) -> Unit)?, onDownloadChapter: ((List<ChapterList.Item>, ChapterDownloadAction) -> Unit)?,
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit, onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
) { ) {
items( items(
@@ -1020,14 +1014,14 @@ private fun LazyListScope.sharedChapterItems(
chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction, chapterSwipeEndAction = chapterSwipeEndAction,
onLongClick = { onLongClick = {
onChapterSelected(item, !item.selected, true, true) onChapterSelected(item, !item.selected, true)
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}, },
onClick = { onClick = {
onChapterItemClick( onChapterItemClick(
chapterItem = item, chapterItem = item,
isAnyChapterSelected = isAnyChapterSelected, isAnyChapterSelected = isAnyChapterSelected,
onToggleSelection = { onChapterSelected(item, !item.selected, true, false) }, onToggleSelection = { onChapterSelected(item, !item.selected, false) },
onChapterClicked = onChapterClicked, onChapterClicked = onChapterClicked,
) )
}, },
@@ -6,6 +6,7 @@ enum class DownloadAction {
NEXT_10_CHAPTERS, NEXT_10_CHAPTERS,
NEXT_25_CHAPTERS, NEXT_25_CHAPTERS,
UNREAD_CHAPTERS, UNREAD_CHAPTERS,
BOOKMARKED_CHAPTERS,
} }
enum class EditCoverAction { enum class EditCoverAction {
@@ -93,10 +93,10 @@ fun MangaBottomActionMenu(
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val confirm = remember { mutableStateListOf(false, false, false, false, false, false, false) } val confirm = remember { mutableStateListOf(false, false, false, false, false, false, false) }
var resetJob: Job? = remember { null } var resetJob by remember { mutableStateOf<Job?>(null) }
val onLongClickItem: (Int) -> Unit = { toConfirmIndex -> val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
(0..<7).forEach { i -> confirm[i] = i == toConfirmIndex } confirm.indices.forEach { i -> confirm[i] = i == toConfirmIndex }
resetJob?.cancel() resetJob?.cancel()
resetJob = scope.launch { resetJob = scope.launch {
delay(1.seconds) delay(1.seconds)
@@ -260,10 +260,10 @@ fun LibraryBottomActionMenu(
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val confirm = remember { mutableStateListOf(false, false, false, false, false, false) } val confirm = remember { mutableStateListOf(false, false, false, false, false, false) }
var resetJob: Job? = remember { null } var resetJob by remember { mutableStateOf<Job?>(null) }
val onLongClickItem: (Int) -> Unit = { toConfirmIndex -> val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
(0..5).forEach { i -> confirm[i] = i == toConfirmIndex } confirm.indices.forEach { i -> confirm[i] = i == toConfirmIndex }
resetJob?.cancel() resetJob?.cancel()
resetJob = scope.launch { resetJob = scope.launch {
delay(1.seconds) delay(1.seconds)
@@ -605,44 +605,47 @@ private fun ColumnScope.MangaContentInfo(
} }
} }
private fun descriptionAnnotator(loadImages: Boolean, linkStyle: SpanStyle) = markdownAnnotator( @Composable
annotate = { content, child -> private fun descriptionAnnotator(loadImages: Boolean, linkStyle: SpanStyle) = remember(loadImages, linkStyle) {
if (!loadImages && child.type == MarkdownElementTypes.IMAGE) { markdownAnnotator(
val inlineLink = child.findChildOfType(MarkdownElementTypes.INLINE_LINK) annotate = { content, child ->
if (!loadImages && child.type == MarkdownElementTypes.IMAGE) {
val inlineLink = child.findChildOfType(MarkdownElementTypes.INLINE_LINK)
val url = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_DESTINATION) val url = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_DESTINATION)
?.getUnescapedTextInNode(content)
?: inlineLink?.findChildOfType(MarkdownElementTypes.AUTOLINK)
?.findChildOfType(MarkdownTokenTypes.AUTOLINK)
?.getUnescapedTextInNode(content) ?.getUnescapedTextInNode(content)
?: return@markdownAnnotator false ?: inlineLink?.findChildOfType(MarkdownElementTypes.AUTOLINK)
?.findChildOfType(MarkdownTokenTypes.AUTOLINK)
?.getUnescapedTextInNode(content)
?: return@markdownAnnotator false
val textNode = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TITLE) val textNode = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TITLE)
?: inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TEXT) ?: inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TEXT)
val altText = textNode?.findChildOfType(MarkdownTokenTypes.TEXT) val altText = textNode?.findChildOfType(MarkdownTokenTypes.TEXT)
?.getUnescapedTextInNode(content).orEmpty() ?.getUnescapedTextInNode(content).orEmpty()
withLink(LinkAnnotation.Url(url = url)) { withLink(LinkAnnotation.Url(url = url)) {
pushStyle(linkStyle) pushStyle(linkStyle)
appendInlineContent(MARKDOWN_INLINE_IMAGE_TAG) appendInlineContent(MARKDOWN_INLINE_IMAGE_TAG)
append(altText) append(altText)
pop() pop()
}
return@markdownAnnotator true
} }
return@markdownAnnotator true if (child.type in DISALLOWED_MARKDOWN_TYPES) {
} append(content.substring(child.startOffset, child.endOffset))
return@markdownAnnotator true
}
if (child.type in DISALLOWED_MARKDOWN_TYPES) { false
append(content.substring(child.startOffset, child.endOffset)) },
return@markdownAnnotator true config = markdownAnnotatorConfig(
} eolAsNewLine = true,
),
false )
}, }
config = markdownAnnotatorConfig(
eolAsNewLine = true,
),
)
@Composable @Composable
private fun MangaSummary( private fun MangaSummary(
@@ -17,7 +17,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -25,8 +24,6 @@ import eu.kanade.presentation.components.ChipBorder
import eu.kanade.presentation.components.SuggestionChip import eu.kanade.presentation.components.SuggestionChip
import eu.kanade.presentation.components.SuggestionChipDefaults import eu.kanade.presentation.components.SuggestionChipDefaults
import eu.kanade.presentation.theme.TachiyomiPreviewTheme import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.all.EHentai
import exh.metadata.metadata.EHentaiSearchMetadata import exh.metadata.metadata.EHentaiSearchMetadata
import exh.metadata.metadata.RaisedSearchMetadata import exh.metadata.metadata.RaisedSearchMetadata
import exh.metadata.metadata.base.RaisedTag import exh.metadata.metadata.base.RaisedTag
@@ -49,7 +46,7 @@ value class SearchMetadataChips(
val tags: Map<String, List<DisplayTag>>, val tags: Map<String, List<DisplayTag>>,
) { ) {
companion object { companion object {
operator fun invoke(meta: RaisedSearchMetadata?, source: Source, tags: List<String>?): SearchMetadataChips? { operator fun invoke(meta: RaisedSearchMetadata?, sourceId: Long, tags: List<String>?): SearchMetadataChips? {
return if (meta != null) { return if (meta != null) {
SearchMetadataChips( SearchMetadataChips(
meta.tags meta.tags
@@ -59,11 +56,11 @@ value class SearchMetadataChips(
namespace = it.namespace, namespace = it.namespace,
text = it.name, text = it.name,
search = if (!it.namespace.isNullOrEmpty()) { search = if (!it.namespace.isNullOrEmpty()) {
SourceTagsUtil.getWrappedTag(source.id, namespace = it.namespace, tag = it.name) SourceTagsUtil.getWrappedTag(sourceId, namespace = it.namespace, tag = it.name)
} else { } else {
SourceTagsUtil.getWrappedTag(source.id, fullTag = it.name) SourceTagsUtil.getWrappedTag(sourceId, fullTag = it.name)
} ?: it.name, } ?: it.name,
border = if (source.id == EXH_SOURCE_ID || source.id == EH_SOURCE_ID) { border = if (sourceId == EXH_SOURCE_ID || sourceId == EH_SOURCE_ID) {
when (it.type) { when (it.type) {
EHentaiSearchMetadata.TAG_TYPE_NORMAL -> 2 EHentaiSearchMetadata.TAG_TYPE_NORMAL -> 2
EHentaiSearchMetadata.TAG_TYPE_LIGHT -> 1 EHentaiSearchMetadata.TAG_TYPE_LIGHT -> 1
@@ -178,7 +175,6 @@ fun TagsChip(
fun NamespaceTagsPreview() { fun NamespaceTagsPreview() {
TachiyomiPreviewTheme { TachiyomiPreviewTheme {
Surface { Surface {
val context = LocalContext.current
NamespaceTags( NamespaceTags(
tags = remember { tags = remember {
EHentaiSearchMetadata().apply { EHentaiSearchMetadata().apply {
@@ -216,7 +212,7 @@ fun NamespaceTagsPreview() {
), ),
), ),
) )
}.let { SearchMetadataChips(it, EHentai(EXH_SOURCE_ID, true, context), emptyList()) }!! }.let { SearchMetadataChips(it, EXH_SOURCE_ID, emptyList()) }!!
}, },
onClick = {}, onClick = {},
) )
@@ -89,6 +89,7 @@ import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.GetAllManga import tachiyomi.domain.manga.interactor.GetAllManga
import tachiyomi.domain.manga.interactor.ResetViewerFlags import tachiyomi.domain.manga.interactor.ResetViewerFlags
@@ -117,6 +118,7 @@ object SettingsAdvancedScreen : SearchableSettings {
val basePreferences = remember { Injekt.get<BasePreferences>() } val basePreferences = remember { Injekt.get<BasePreferences>() }
val networkPreferences = remember { Injekt.get<NetworkPreferences>() } val networkPreferences = remember { Injekt.get<NetworkPreferences>() }
val libraryPreferences = remember { Injekt.get<LibraryPreferences>() } val libraryPreferences = remember { Injekt.get<LibraryPreferences>() }
val downloadPreferences = remember { Injekt.get<DownloadPreferences>() }
return listOf( return listOf(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
@@ -167,6 +169,7 @@ object SettingsAdvancedScreen : SearchableSettings {
getDataGroup(), getDataGroup(),
getNetworkGroup(networkPreferences = networkPreferences), getNetworkGroup(networkPreferences = networkPreferences),
getLibraryGroup(libraryPreferences = libraryPreferences), getLibraryGroup(libraryPreferences = libraryPreferences),
getDownloadsGroup(downloadPreferences = downloadPreferences),
getReaderGroup(basePreferences = basePreferences), getReaderGroup(basePreferences = basePreferences),
getExtensionsGroup(basePreferences = basePreferences), getExtensionsGroup(basePreferences = basePreferences),
// SY --> // SY -->
@@ -378,6 +381,24 @@ object SettingsAdvancedScreen : SearchableSettings {
) )
} }
// SY ->
@Composable
private fun getDownloadsGroup(
downloadPreferences: DownloadPreferences,
): Preference.PreferenceGroup {
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_downloads),
preferenceItems = persistentListOf(
Preference.PreferenceItem.SwitchPreference(
preference = downloadPreferences.includeChapterUrlHash(),
title = stringResource(SYMR.strings.pref_include_chapter_url_hash),
subtitle = stringResource(SYMR.strings.pref_include_chapter_url_hash_desc),
),
),
)
}
// <- SY
@Composable @Composable
private fun getReaderGroup( private fun getReaderGroup(
basePreferences: BasePreferences, basePreferences: BasePreferences,
@@ -245,7 +245,6 @@ object AboutScreen : Screen() {
is GetApplicationRelease.Result.OsTooOld -> { is GetApplicationRelease.Result.OsTooOld -> {
context.toast(MR.strings.update_check_eol) context.toast(MR.strings.update_check_eol)
} }
else -> {}
} }
} catch (e: Exception) { } catch (e: Exception) {
context.toast(e.message) context.toast(e.message)
@@ -2,13 +2,16 @@ package eu.kanade.presentation.more.settings.screen.about
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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 com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.util.htmlReadyLicenseContent import com.mikepenz.aboutlibraries.ui.compose.util.htmlReadyLicenseContent
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.R
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
@@ -27,7 +30,9 @@ class OpenSourceLicensesScreen : Screen() {
) )
}, },
) { contentPadding -> ) { contentPadding ->
val libraries by produceLibraries(R.raw.aboutlibraries)
LibrariesContainer( LibrariesContainer(
libraries = libraries,
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
contentPadding = contentPadding, contentPadding = contentPadding,
@@ -1,6 +1,7 @@
package eu.kanade.presentation.more.settings.screen.browse.components package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -61,6 +62,7 @@ fun ExtensionRepoCreateDialog(
OutlinedTextField( OutlinedTextField(
modifier = Modifier modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester), .focusRequester(focusRequester),
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
@@ -78,7 +78,7 @@ class DebugInfoScreen : Screen() {
val status by produceState(initialValue = "-") { val status by produceState(initialValue = "-") {
val result = ProfileVerifier.getCompilationStatusAsync().await().profileInstallResultCode val result = ProfileVerifier.getCompilationStatusAsync().await().profileInstallResultCode
value = when (result) { value = when (result) {
ProfileVerifier.CompilationStatus.RESULT_CODE_NO_PROFILE -> "No profile installed" ProfileVerifier.CompilationStatus.RESULT_CODE_NO_PROFILE_INSTALLED -> "No profile installed"
ProfileVerifier.CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE -> "Compiled" ProfileVerifier.CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE -> "Compiled"
ProfileVerifier.CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE_NON_MATCHING -> ProfileVerifier.CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE_NON_MATCHING ->
"Compiled non-matching" "Compiled non-matching"
@@ -1,10 +1,11 @@
package eu.kanade.presentation.theme package eu.kanade.presentation.theme
import android.content.Context
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.UiPreferences
import eu.kanade.domain.ui.model.AppTheme import eu.kanade.domain.ui.model.AppTheme
@@ -53,26 +54,36 @@ private fun BaseTachiyomiTheme(
isAmoled: Boolean, isAmoled: Boolean,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
MaterialTheme( val context = LocalContext.current
colorScheme = getThemeColorScheme(appTheme, isAmoled), val isDark = isSystemInDarkTheme()
MaterialExpressiveTheme(
colorScheme = remember(appTheme, isDark, isAmoled) {
getThemeColorScheme(
context = context,
appTheme = appTheme,
isDark = isDark,
isAmoled = isAmoled,
)
},
content = content, content = content,
) )
} }
@Composable
@ReadOnlyComposable
private fun getThemeColorScheme( private fun getThemeColorScheme(
context: Context,
appTheme: AppTheme, appTheme: AppTheme,
isDark: Boolean,
isAmoled: Boolean, isAmoled: Boolean,
): ColorScheme { ): ColorScheme {
val colorScheme = if (appTheme == AppTheme.MONET) { val colorScheme = if (appTheme == AppTheme.MONET) {
MonetColorScheme(LocalContext.current) MonetColorScheme(context)
} else { } else {
colorSchemes.getOrDefault(appTheme, TachiyomiColorScheme) colorSchemes.getOrDefault(appTheme, TachiyomiColorScheme)
} }
return colorScheme.getColorScheme( return colorScheme.getColorScheme(
isSystemInDarkTheme(), isDark = isDark,
isAmoled, isAmoled = isAmoled,
overrideDarkSurfaceContainers = appTheme != AppTheme.MONET,
) )
} }
@@ -14,16 +14,25 @@ internal abstract class BaseColorScheme {
private val surfaceContainerHigh = Color(0xFF131313) private val surfaceContainerHigh = Color(0xFF131313)
private val surfaceContainerHighest = Color(0xFF1B1B1B) private val surfaceContainerHighest = Color(0xFF1B1B1B)
fun getColorScheme(isDark: Boolean, isAmoled: Boolean): ColorScheme { fun getColorScheme(
isDark: Boolean,
isAmoled: Boolean,
overrideDarkSurfaceContainers: Boolean,
): ColorScheme {
if (!isDark) return lightScheme if (!isDark) return lightScheme
if (!isAmoled) return darkScheme if (!isAmoled) return darkScheme
return darkScheme.copy( val amoledScheme = darkScheme.copy(
background = Color.Black, background = Color.Black,
onBackground = Color.White, onBackground = Color.White,
surface = Color.Black, surface = Color.Black,
onSurface = Color.White, onSurface = Color.White,
)
if (!overrideDarkSurfaceContainers) return amoledScheme
return amoledScheme.copy(
surfaceVariant = surfaceContainer, // Navigation bar background (ThemePrefWidget) surfaceVariant = surfaceContainer, // Navigation bar background (ThemePrefWidget)
surfaceContainerLowest = surfaceContainer, surfaceContainerLowest = surfaceContainer,
surfaceContainerLow = surfaceContainer, surfaceContainerLow = surfaceContainer,
@@ -1,22 +1,17 @@
package eu.kanade.presentation.theme.colorscheme package eu.kanade.presentation.theme.colorscheme
import android.annotation.SuppressLint
import android.app.UiModeManager
import android.app.WallpaperManager import android.app.WallpaperManager
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.core.content.getSystemService import com.materialkolor.PaletteStyle
import com.google.android.material.color.utilities.Hct import com.materialkolor.dynamiccolor.ColorSpec
import com.google.android.material.color.utilities.MaterialDynamicColors import com.materialkolor.ktx.DynamicScheme
import com.google.android.material.color.utilities.QuantizerCelebi import com.materialkolor.toColorScheme
import com.google.android.material.color.utilities.SchemeContent
import com.google.android.material.color.utilities.Score
internal class MonetColorScheme(context: Context) : BaseColorScheme() { internal class MonetColorScheme(context: Context) : BaseColorScheme() {
@@ -28,7 +23,7 @@ internal class MonetColorScheme(context: Context) : BaseColorScheme() {
?.primaryColor ?.primaryColor
?.toArgb() ?.toArgb()
if (seed != null) { if (seed != null) {
MonetCompatColorScheme(context, seed) MonetCompatColorScheme(Color(seed))
} else { } else {
TachiyomiColorScheme TachiyomiColorScheme
} }
@@ -41,19 +36,6 @@ internal class MonetColorScheme(context: Context) : BaseColorScheme() {
override val lightScheme override val lightScheme
get() = monet.lightScheme get() = monet.lightScheme
companion object {
@Suppress("Unused")
@SuppressLint("RestrictedApi")
fun extractSeedColorFromImage(bitmap: Bitmap): Int? {
val width = bitmap.width
val height = bitmap.height
val bitmapPixels = IntArray(width * height)
bitmap.getPixels(bitmapPixels, 0, width, 0, 0, width, height)
return Score.score(QuantizerCelebi.quantize(bitmapPixels, 128), 1, 0)[0]
.takeIf { it != 0 } // Don't take fallback color
}
}
} }
@RequiresApi(Build.VERSION_CODES.S) @RequiresApi(Build.VERSION_CODES.S)
@@ -62,64 +44,19 @@ private class MonetSystemColorScheme(context: Context) : BaseColorScheme() {
override val darkScheme = dynamicDarkColorScheme(context) override val darkScheme = dynamicDarkColorScheme(context)
} }
private class MonetCompatColorScheme(context: Context, seed: Int) : BaseColorScheme() { internal class MonetCompatColorScheme(seed: Color) : BaseColorScheme() {
override val lightScheme = generateColorSchemeFromSeed(seed = seed, dark = false)
override val lightScheme = generateColorSchemeFromSeed(context = context, seed = seed, dark = false) override val darkScheme = generateColorSchemeFromSeed(seed = seed, dark = true)
override val darkScheme = generateColorSchemeFromSeed(context = context, seed = seed, dark = true)
companion object { companion object {
private fun Int.toComposeColor(): Color = Color(this) fun generateColorSchemeFromSeed(seed: Color, dark: Boolean): ColorScheme {
return DynamicScheme(
@SuppressLint("PrivateResource", "RestrictedApi") seedColor = seed,
private fun generateColorSchemeFromSeed(context: Context, seed: Int, dark: Boolean): ColorScheme { isDark = dark,
val scheme = SchemeContent( specVersion = ColorSpec.SpecVersion.SPEC_2025,
Hct.fromInt(seed), style = PaletteStyle.Expressive,
dark,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
context.getSystemService<UiModeManager>()?.contrast?.toDouble() ?: 0.0
} else {
0.0
},
)
val dynamicColors = MaterialDynamicColors()
return ColorScheme(
primary = dynamicColors.primary().getArgb(scheme).toComposeColor(),
onPrimary = dynamicColors.onPrimary().getArgb(scheme).toComposeColor(),
primaryContainer = dynamicColors.primaryContainer().getArgb(scheme).toComposeColor(),
onPrimaryContainer = dynamicColors.onPrimaryContainer().getArgb(scheme).toComposeColor(),
inversePrimary = dynamicColors.inversePrimary().getArgb(scheme).toComposeColor(),
secondary = dynamicColors.secondary().getArgb(scheme).toComposeColor(),
onSecondary = dynamicColors.onSecondary().getArgb(scheme).toComposeColor(),
secondaryContainer = dynamicColors.secondaryContainer().getArgb(scheme).toComposeColor(),
onSecondaryContainer = dynamicColors.onSecondaryContainer().getArgb(scheme).toComposeColor(),
tertiary = dynamicColors.tertiary().getArgb(scheme).toComposeColor(),
onTertiary = dynamicColors.onTertiary().getArgb(scheme).toComposeColor(),
tertiaryContainer = dynamicColors.tertiary().getArgb(scheme).toComposeColor(),
onTertiaryContainer = dynamicColors.onTertiaryContainer().getArgb(scheme).toComposeColor(),
background = dynamicColors.background().getArgb(scheme).toComposeColor(),
onBackground = dynamicColors.onBackground().getArgb(scheme).toComposeColor(),
surface = dynamicColors.surface().getArgb(scheme).toComposeColor(),
onSurface = dynamicColors.onSurface().getArgb(scheme).toComposeColor(),
surfaceVariant = dynamicColors.surfaceVariant().getArgb(scheme).toComposeColor(),
onSurfaceVariant = dynamicColors.onSurfaceVariant().getArgb(scheme).toComposeColor(),
surfaceTint = dynamicColors.surfaceTint().getArgb(scheme).toComposeColor(),
inverseSurface = dynamicColors.inverseSurface().getArgb(scheme).toComposeColor(),
inverseOnSurface = dynamicColors.inverseOnSurface().getArgb(scheme).toComposeColor(),
error = dynamicColors.error().getArgb(scheme).toComposeColor(),
onError = dynamicColors.onError().getArgb(scheme).toComposeColor(),
errorContainer = dynamicColors.errorContainer().getArgb(scheme).toComposeColor(),
onErrorContainer = dynamicColors.onErrorContainer().getArgb(scheme).toComposeColor(),
outline = dynamicColors.outline().getArgb(scheme).toComposeColor(),
outlineVariant = dynamicColors.outlineVariant().getArgb(scheme).toComposeColor(),
scrim = Color.Black,
surfaceBright = dynamicColors.surfaceBright().getArgb(scheme).toComposeColor(),
surfaceDim = dynamicColors.surfaceDim().getArgb(scheme).toComposeColor(),
surfaceContainer = dynamicColors.surfaceContainer().getArgb(scheme).toComposeColor(),
surfaceContainerHigh = dynamicColors.surfaceContainerHigh().getArgb(scheme).toComposeColor(),
surfaceContainerHighest = dynamicColors.surfaceContainerHighest().getArgb(scheme).toComposeColor(),
surfaceContainerLow = dynamicColors.surfaceContainerLow().getArgb(scheme).toComposeColor(),
surfaceContainerLowest = dynamicColors.surfaceContainerLowest().getArgb(scheme).toComposeColor(),
) )
.toColorScheme(isAmoled = false)
} }
} }
} }
@@ -1,5 +1,6 @@
package eu.kanade.presentation.track package eu.kanade.presentation.track
import android.content.ClipData
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -47,6 +48,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -55,11 +57,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.platform.toClipEntry
import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
@@ -73,6 +75,7 @@ import eu.kanade.presentation.manga.components.MangaCover
import eu.kanade.presentation.theme.TachiyomiPreviewTheme import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import kotlinx.coroutines.launch
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ScrollbarLazyColumn import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
@@ -240,7 +243,7 @@ private fun SearchResultItem(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val clipboardManager: ClipboardManager = LocalClipboardManager.current val clipboard: Clipboard = LocalClipboard.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val type = trackSearch.publishing_type.toLowerCase(Locale.current).capitalize(Locale.current) val type = trackSearch.publishing_type.toLowerCase(Locale.current).capitalize(Locale.current)
val status = trackSearch.publishing_status.toLowerCase(Locale.current).capitalize(Locale.current) val status = trackSearch.publishing_status.toLowerCase(Locale.current).capitalize(Locale.current)
@@ -248,6 +251,7 @@ private fun SearchResultItem(
val shape = RoundedCornerShape(16.dp) val shape = RoundedCornerShape(16.dp)
val borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent val borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent
var dropDownMenuExpanded by remember { mutableStateOf(false) } var dropDownMenuExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -295,7 +299,13 @@ private fun SearchResultItem(
expanded = dropDownMenuExpanded, expanded = dropDownMenuExpanded,
onCollapseMenu = { dropDownMenuExpanded = false }, onCollapseMenu = { dropDownMenuExpanded = false },
onCopyName = { onCopyName = {
clipboardManager.setText(AnnotatedString(trackSearch.title)) scope.launch {
val clipEntry = ClipData.newPlainText(
trackSearch.title,
trackSearch.title,
).toClipEntry()
clipboard.setClipEntry(clipEntry)
}
}, },
onOpenInBrowser = { onOpenInBrowser = {
val url = trackSearch.tracking_url val url = trackSearch.tracking_url
@@ -1,15 +1,11 @@
package eu.kanade.presentation.track.components package eu.kanade.presentation.track.components
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.draw.clip
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -30,18 +26,13 @@ fun TrackLogoIcon(
Modifier Modifier
} }
Box( Image(
painter = painterResource(tracker.getLogo()),
contentDescription = tracker.name,
modifier = modifier modifier = modifier
.size(48.dp) .size(48.dp)
.background(color = Color(tracker.getLogoColor()), shape = MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium),
.padding(4.dp), )
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(tracker.getLogo()),
contentDescription = tracker.name,
)
}
} }
@PreviewLightDark @PreviewLightDark
@@ -1,8 +1,6 @@
package eu.kanade.presentation.track.components package eu.kanade.presentation.track.components
import android.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.Tracker import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.test.DummyTracker import eu.kanade.test.DummyTracker
@@ -13,8 +11,6 @@ internal class TrackLogoIconPreviewProvider : PreviewParameterProvider<Tracker>
DummyTracker( DummyTracker(
id = 1L, id = 1L,
name = "Dummy Tracker", name = "Dummy Tracker",
valLogoColor = Color.rgb(18, 25, 35),
valLogo = R.drawable.ic_tracker_anilist,
), ),
) )
} }
@@ -0,0 +1,111 @@
package eu.kanade.presentation.updates
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import eu.kanade.presentation.components.TabbedDialog
import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.tachiyomi.ui.updates.UpdatesSettingsScreenModel
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.domain.updates.service.UpdatesPreferences
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.SettingsItemsPaddings
import tachiyomi.presentation.core.components.TriStateItem
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
@Composable
fun UpdatesFilterDialog(
onDismissRequest: () -> Unit,
screenModel: UpdatesSettingsScreenModel,
) {
TabbedDialog(
onDismissRequest = onDismissRequest,
tabTitles = persistentListOf(
stringResource(MR.strings.action_filter),
),
) {
Column(
modifier = Modifier
.padding(vertical = TabbedDialogPaddings.Vertical)
.verticalScroll(rememberScrollState()),
) {
FilterSheet(screenModel = screenModel)
}
}
}
@Composable
private fun ColumnScope.FilterSheet(
screenModel: UpdatesSettingsScreenModel,
) {
val filterDownloaded by screenModel.updatesPreferences.filterDownloaded().collectAsState()
TriStateItem(
label = stringResource(MR.strings.label_downloaded),
state = filterDownloaded,
onClick = { screenModel.toggleFilter(UpdatesPreferences::filterDownloaded) },
)
val filterUnread by screenModel.updatesPreferences.filterUnread().collectAsState()
TriStateItem(
label = stringResource(MR.strings.action_filter_unread),
state = filterUnread,
onClick = { screenModel.toggleFilter(UpdatesPreferences::filterUnread) },
)
val filterStarted by screenModel.updatesPreferences.filterStarted().collectAsState()
TriStateItem(
label = stringResource(MR.strings.label_started),
state = filterStarted,
onClick = { screenModel.toggleFilter(UpdatesPreferences::filterStarted) },
)
val filterBookmarked by screenModel.updatesPreferences.filterBookmarked().collectAsState()
TriStateItem(
label = stringResource(MR.strings.action_filter_bookmarked),
state = filterBookmarked,
onClick = { screenModel.toggleFilter(UpdatesPreferences::filterBookmarked) },
)
HorizontalDivider(modifier = Modifier.padding(MaterialTheme.padding.small))
val filterExcludedScanlators by screenModel.updatesPreferences.filterExcludedScanlators().collectAsState()
fun toggleScanlatorFilter() = screenModel.updatesPreferences.filterExcludedScanlators().getAndSet { !it }
Row(
modifier = Modifier
.clickable { toggleScanlatorFilter() }
.fillMaxWidth()
.padding(horizontal = SettingsItemsPaddings.Horizontal),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(MR.strings.action_filter_excluded_scanlators),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
)
Switch(
checked = filterExcludedScanlators,
onCheckedChange = { toggleScanlatorFilter() },
)
}
}
@@ -5,9 +5,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.FlipToBack import androidx.compose.material.icons.outlined.FlipToBack
import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.SelectAll import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
@@ -37,6 +40,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.theme.active
import java.time.LocalDate import java.time.LocalDate
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -57,8 +61,10 @@ fun UpdateScreen(
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit, onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit, onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
onMultiDeleteClicked: (List<UpdatesItem>) -> Unit, onMultiDeleteClicked: (List<UpdatesItem>) -> Unit,
onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit, onUpdateSelected: (UpdatesItem, Boolean, Boolean) -> Unit,
onOpenChapter: (UpdatesItem) -> Unit, onOpenChapter: (UpdatesItem) -> Unit,
onFilterClicked: () -> Unit,
hasActiveFilters: Boolean,
) { ) {
BackHandler(enabled = state.selectionMode) { BackHandler(enabled = state.selectionMode) {
onSelectAll(false) onSelectAll(false)
@@ -69,6 +75,8 @@ fun UpdateScreen(
UpdatesAppBar( UpdatesAppBar(
onCalendarClicked = { onCalendarClicked() }, onCalendarClicked = { onCalendarClicked() },
onUpdateLibrary = { onUpdateLibrary() }, onUpdateLibrary = { onUpdateLibrary() },
onFilterClicked = { onFilterClicked() },
hasFilters = hasActiveFilters,
actionModeCounter = state.selected.size, actionModeCounter = state.selected.size,
onSelectAll = { onSelectAll(true) }, onSelectAll = { onSelectAll(true) },
onInvertSelection = { onInvertSelection() }, onInvertSelection = { onInvertSelection() },
@@ -139,6 +147,8 @@ fun UpdateScreen(
private fun UpdatesAppBar( private fun UpdatesAppBar(
onCalendarClicked: () -> Unit, onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Unit, onUpdateLibrary: () -> Unit,
onFilterClicked: () -> Unit,
hasFilters: Boolean,
// For action mode // For action mode
actionModeCounter: Int, actionModeCounter: Int,
onSelectAll: () -> Unit, onSelectAll: () -> Unit,
@@ -153,6 +163,12 @@ private fun UpdatesAppBar(
actions = { actions = {
AppBarActions( AppBarActions(
persistentListOf( persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.action_filter),
icon = Icons.Outlined.FilterList,
iconTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current,
onClick = onFilterClicked,
),
AppBar.Action( AppBar.Action(
title = stringResource(MR.strings.action_view_upcoming), title = stringResource(MR.strings.action_view_upcoming),
icon = Icons.Outlined.CalendarMonth, icon = Icons.Outlined.CalendarMonth,
@@ -72,7 +72,7 @@ internal fun LazyListScope.updatesUiItems(
// SY --> // SY -->
preserveReadingPosition: Boolean, preserveReadingPosition: Boolean,
// SY <-- // SY <--
onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit, onUpdateSelected: (UpdatesItem, Boolean, Boolean) -> Unit,
onClickCover: (UpdatesItem) -> Unit, onClickCover: (UpdatesItem) -> Unit,
onClickUpdate: (UpdatesItem) -> Unit, onClickUpdate: (UpdatesItem) -> Unit,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit, onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
@@ -120,11 +120,11 @@ internal fun LazyListScope.updatesUiItems(
) )
}, },
onLongClick = { onLongClick = {
onUpdateSelected(updatesItem, !updatesItem.selected, true, true) onUpdateSelected(updatesItem, !updatesItem.selected, true)
}, },
onClick = { onClick = {
when { when {
selectionMode -> onUpdateSelected(updatesItem, !updatesItem.selected, true, false) selectionMode -> onUpdateSelected(updatesItem, !updatesItem.selected, false)
else -> onClickUpdate(updatesItem) else -> onClickUpdate(updatesItem)
} }
}, },
@@ -9,21 +9,21 @@ import tachiyomi.domain.source.model.SourceNotInstalledException
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import java.net.UnknownHostException import java.net.UnknownHostException
context(Context) context(context: Context)
val Throwable.formattedMessage: String val Throwable.formattedMessage: String
get() { get() {
when (this) { when (this) {
is HttpException -> return stringResource(MR.strings.exception_http, code) is HttpException -> return context.stringResource(MR.strings.exception_http, code)
is UnknownHostException -> { is UnknownHostException -> {
return if (!isOnline()) { return if (!context.isOnline()) {
stringResource(MR.strings.exception_offline) context.stringResource(MR.strings.exception_offline)
} else { } else {
stringResource(MR.strings.exception_unknown_host, message ?: "") context.stringResource(MR.strings.exception_unknown_host, message ?: "")
} }
} }
is NoResultsException -> return stringResource(MR.strings.no_results_found) is NoResultsException -> return context.stringResource(MR.strings.no_results_found)
is SourceNotInstalledException -> return stringResource(MR.strings.loader_not_implemented_error) is SourceNotInstalledException -> return context.stringResource(MR.strings.loader_not_implemented_error)
} }
return when (val className = this::class.simpleName) { return when (val className = this::class.simpleName) {
"Exception", "IOException" -> message ?: className "Exception", "IOException" -> message ?: className
@@ -4,5 +4,7 @@ import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
// https://issuetracker.google.com/352584409 // https://issuetracker.google.com/352584409
context(LazyItemScope) context(itemScope: LazyItemScope)
fun Modifier.animateItemFastScroll() = this.animateItem(fadeInSpec = null, fadeOutSpec = null) fun Modifier.animateItemFastScroll() = with(itemScope) {
this@animateItemFastScroll.animateItem(fadeInSpec = null, fadeOutSpec = null)
}
@@ -80,7 +80,7 @@ fun EhLoginWebViewScreen(
) )
is LoadingState.Loading -> { is LoadingState.Loading -> {
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
(loadingState as? LoadingState.Loading)?.progress ?: 1f, loadingState.progress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "webview_loading", label = "webview_loading",
) )
@@ -273,7 +273,7 @@ fun WebViewScreenContent(
.align(Alignment.BottomCenter), .align(Alignment.BottomCenter),
) )
is LoadingState.Loading -> LinearProgressIndicator( is LoadingState.Loading -> LinearProgressIndicator(
progress = { (loadingState as? LoadingState.Loading)?.progress ?: 1f }, progress = { loadingState.progress },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter), .align(Alignment.BottomCenter),
@@ -19,6 +19,7 @@ import androidx.work.Configuration
import androidx.work.WorkManager import androidx.work.WorkManager
import coil3.ImageLoader import coil3.ImageLoader
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565 import coil3.request.allowRgb565
import coil3.request.crossfade import coil3.request.crossfade
@@ -247,6 +248,12 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
// SY <-- // SY <--
} }
memoryCache(
MemoryCache.Builder()
.maxSizePercent(context)
.build(),
)
crossfade((300 * this@App.animatorDurationScale).toInt()) crossfade((300 * this@App.animatorDurationScale).toInt())
allowRgb565(DeviceUtil.isLowRamDevice(this@App)) allowRgb565(DeviceUtil.isLowRamDevice(this@App))
if (networkPreferences.verboseLogging().get()) logger(DebugLogger()) if (networkPreferences.verboseLogging().get()) logger(DebugLogger())
@@ -10,6 +10,7 @@ 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.download.service.DownloadPreferences
import tachiyomi.domain.library.service.LibraryPreferences 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
@@ -28,6 +29,7 @@ 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 libraryPreferences: LibraryPreferences = Injekt.get(),
private val downloadPreferences: DownloadPreferences = Injekt.get(),
) { ) {
private val downloadsDir: UniFile? private val downloadsDir: UniFile?
@@ -190,6 +192,7 @@ class DownloadProvider(
chapterScanlator: String?, chapterScanlator: String?,
chapterUrl: String, chapterUrl: String,
disallowNonAsciiFilenames: Boolean = libraryPreferences.disallowNonAsciiFilenames().get(), disallowNonAsciiFilenames: Boolean = libraryPreferences.disallowNonAsciiFilenames().get(),
includeChapterUrlHash: Boolean = downloadPreferences.includeChapterUrlHash().get(),
): String { ): String {
var dirName = sanitizeChapterName(chapterName) var dirName = sanitizeChapterName(chapterName)
if (!chapterScanlator.isNullOrBlank()) { if (!chapterScanlator.isNullOrBlank()) {
@@ -197,7 +200,7 @@ class DownloadProvider(
} }
// Subtract 7 bytes for hash and underscore, 4 bytes for .cbz // Subtract 7 bytes for hash and underscore, 4 bytes for .cbz
dirName = DiskUtil.buildValidFilename(dirName, DiskUtil.MAX_FILE_NAME_BYTES - 11, disallowNonAsciiFilenames) dirName = DiskUtil.buildValidFilename(dirName, DiskUtil.MAX_FILE_NAME_BYTES - 11, disallowNonAsciiFilenames)
dirName += "_" + md5(chapterUrl).take(6) if (includeChapterUrlHash) dirName += "_" + md5(chapterUrl).take(6)
return dirName return dirName
} }
@@ -233,6 +236,7 @@ class DownloadProvider(
chapterScanlator, chapterScanlator,
chapterUrl, chapterUrl,
!libraryPreferences.disallowNonAsciiFilenames().get(), !libraryPreferences.disallowNonAsciiFilenames().get(),
!downloadPreferences.includeChapterUrlHash().get(),
) )
return buildList(2) { return buildList(2) {
@@ -420,7 +420,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
is SourceNotInstalledException -> context.stringResource( is SourceNotInstalledException -> context.stringResource(
MR.strings.loader_not_implemented_error, MR.strings.loader_not_implemented_error,
) )
else -> e.message else -> e.message
} }
failedUpdates.add(manga to errorMessage) failedUpdates.add(manga to errorMessage)
@@ -692,8 +691,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
} }
return file return file
} }
} catch (_: Exception) { } catch (_: Exception) {}
}
return File("") return File("")
} }
@@ -737,10 +735,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
const val KEY_GROUP_EXTRA = "group_extra" const val KEY_GROUP_EXTRA = "group_extra"
// SY <-- // SY <--
fun cancelAllWorks(context: Context) {
context.workManager.cancelAllWorkByTag(TAG)
}
fun setupTask( fun setupTask(
context: Context, context: Context,
prefInterval: Int? = null, prefInterval: Int? = null,
@@ -754,16 +748,19 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
} else { } else {
NetworkType.CONNECTED NetworkType.CONNECTED
} }
val networkRequestBuilder = NetworkRequest.Builder() val networkRequest = NetworkRequest.Builder().apply {
if (DEVICE_ONLY_ON_WIFI in restrictions) { removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
networkRequestBuilder.addTransportType(NetworkCapabilities.TRANSPORT_WIFI) if (DEVICE_ONLY_ON_WIFI in restrictions) {
} addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
if (DEVICE_NETWORK_NOT_METERED in restrictions) { }
networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) if (DEVICE_NETWORK_NOT_METERED in restrictions) {
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
} }
.build()
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
// 'networkRequest' only applies to Android 9+, otherwise 'networkType' is used // 'networkRequest' only applies to Android 9+, otherwise 'networkType' is used
.setRequiredNetworkRequest(networkRequestBuilder.build(), networkType) .setRequiredNetworkRequest(networkRequest, networkType)
.setRequiresCharging(DEVICE_CHARGING in restrictions) .setRequiresCharging(DEVICE_CHARGING in restrictions)
.setRequiresBatteryNotLow(true) .setRequiresBatteryNotLow(true)
.build() .build()
@@ -5,8 +5,14 @@ import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.sync.SyncNotifier import eu.kanade.tachiyomi.data.sync.SyncNotifier
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
@@ -34,7 +40,26 @@ class SyncYomiSyncService(
private class SyncYomiException(message: String?) : Exception(message) private class SyncYomiException(message: String?) : Exception(message)
@Serializable
private data class SyncEvent(
val event: SyncEventStatus,
@SerialName("device_name")
val deviceName: String? = null,
val message: String? = null,
)
@Serializable
private enum class SyncEventStatus {
SYNC_STARTED,
SYNC_SUCCESS,
SYNC_FAILED,
SYNC_ERROR,
SYNC_CANCELLED,
}
override suspend fun doSync(syncData: SyncData): Backup? { override suspend fun doSync(syncData: SyncData): Backup? {
reportSyncEvent(SyncEventStatus.SYNC_STARTED)
try { try {
val (remoteData, etag) = pullSyncData() val (remoteData, etag) = pullSyncData()
@@ -52,11 +77,23 @@ class SyncYomiSyncService(
syncData syncData
} }
pushSyncData(finalSyncData, etag) val success = pushSyncData(finalSyncData, etag)
if (success) {
reportSyncEvent(SyncEventStatus.SYNC_SUCCESS)
} else {
reportSyncEvent(SyncEventStatus.SYNC_FAILED, "Failed to push sync data")
}
return finalSyncData.backup return finalSyncData.backup
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) {
reportSyncEvent(SyncEventStatus.SYNC_CANCELLED, e.message)
throw e
}
logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" } logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" }
notifier.showSyncError(e.message) notifier.showSyncError(e.message)
reportSyncEvent(SyncEventStatus.SYNC_ERROR, e.message)
return null return null
} }
} }
@@ -123,8 +160,8 @@ class SyncYomiSyncService(
/** /**
* Return true if update success * Return true if update success
*/ */
private suspend fun pushSyncData(syncData: SyncData, eTag: String) { private suspend fun pushSyncData(syncData: SyncData, eTag: String): Boolean {
val backup = syncData.backup ?: return val backup = syncData.backup ?: return true
val host = syncPreferences.clientHost().get() val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get() val apiKey = syncPreferences.clientAPIKey().get()
@@ -163,13 +200,49 @@ class SyncYomiSyncService(
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag") .takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
syncPreferences.lastSyncEtag().set(newETag) syncPreferences.lastSyncEtag().set(newETag)
logcat(LogPriority.DEBUG) { "SyncYomi sync completed" } logcat(LogPriority.DEBUG) { "SyncYomi sync completed" }
return true
} else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) { } else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) {
// other clients updated remote data, will try next time // other clients updated remote data, will try next time
logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" } logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" }
return false
} else { } else {
val responseBody = response.body.string() val responseBody = response.body.string()
notifier.showSyncError("Failed to upload sync data: $responseBody") notifier.showSyncError("Failed to upload sync data: $responseBody")
logcat(LogPriority.ERROR) { "SyncError: $responseBody" } logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
return false
}
}
private suspend fun reportSyncEvent(event: SyncEventStatus, message: String? = null) {
withContext(NonCancellable) {
try {
val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get()
val url = "$host/api/sync/event"
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
val headers = headersBuilder.build()
val bodyObj = SyncEvent(
event = event,
deviceName = android.os.Build.MODEL,
message = message,
)
val jsonBody = json.encodeToString(SyncEvent.serializer(), bodyObj)
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = POST(
url = url,
headers = headers,
body = requestBody,
)
val client = OkHttpClient()
client.newCall(request).await().close()
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Failed to report sync event: ${e.message}" }
}
} }
} }
} }
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.data.track package eu.kanade.tachiyomi.data.track
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
@@ -25,9 +24,6 @@ interface Tracker {
val supportsPrivateTracking: Boolean val supportsPrivateTracking: Boolean
@ColorInt
fun getLogoColor(): Int
@DrawableRes @DrawableRes
fun getLogo(): Int fun getLogo(): Int
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.anilist package eu.kanade.tachiyomi.data.track.anilist
import android.graphics.Color
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.track.model.toDbTrack import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@@ -57,9 +56,7 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
} }
} }
override fun getLogo() = R.drawable.ic_tracker_anilist override fun getLogo() = R.drawable.brand_anilist
override fun getLogoColor() = Color.rgb(18, 25, 35)
override fun getStatusList(): List<Long> { override fun getStatusList(): List<Long> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING) return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING)
@@ -45,9 +45,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
suspend fun addLibManga(track: Track): Track { suspend fun addLibManga(track: Track): Track {
return withIOContext { return withIOContext {
val query = """ val query = $$"""
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}private: Boolean) { |mutation AddManga($mangaId: Int, $progress: Int, $status: MediaListStatus, $private: Boolean) {
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status, private: ${'$'}private) { |SaveMediaListEntry (mediaId: $mangaId, progress: $progress, status: $status, private: $private) {
| id | id
| status | status
|} |}
@@ -82,14 +82,14 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
suspend fun updateLibManga(track: Track): Track { suspend fun updateLibManga(track: Track): Track {
return withIOContext { return withIOContext {
val query = """ val query = $$"""
|mutation UpdateManga( |mutation UpdateManga(
|${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}private: Boolean, |$listId: Int, $progress: Int, $status: MediaListStatus, $private: Boolean,
|${'$'}score: Int, ${'$'}startedAt: FuzzyDateInput, ${'$'}completedAt: FuzzyDateInput |$score: Int, $startedAt: FuzzyDateInput, $completedAt: FuzzyDateInput
|) { |) {
|SaveMediaListEntry( |SaveMediaListEntry(
|id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, private: ${'$'}private, |id: $listId, progress: $progress, status: $status, private: $private,
|scoreRaw: ${'$'}score, startedAt: ${'$'}startedAt, completedAt: ${'$'}completedAt |scoreRaw: $score, startedAt: $startedAt, completedAt: $completedAt
|) { |) {
|id |id
|status |status
@@ -118,9 +118,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
suspend fun deleteLibManga(track: DomainTrack) { suspend fun deleteLibManga(track: DomainTrack) {
withIOContext { withIOContext {
val query = """ val query = $$"""
|mutation DeleteManga(${'$'}listId: Int) { |mutation DeleteManga($listId: Int) {
|DeleteMediaListEntry(id: ${'$'}listId) { |DeleteMediaListEntry(id: $listId) {
|deleted |deleted
|} |}
|} |}
@@ -139,10 +139,10 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
suspend fun search(search: String): List<TrackSearch> { suspend fun search(search: String): List<TrackSearch> {
return withIOContext { return withIOContext {
val query = """ val query = $$"""
|query Search(${'$'}query: String) { |query Search($query: String) {
|Page (perPage: 50) { |Page (perPage: 50) {
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { |media(search: $query, type: MANGA, format_not_in: [NOVEL]) {
|id |id
|staff { |staff {
|edges { |edges {
@@ -201,10 +201,10 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
suspend fun findLibManga(track: Track, userid: Int): Track? { suspend fun findLibManga(track: Track, userid: Int): Track? {
return withIOContext { return withIOContext {
val query = """ val query = $$"""
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) { |query ($id: Int!, $manga_id: Int!) {
|Page { |Page {
|mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) { |mediaList(userId: $id, type: MANGA, mediaId: $manga_id) {
|id |id
|status |status
|scoreRaw: score(format: POINT_100) |scoreRaw: score(format: POINT_100)
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.bangumi package eu.kanade.tachiyomi.data.track.bangumi
import android.graphics.Color
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
@@ -84,9 +83,7 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
return track return track
} }
override fun getLogo() = R.drawable.ic_tracker_bangumi override fun getLogo() = R.drawable.brand_bangumi
override fun getLogoColor() = Color.rgb(240, 145, 153)
override fun getStatusList(): List<Long> { override fun getStatusList(): List<Long> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
@@ -116,7 +113,7 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
// Users can set a 'username' (not nickname) once which effectively // Users can set a 'username' (not nickname) once which effectively
// replaces the stringified ID in certain queries. // replaces the stringified ID in certain queries.
// If no username is set, the API returns the user ID as a strings // If no username is set, the API returns the user ID as a strings
var username = api.getUsername() val username = api.getUsername()
saveCredentials(username, oauth.accessToken) saveCredentials(username, oauth.accessToken)
} catch (_: Throwable) { } catch (_: Throwable) {
logout() logout()
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.kavita package eu.kanade.tachiyomi.data.track.kavita
import android.graphics.Color
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
@@ -34,9 +33,7 @@ class Kavita(id: Long) : BaseTracker(id, "Kavita"), EnhancedTracker {
private val sourceManager: SourceManager by injectLazy() private val sourceManager: SourceManager by injectLazy()
override fun getLogo(): Int = R.drawable.ic_tracker_kavita override fun getLogo(): Int = R.drawable.brand_kavita
override fun getLogoColor() = Color.rgb(74, 198, 148)
override fun getStatusList(): List<Long> = listOf(UNREAD, READING, COMPLETED) override fun getStatusList(): List<Long> = listOf(UNREAD, READING, COMPLETED)
@@ -140,7 +137,7 @@ class Kavita(id: Long) : BaseTracker(id, "Kavita"), EnhancedTracker {
} }
authentication.apiUrl = prefApiUrl authentication.apiUrl = prefApiUrl
authentication.jwtToken = token.toString() authentication.jwtToken = token
} }
authentications = oauth authentications = oauth
} }
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.kitsu package eu.kanade.tachiyomi.data.track.kitsu
import android.graphics.Color
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
@@ -37,9 +36,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
private val api by lazy { KitsuApi(client, interceptor) } private val api by lazy { KitsuApi(client, interceptor) }
override fun getLogo() = R.drawable.ic_tracker_kitsu override fun getLogo() = R.drawable.brand_kitsu
override fun getLogoColor() = Color.rgb(51, 37, 50)
override fun getStatusList(): List<Long> { override fun getStatusList(): List<Long> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.komga package eu.kanade.tachiyomi.data.track.komga
import android.graphics.Color
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
@@ -31,9 +30,7 @@ class Komga(id: Long) : BaseTracker(id, "Komga"), EnhancedTracker {
val api by lazy { KomgaApi(id, client) } val api by lazy { KomgaApi(id, client) }
override fun getLogo() = R.drawable.ic_tracker_komga override fun getLogo() = R.drawable.brand_komga
override fun getLogoColor() = Color.rgb(51, 37, 50)
override fun getStatusList(): List<Long> = listOf(UNREAD, READING, COMPLETED) override fun getStatusList(): List<Long> = listOf(UNREAD, READING, COMPLETED)
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.mangaupdates package eu.kanade.tachiyomi.data.track.mangaupdates
import android.graphics.Color
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
@@ -44,9 +43,7 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker
private val api by lazy { MangaUpdatesApi(interceptor, client) } private val api by lazy { MangaUpdatesApi(interceptor, client) }
override fun getLogo(): Int = R.drawable.ic_manga_updates override fun getLogo(): Int = R.drawable.brand_mangaupdates
override fun getLogoColor(): Int = Color.rgb(146, 160, 173)
override fun getStatusList(): List<Long> { override fun getStatusList(): List<Long> {
return listOf(READING_LIST, COMPLETE_LIST, ON_HOLD_LIST, UNFINISHED_LIST, WISH_LIST) return listOf(READING_LIST, COMPLETE_LIST, ON_HOLD_LIST, UNFINISHED_LIST, WISH_LIST)
@@ -121,7 +118,7 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker
override suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata? { override suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata? {
val series = api.getSeries(track) val series = api.getSeries(track)
return series?.let { return series.let {
TrackMangaMetadata( TrackMangaMetadata(
it.seriesId, it.seriesId,
it.title?.htmlDecode(), it.title?.htmlDecode(),
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.mdlist package eu.kanade.tachiyomi.data.track.mdlist
import android.graphics.Color
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.track.model.toDbTrack import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@@ -33,11 +32,7 @@ class MdList(id: Long) : BaseTracker(id, "MDList") {
val interceptor = MangaDexAuthInterceptor(trackPreferences, this) val interceptor = MangaDexAuthInterceptor(trackPreferences, this)
override fun getLogo(): Int { override fun getLogo(): Int {
return R.drawable.ic_tracker_mangadex_logo return R.drawable.brand_mangadex
}
override fun getLogoColor(): Int {
return Color.rgb(43, 48, 53)
} }
override fun getStatusList(): List<Long> { override fun getStatusList(): List<Long> {
@@ -168,17 +163,17 @@ class MdList(id: Long) : BaseTracker(id, "MDList") {
trackPreferences.trackToken(this).delete() trackPreferences.trackToken(this).delete()
} }
override suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata? { override suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata {
return withIOContext { return withIOContext {
val mdex = mdex ?: throw MangaDexNotFoundException() val mdex = mdex ?: throw MangaDexNotFoundException()
val manga = mdex.getMangaMetadata(track.toDbTrack()) val manga = mdex.getMangaMetadata(track.toDbTrack())
TrackMangaMetadata( TrackMangaMetadata(
remoteId = 0, remoteId = 0,
title = manga?.title, title = manga.title,
thumbnailUrl = manga?.thumbnail_url, // Doesn't load the actual cover because of Refer header thumbnailUrl = manga.thumbnail_url, // Doesn't load the actual cover because of Refer header
description = manga?.description, description = manga.description,
authors = manga?.author, authors = manga.author,
artists = manga?.artist, artists = manga.artist,
) )
} }
} }
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.myanimelist package eu.kanade.tachiyomi.data.track.myanimelist
import android.graphics.Color
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
@@ -41,9 +40,7 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
override val supportsReadingDates: Boolean = true override val supportsReadingDates: Boolean = true
override fun getLogo() = R.drawable.ic_tracker_mal override fun getLogo() = R.drawable.brand_myanimelist
override fun getLogoColor() = Color.rgb(46, 81, 162)
override fun getStatusList(): List<Long> { override fun getStatusList(): List<Long> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING) return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING)
@@ -12,15 +12,12 @@ import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALMangaMetadata
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALSearchResult import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALSearchResult
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUser import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUser
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUserSearchResult
import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.PkceUtil import eu.kanade.tachiyomi.util.PkceUtil
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.Headers import okhttp3.Headers
@@ -80,15 +77,15 @@ class MyAnimeListApi(
// MAL API throws a 400 when the query is over 64 characters... // MAL API throws a 400 when the query is over 64 characters...
.appendQueryParameter("q", query.take(64)) .appendQueryParameter("q", query.take(64))
.appendQueryParameter("nsfw", "true") .appendQueryParameter("nsfw", "true")
.appendQueryParameter("fields", SEARCH_FIELDS)
.build() .build()
with(json) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<MALSearchResult>() .parseAs<MALSearchResult>()
.data .data
.map { async { getMangaDetails(it.node.id) } } .filter { !(it.node.mediaType.contains("novel")) }
.awaitAll() .map { parseSearchItem(it.node) }
.filter { !it.publishing_type.contains("novel") }
} }
} }
} }
@@ -97,29 +94,13 @@ class MyAnimeListApi(
return withIOContext { return withIOContext {
val url = "$BASE_API_URL/manga".toUri().buildUpon() val url = "$BASE_API_URL/manga".toUri().buildUpon()
.appendPath(id.toString()) .appendPath(id.toString())
.appendQueryParameter( .appendQueryParameter("fields", SEARCH_FIELDS)
"fields",
"id,title,synopsis,num_chapters,mean,main_picture,status,media_type,start_date",
)
.build() .build()
with(json) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<MALManga>() .parseAs<MALManga>()
.let { .let { parseSearchItem(it) }
TrackSearch.create(trackId).apply {
remote_id = it.id
title = it.title
summary = it.synopsis
total_chapters = it.numChapters
score = it.mean
cover_url = it.covers?.large.orEmpty()
tracking_url = "https://myanimelist.net/manga/$remote_id"
publishing_status = it.status.replace("_", " ")
publishing_type = it.mediaType.replace("_", " ")
start_date = it.startDate ?: ""
}
}
} }
} }
} }
@@ -183,8 +164,7 @@ class MyAnimeListApi(
val matches = myListSearchResult.data val matches = myListSearchResult.data
.filter { it.node.title.contains(query, ignoreCase = true) } .filter { it.node.title.contains(query, ignoreCase = true) }
.map { async { getMangaDetails(it.node.id) } } .map { parseSearchItem(it.node) }
.awaitAll()
// Check next page if there's more // Check next page if there's more
if (!myListSearchResult.paging.next.isNullOrBlank()) { if (!myListSearchResult.paging.next.isNullOrBlank()) {
@@ -216,12 +196,12 @@ class MyAnimeListApi(
description = it.synopsis, description = it.synopsis,
authors = it.authors authors = it.authors
.filter { it.role == "Story" || it.role == "Story & Art" } .filter { it.role == "Story" || it.role == "Story & Art" }
.map { "${it.node.firstName} ${it.node.lastName}".trim() } .mapNotNull { it.node.getFullName() }
.joinToString(separator = ", ") .joinToString(separator = ", ")
.ifEmpty { null }, .ifEmpty { null },
artists = it.authors artists = it.authors
.filter { it.role == "Art" || it.role == "Story & Art" } .filter { it.role == "Art" || it.role == "Story & Art" }
.map { "${it.node.firstName} ${it.node.lastName}".trim() } .mapNotNull { it.node.getFullName() }
.joinToString(separator = ", ") .joinToString(separator = ", ")
.ifEmpty { null }, .ifEmpty { null },
) )
@@ -230,10 +210,10 @@ class MyAnimeListApi(
} }
} }
private suspend fun getListPage(offset: Int): MALUserSearchResult { private suspend fun getListPage(offset: Int): MALSearchResult {
return withIOContext { return withIOContext {
val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon() val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon()
.appendQueryParameter("fields", "list_status{start_date,finish_date}") .appendQueryParameter("fields", SEARCH_FIELDS)
.appendQueryParameter("limit", LIST_PAGINATION_AMOUNT.toString()) .appendQueryParameter("limit", LIST_PAGINATION_AMOUNT.toString())
if (offset > 0) { if (offset > 0) {
urlBuilder.appendQueryParameter("offset", offset.toString()) urlBuilder.appendQueryParameter("offset", offset.toString())
@@ -262,6 +242,28 @@ class MyAnimeListApi(
} }
} }
private fun parseSearchItem(searchItem: MALManga): TrackSearch {
return TrackSearch.create(trackId).apply {
remote_id = searchItem.id
title = searchItem.title
summary = searchItem.synopsis
total_chapters = searchItem.numChapters
score = searchItem.mean
cover_url = searchItem.covers?.large.orEmpty()
tracking_url = "https://myanimelist.net/manga/$remote_id"
publishing_status = searchItem.status.replace("_", " ")
publishing_type = searchItem.mediaType.replace("_", " ")
start_date = searchItem.startDate ?: ""
artists = searchItem.authors
.filter { authorNode -> authorNode.role == "Art" }
.mapNotNull { authorNode -> authorNode.node.getFullName() }
authors = searchItem.authors
// count all with "Story" or "Story & Art" as authors, like is done for library entries
.filter { authorNode -> authorNode.role.contains("Story") }
.mapNotNull { authorNode -> authorNode.node.getFullName() }
}
}
private fun parseDate(isoDate: String): Long { private fun parseDate(isoDate: String): Long {
return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L
} }
@@ -273,7 +275,7 @@ class MyAnimeListApi(
return try { return try {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(epochTime) outputDf.format(epochTime)
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
} }
@@ -284,6 +286,9 @@ class MyAnimeListApi(
private const val BASE_OAUTH_URL = "https://myanimelist.net/v1/oauth2" private const val BASE_OAUTH_URL = "https://myanimelist.net/v1/oauth2"
private const val BASE_API_URL = "https://api.myanimelist.net/v2" private const val BASE_API_URL = "https://api.myanimelist.net/v2"
private const val SEARCH_FIELDS =
"id,title,synopsis,num_chapters,mean,main_picture,status,media_type,start_date,authors{first_name,last_name}"
private const val LIST_PAGINATION_AMOUNT = 250 private const val LIST_PAGINATION_AMOUNT = 250
private var codeVerifier: String = "" private var codeVerifier: String = ""
@@ -18,8 +18,26 @@ data class MALManga(
val mediaType: String, val mediaType: String,
@SerialName("start_date") @SerialName("start_date")
val startDate: String?, val startDate: String?,
val authors: List<MALAuthorNode> = emptyList(),
) )
@Serializable
data class MALAuthorNode(
val node: MALAuthor,
val role: String,
)
@Serializable
data class MALAuthor(
val id: Int,
@SerialName("first_name")
val firstName: String,
@SerialName("last_name")
val lastName: String,
) {
fun getFullName(): String? = "$firstName $lastName".trim().ifBlank { null }
}
@Serializable @Serializable
data class MALMangaCovers( data class MALMangaCovers(
val large: String = "", val large: String = "",
@@ -33,19 +51,5 @@ data class MALMangaMetadata(
val synopsis: String?, val synopsis: String?,
@SerialName("main_picture") @SerialName("main_picture")
val covers: MALMangaCovers, val covers: MALMangaCovers,
val authors: List<MALAuthor>, val authors: List<MALAuthorNode>,
)
@Serializable
data class MALAuthor(
val node: MALAuthorNode,
val role: String,
)
@Serializable
data class MALAuthorNode(
@SerialName("first_name")
val firstName: String,
@SerialName("last_name")
val lastName: String,
) )
@@ -5,14 +5,15 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class MALSearchResult( data class MALSearchResult(
val data: List<MALSearchResultNode>, val data: List<MALSearchResultNode>,
val paging: MALSearchPaging,
) )
@Serializable @Serializable
data class MALSearchResultNode( data class MALSearchResultNode(
val node: MALSearchResultItem, val node: MALManga,
) )
@Serializable @Serializable
data class MALSearchResultItem( data class MALSearchPaging(
val id: Int, val next: String?,
) )
@@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.data.track.myanimelist.dto
import kotlinx.serialization.Serializable
@Serializable
data class MALUserSearchResult(
val data: List<MALUserSearchItem>,
val paging: MALUserSearchPaging,
)
@Serializable
data class MALUserSearchItem(
val node: MALUserSearchItemNode,
)
@Serializable
data class MALUserSearchPaging(
val next: String?,
)
@Serializable
data class MALUserSearchItemNode(
val id: Int,
val title: String,
)
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.shikimori package eu.kanade.tachiyomi.data.track.shikimori
import android.graphics.Color
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
@@ -102,9 +101,7 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
return api.getMangaMetadata(track) return api.getMangaMetadata(track)
} }
override fun getLogo() = R.drawable.ic_tracker_shikimori override fun getLogo() = R.drawable.brand_shikimori
override fun getLogoColor() = Color.rgb(40, 40, 40)
override fun getStatusList(): List<Long> { override fun getStatusList(): List<Long> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING) return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING)
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.suwayomi package eu.kanade.tachiyomi.data.track.suwayomi
import android.graphics.Color
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
@@ -18,14 +17,15 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
val api by lazy { SuwayomiApi(id) } val api by lazy { SuwayomiApi(id) }
override fun getLogo() = R.drawable.ic_tracker_suwayomi override fun getLogo() = R.drawable.brand_suwayomi
override fun getLogoColor() = Color.rgb(255, 35, 35) // TODO
companion object { companion object {
const val UNREAD = 1L const val UNREAD = 1L
const val READING = 2L const val READING = 2L
const val COMPLETED = 3L const val COMPLETED = 3L
private const val TRACKER_DELETE_KEY = "Tracker Delete"
private const val TRACKER_DELETE_DEFAULT = false
} }
override fun getStatusList(): List<Long> = listOf(UNREAD, READING, COMPLETED) override fun getStatusList(): List<Long> = listOf(UNREAD, READING, COMPLETED)
@@ -58,7 +58,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
} }
} }
return api.updateProgress(track) return api.updateProgress(track, getPrefTrackerDelete())
} }
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
@@ -105,4 +105,9 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
private fun String.getMangaId(): Long = private fun String.getMangaId(): Long =
this.substringAfterLast('/').toLong() this.substringAfterLast('/').toLong()
private fun getPrefTrackerDelete(): Boolean {
val preferences = api.sourcePreferences()
return preferences.getBoolean(TRACKER_DELETE_KEY, TRACKER_DELETE_DEFAULT)
}
} }
@@ -1,12 +1,15 @@
package eu.kanade.tachiyomi.data.track.suwayomi package eu.kanade.tachiyomi.data.track.suwayomi
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.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.sourcePreferences
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.addAll import kotlinx.serialization.json.addAll
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
@@ -26,19 +29,22 @@ class SuwayomiApi(private val trackId: Long) {
private val sourceManager: SourceManager by injectLazy() private val sourceManager: SourceManager by injectLazy()
private val source: HttpSource by lazy { (sourceManager.get(sourceId) as HttpSource) } private val source: HttpSource by lazy { (sourceManager.get(sourceId) as HttpSource) }
private val configurableSource: ConfigurableSource by lazy { (sourceManager.get(sourceId) as ConfigurableSource) }
private val client: OkHttpClient by lazy { source.client } private val client: OkHttpClient by lazy { source.client }
private val baseUrl: String by lazy { source.baseUrl.trimEnd('/') } private val baseUrl: String by lazy { source.baseUrl.trimEnd('/') }
private val apiUrl: String by lazy { "$baseUrl/api/graphql" } private val apiUrl: String by lazy { "$baseUrl/api/graphql" }
public fun sourcePreferences(): SharedPreferences = configurableSource.sourcePreferences()
suspend fun getTrackSearch(mangaId: Long): TrackSearch = withIOContext { suspend fun getTrackSearch(mangaId: Long): TrackSearch = withIOContext {
val query = """ val query = $$"""
|query GetManga(${'$'}mangaId: Int!) { |query GetManga($mangaId: Int!) {
| manga(id: ${'$'}mangaId) { | manga(id: $mangaId) {
| ...MangaFragment | ...MangaFragment
| } | }
|} |}
| |
|$MangaFragment |$$MangaFragment
""".trimMargin() """.trimMargin()
val payload = buildJsonObject { val payload = buildJsonObject {
put("query", query) put("query", query)
@@ -76,12 +82,14 @@ class SuwayomiApi(private val trackId: Long) {
} }
} }
suspend fun updateProgress(track: Track): Track { suspend fun updateProgress(track: Track, deleteDownloadsOnServer: Boolean = false): Track {
val mangaId = track.remote_id val mangaId = track.remote_id
val chaptersQuery = """ // TODO: Include a filter on the chapter number here
|query GetMangaUnreadChapters(${'$'}mangaId: Int!) { // Below, we only consider older chapters; since v2.1.1985 filtering works properly in the query
| chapters(condition: {mangaId: ${'$'}mangaId, isRead: false}) { val chaptersQuery = $$"""
|query GetMangaUnreadChapters($mangaId: Int!) {
| chapters(condition: {mangaId: $mangaId, isRead: false}) {
| nodes { | nodes {
| id | id
| chapterNumber | chapterNumber
@@ -107,18 +115,29 @@ class SuwayomiApi(private val trackId: Long) {
.data .data
.entry .entry
.nodes .nodes
.mapNotNull { n -> n.id.takeIf { n.chapterNumber <= track.last_chapter_read } } .mapNotNull { n -> n.id.takeIf { n.chapterNumber <= track.last_chapter_read + 0.001 } }
} }
val markQuery = """ val markQuery = if (deleteDownloadsOnServer) {
|mutation MarkChaptersRead(${'$'}chapters: [Int!]!) { $$"""
| updateChapters(input: {ids: ${'$'}chapters, patch: {isRead: true}}) { |mutation MarkChaptersRead($chapters: [Int!]!) {
| chapters { | updateChapters(input: {ids: $chapters, patch: {isRead: true}}) {
| id | __typename
| } | }
| } | deleteDownloadedChapters(input: {ids: $chapters}) {
|} | __typename
""".trimMargin() | }
|}
""".trimMargin()
} else {
$$"""
|mutation MarkChaptersRead($chapters: [Int!]!) {
| updateChapters(input: {ids: $chapters, patch: {isRead: true}}) {
| __typename
| }
|}
""".trimMargin()
}
val markPayload = buildJsonObject { val markPayload = buildJsonObject {
put("query", markQuery) put("query", markQuery)
putJsonObject("variables") { putJsonObject("variables") {
@@ -137,12 +156,10 @@ class SuwayomiApi(private val trackId: Long) {
.awaitSuccess() .awaitSuccess()
} }
val trackQuery = """ val trackQuery = $$"""
|mutation TrackManga(${'$'}mangaId: Int!) { |mutation TrackManga($mangaId: Int!) {
| trackProgress(input: {mangaId: ${'$'}mangaId}) { | trackProgress(input: {mangaId: $mangaId}) {
| trackRecords { | __typename
| lastChapterRead
| }
| } | }
|} |}
""".trimMargin() """.trimMargin()
@@ -18,6 +18,7 @@ import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.storage.service.StoragePreferences import tachiyomi.domain.storage.service.StoragePreferences
import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.api.InjektRegistrar import uy.kohesive.injekt.api.InjektRegistrar
class PreferenceModule(val app: Application) : InjektModule { class PreferenceModule(val app: Application) : InjektModule {
@@ -44,6 +45,9 @@ class PreferenceModule(val app: Application) : InjektModule {
addSingletonFactory { addSingletonFactory {
LibraryPreferences(get()) LibraryPreferences(get())
} }
addSingletonFactory {
UpdatesPreferences(get())
}
addSingletonFactory { addSingletonFactory {
ReaderPreferences(get()) ReaderPreferences(get())
} }
@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.extension.installer package eu.kanade.tachiyomi.extension.installer
import android.annotation.SuppressLint
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
@@ -82,6 +83,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
inputStream.copyTo(outputStream) inputStream.copyTo(outputStream)
session.fsync(outputStream) session.fsync(outputStream)
} }
service.contentResolver.delete(entry.uri, null, null)
val intentSender = PendingIntent.getBroadcast( val intentSender = PendingIntent.getBroadcast(
service, service,
@@ -89,6 +91,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
Intent(INSTALL_ACTION).setPackage(service.packageName), Intent(INSTALL_ACTION).setPackage(service.packageName),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
).intentSender ).intentSender
@SuppressLint("RequestInstallPackagesPolicy")
session.commit(intentSender) session.commit(intentSender)
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -109,9 +109,10 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
override fun processEntry(entry: Entry) { override fun processEntry(entry: Entry) {
super.processEntry(entry) super.processEntry(entry)
try { try {
shellInterface?.install( service.contentResolver.openAssetFileDescriptor(entry.uri, "r").use {
service.contentResolver.openAssetFileDescriptor(entry.uri, "r"), shellInterface?.install(it)
) }
service.contentResolver.delete(entry.uri, null, null)
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
continueQueue(InstallStep.Error) continueQueue(InstallStep.Error)
@@ -124,7 +125,13 @@ 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) if (Shizuku.pingBinder()) {
try {
Shizuku.unbindUserService(shizukuArgs, connection, true)
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Failed to unbind shizuku service" }
}
}
service.unregisterReceiver(receiver) service.unregisterReceiver(receiver)
logcat { "ShizukuInstaller destroy" } logcat { "ShizukuInstaller destroy" }
scope.cancel() scope.cancel()
@@ -64,6 +64,11 @@ class ExtensionInstallActivity : Activity() {
} }
} }
override fun onDestroy() {
super.onDestroy()
intent.data?.let { contentResolver.delete(it, null, null) }
}
private fun checkInstallationResult(resultCode: Int) { private fun checkInstallationResult(resultCode: Int) {
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID) val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
val extensionManager = Injekt.get<ExtensionManager>() val extensionManager = Injekt.get<ExtensionManager>()
@@ -1,66 +1,48 @@
package eu.kanade.tachiyomi.extension.util package eu.kanade.tachiyomi.extension.util
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Environment
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.installer.Installer import eu.kanade.tachiyomi.extension.installer.Installer
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.isPackageInstalled import eu.kanade.tachiyomi.util.system.isPackageInstalled
import kotlinx.coroutines.delay import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.lang.withUIContext import okhttp3.OkHttpClient
import okhttp3.Request
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File import java.io.File
import kotlin.time.Duration.Companion.seconds
/** /**
* The installer which installs, updates and uninstalls the extensions. * The installer which installs, updates and uninstalls the extensions.
* *
* @param context The application context. * @param context The application context.
*/ */
internal class ExtensionInstaller(private val context: Context) { internal class ExtensionInstaller(
private val context: Context,
/** ) {
* The system's download manager
*/
private val downloadManager = context.getSystemService<DownloadManager>()!!
/**
* The broadcast receiver which listens to download completion events.
*/
private val downloadReceiver = DownloadCompletionReceiver()
/**
* The currently requested downloads, with the package name (unique id) as key, and the id
* returned by the download manager.
*/
private val activeDownloads = hashMapOf<String, Long>()
private val downloadsStateFlows = hashMapOf<Long, MutableStateFlow<InstallStep>>()
private val scope = CoroutineScope(Dispatchers.IO)
private val activeJobs = mutableMapOf<String, Job>()
private val activeSteps = mutableMapOf<Long, MutableStateFlow<InstallStep>>()
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller() private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
private val httpClient: OkHttpClient = Injekt.get<NetworkHelper>().client
/** /**
* Adds the given extension to the downloads queue and returns an observable containing its * Adds the given extension to the downloads queue and returns an observable containing its
* step in the installation process. * step in the installation process.
@@ -69,129 +51,86 @@ internal class ExtensionInstaller(private val context: Context) {
* @param extension The extension to install. * @param extension The extension to install.
*/ */
fun downloadAndInstall(url: String, extension: Extension): Flow<InstallStep> { fun downloadAndInstall(url: String, extension: Extension): Flow<InstallStep> {
val pkgName = extension.pkgName val downloadId = extension.pkgName.hashCode().toLong()
cancelInstall(extension.pkgName)
val oldDownload = activeDownloads[pkgName] val step = MutableStateFlow(InstallStep.Pending)
if (oldDownload != null) { activeSteps[downloadId] = step
deleteDownload(pkgName)
}
// Register the receiver after removing (and unregistering) the previous download val job = scope.launch {
downloadReceiver.register() val tmpFile = File(context.cacheDir, "extension_${extension.pkgName}.apk")
try {
step.value = InstallStep.Downloading
val request = Request.Builder().url(url).build()
val response = httpClient.newCall(request).execute()
val downloadUri = url.toUri() if (!response.isSuccessful) {
val request = DownloadManager.Request(downloadUri) throw Exception("Failed to download extension")
.setTitle(extension.name) }
.setMimeType(APK_MIME) response.body.byteStream().use { input ->
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment) tmpFile.outputStream().use { output ->
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) input.copyTo(output)
}
}
val id = downloadManager.enqueue(request) step.value = InstallStep.Installing
activeDownloads[pkgName] = id installApk(downloadId, tmpFile)
} catch (e: Exception) {
val downloadStateFlow = MutableStateFlow(InstallStep.Pending) if (e is InterruptedException) {
downloadsStateFlows[id] = downloadStateFlow // Canceled
} else {
// Poll download status logcat(LogPriority.ERROR, e)
val pollStatusFlow = downloadStatusFlow(id).mapNotNull { downloadStatus -> step.value = InstallStep.Error
// Map to our model }
when (downloadStatus) {
DownloadManager.STATUS_PENDING -> InstallStep.Pending
DownloadManager.STATUS_RUNNING -> InstallStep.Downloading
else -> null
} }
} }
return merge(downloadStateFlow, pollStatusFlow).transformWhile { activeJobs[extension.pkgName] = job
emit(it)
// Stop when the application is installed or errors return step.asStateFlow()
!it.isCompleted() .onCompletion {
}.onCompletion { activeJobs.remove(extension.pkgName)
// Always notify on main thread activeSteps.remove(downloadId)
withUIContext { job.cancel()
// Always remove the download when unsubscribed
deleteDownload(pkgName)
} }
}
} }
/**
* Returns a flow that polls the given download id for its status every second, as the
* manager doesn't have any notification system. It'll stop once the download finishes.
*
* @param id The id of the download to poll.
*/
private fun downloadStatusFlow(id: Long): Flow<Int> = flow {
val query = DownloadManager.Query().setFilterById(id)
while (true) {
// Get the current download status
val downloadStatus = downloadManager.query(query).use { cursor ->
if (!cursor.moveToFirst()) return@flow
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
}
emit(downloadStatus)
// Stop polling when the download fails or finishes
if (
downloadStatus == DownloadManager.STATUS_SUCCESSFUL ||
downloadStatus == DownloadManager.STATUS_FAILED
) {
return@flow
}
delay(1.seconds)
}
}
// Ignore duplicate results
.distinctUntilChanged()
/** /**
* Starts an intent to install the extension at the given uri. * Starts an intent to install the extension at the given uri.
* *
* @param uri The uri of the extension to install. * @param tempFile The file of the extension to install. Delete after use.
*/ */
fun installApk(downloadId: Long, uri: Uri) { private fun installApk(downloadId: Long, tempFile: File) {
when (val installer = extensionInstaller.get()) { when (val installer = extensionInstaller.get()) {
BasePreferences.ExtensionInstaller.LEGACY -> { BasePreferences.ExtensionInstaller.LEGACY -> {
val intent = Intent(context, ExtensionInstallActivity::class.java) val intent = Intent(context, ExtensionInstallActivity::class.java)
.setDataAndType(uri, APK_MIME) .setDataAndType(tempFile.getUriCompat(context), APK_MIME)
.putExtra(EXTRA_DOWNLOAD_ID, downloadId) .putExtra(EXTRA_DOWNLOAD_ID, downloadId)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(intent) context.startActivity(intent)
} }
BasePreferences.ExtensionInstaller.PRIVATE -> { BasePreferences.ExtensionInstaller.PRIVATE -> {
val extensionManager = Injekt.get<ExtensionManager>()
val tempFile = File(context.cacheDir, "temp_$downloadId")
if (tempFile.exists() && !tempFile.delete()) {
// Unlikely but just in case
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
return
}
try { try {
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) { if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
extensionManager.updateInstallStep(downloadId, InstallStep.Installed) updateInstallStep(downloadId, InstallStep.Installed)
} else { } else {
extensionManager.updateInstallStep(downloadId, InstallStep.Error) updateInstallStep(downloadId, InstallStep.Error)
} }
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." } logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
extensionManager.updateInstallStep(downloadId, InstallStep.Error) updateInstallStep(downloadId, InstallStep.Error)
} }
tempFile.delete() tempFile.delete()
} }
else -> { else -> {
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer) val intent = ExtensionInstallService.getIntent(
context,
downloadId,
tempFile.getUriCompat(context),
installer,
)
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
} }
@@ -201,9 +140,8 @@ internal class ExtensionInstaller(private val context: Context) {
* Cancels extension install and remove from download manager and installer. * Cancels extension install and remove from download manager and installer.
*/ */
fun cancelInstall(pkgName: String) { fun cancelInstall(pkgName: String) {
val downloadId = activeDownloads.remove(pkgName) ?: return activeJobs.remove(pkgName)?.cancel()
downloadManager.remove(downloadId) Installer.cancelInstallQueue(context, pkgName.hashCode().toLong())
Installer.cancelInstallQueue(context, downloadId)
} }
/** /**
@@ -230,91 +168,11 @@ internal class ExtensionInstaller(private val context: Context) {
* @param step New install step. * @param step New install step.
*/ */
fun updateInstallStep(downloadId: Long, step: InstallStep) { fun updateInstallStep(downloadId: Long, step: InstallStep) {
downloadsStateFlows[downloadId]?.let { it.value = step } activeSteps[downloadId]?.let { it.value = step }
}
/**
* Deletes the download for the given package name.
*
* @param pkgName The package name of the download to delete.
*/
private fun deleteDownload(pkgName: String) {
val downloadId = activeDownloads.remove(pkgName)
if (downloadId != null) {
downloadManager.remove(downloadId)
downloadsStateFlows.remove(downloadId)
}
if (activeDownloads.isEmpty()) {
downloadReceiver.unregister()
}
}
/**
* Receiver that listens to download status events.
*/
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
/**
* Whether this receiver is currently registered.
*/
private var isRegistered = false
/**
* Registers this receiver if it's not already.
*/
fun register() {
if (isRegistered) return
isRegistered = true
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED)
}
/**
* Unregisters this receiver if it's not already.
*/
fun unregister() {
if (!isRegistered) return
isRegistered = false
context.unregisterReceiver(this)
}
/**
* Called when a download event is received. It looks for the download in the current active
* downloads and notifies its installation step.
*/
override fun onReceive(context: Context, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
// Avoid events for downloads we didn't request
if (id !in activeDownloads.values) return
val uri = downloadManager.getUriForDownloadedFile(id)
// Set next installation step
if (uri == null) {
logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" }
updateInstallStep(id, InstallStep.Error)
return
}
val query = DownloadManager.Query().setFilterById(id)
downloadManager.query(query).use { cursor ->
if (cursor.moveToFirst()) {
val localUri = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI),
).removePrefix(FILE_SCHEME)
installApk(id, File(localUri).getUriCompat(context))
}
}
}
} }
companion object { companion object {
const val APK_MIME = "application/vnd.android.package-archive" const val APK_MIME = "application/vnd.android.package-archive"
const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID" const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID"
const val FILE_SCHEME = "file://"
} }
} }
@@ -90,6 +90,8 @@ class MangaDex(delegate: HttpSource, val context: Context) :
private fun coverQuality() = sourcePreferences.getString(getCoverQualityPrefKey(mdLang.lang), "").orEmpty() private fun coverQuality() = sourcePreferences.getString(getCoverQualityPrefKey(mdLang.lang), "").orEmpty()
private fun tryUsingFirstVolumeCover() = sourcePreferences.getBoolean(getTryUsingFirstVolumeCoverKey(mdLang.lang), false) private fun tryUsingFirstVolumeCover() = sourcePreferences.getBoolean(getTryUsingFirstVolumeCoverKey(mdLang.lang), false)
private fun altTitlesInDesc() = sourcePreferences.getBoolean(getAltTitlesInDescKey(mdLang.lang), false) private fun altTitlesInDesc() = sourcePreferences.getBoolean(getAltTitlesInDescKey(mdLang.lang), false)
private fun finalChapterInDesc() = sourcePreferences.getBoolean(getFinalChapterInDescPrefKey(mdLang.lang), false)
private fun preferExtensionLangTitle() = sourcePreferences.getBoolean(getPreferExtensionLangTitlePrefKey(mdLang.extLang), true)
private val mangadexService by lazy { private val mangadexService by lazy {
MangaDexService(client) MangaDexService(client)
@@ -107,7 +109,7 @@ class MangaDex(delegate: HttpSource, val context: Context) :
FollowsHandler(mdLang.lang, mangadexAuthService) FollowsHandler(mdLang.lang, mangadexAuthService)
} }
private val mangaHandler by lazy { private val mangaHandler by lazy {
MangaHandler(mdLang.lang, mangadexService, apiMangaParser, followsHandler) MangaHandler(mdLang.lang, mangadexService, apiMangaParser)
} }
private val similarHandler by lazy { private val similarHandler by lazy {
SimilarHandler(mdLang.lang, mangadexService, similarService) SimilarHandler(mdLang.lang, mangadexService, similarService)
@@ -192,11 +194,27 @@ class MangaDex(delegate: HttpSource, val context: Context) :
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getMangaDetails")) @Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getMangaDetails"))
override fun fetchMangaDetails(manga: SManga): Observable<SManga> { override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return mangaHandler.fetchMangaDetailsObservable(manga, id, coverQuality(), tryUsingFirstVolumeCover(), altTitlesInDesc()) return mangaHandler.fetchMangaDetailsObservable(
manga,
id,
coverQuality(),
tryUsingFirstVolumeCover(),
altTitlesInDesc(),
finalChapterInDesc(),
preferExtensionLangTitle(),
)
} }
override suspend fun getMangaDetails(manga: SManga): SManga { override suspend fun getMangaDetails(manga: SManga): SManga {
return mangaHandler.getMangaDetails(manga, id, coverQuality(), tryUsingFirstVolumeCover(), altTitlesInDesc()) return mangaHandler.getMangaDetails(
manga,
id,
coverQuality(),
tryUsingFirstVolumeCover(),
altTitlesInDesc(),
finalChapterInDesc(),
preferExtensionLangTitle(),
)
} }
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getChapterList")) @Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getChapterList"))
@@ -241,8 +259,21 @@ class MangaDex(delegate: HttpSource, val context: Context) :
override fun newMetaInstance() = MangaDexSearchMetadata() override fun newMetaInstance() = MangaDexSearchMetadata()
override suspend fun parseIntoMetadata(metadata: MangaDexSearchMetadata, input: Triple<MangaDto, List<String>, StatisticsMangaDto>) { override suspend fun parseIntoMetadata(
apiMangaParser.parseIntoMetadata(metadata, input.first, input.second, input.third, null, coverQuality(), altTitlesInDesc()) metadata: MangaDexSearchMetadata,
input: Triple<MangaDto, List<String>, StatisticsMangaDto>,
) {
apiMangaParser.parseIntoMetadata(
metadata,
input.first,
input.second,
input.third,
null,
coverQuality(),
altTitlesInDesc(),
finalChapterInDesc(),
preferExtensionLangTitle(),
)
} }
// LoginSource methods // LoginSource methods
@@ -296,10 +327,6 @@ class MangaDex(delegate: HttpSource, val context: Context) :
return followsHandler.updateRating(track) return followsHandler.updateRating(track)
} }
suspend fun getTrackingAndMangaInfo(track: Track): Pair<Track, MangaDexSearchMetadata?> {
return mangaHandler.getTrackingInfo(track)
}
// RandomMangaSource method // RandomMangaSource method
override suspend fun fetchRandomMangaUrl(): String { override suspend fun fetchRandomMangaUrl(): String {
return mangaHandler.fetchRandomMangaId() return mangaHandler.fetchRandomMangaId()
@@ -313,51 +340,62 @@ class MangaDex(delegate: HttpSource, val context: Context) :
return similarHandler.getRelated(manga) return similarHandler.getRelated(manga)
} }
suspend fun getMangaMetadata(track: Track): SManga? { suspend fun getMangaMetadata(track: Track): SManga {
return mangaHandler.getMangaMetadata(track, id, coverQuality(), tryUsingFirstVolumeCover(), altTitlesInDesc()) return mangaHandler.getMangaMetadata(
track,
id,
coverQuality(),
tryUsingFirstVolumeCover(),
altTitlesInDesc(),
finalChapterInDesc(),
preferExtensionLangTitle(),
)
} }
companion object { companion object {
private const val dataSaverPref = "dataSaverV5" private const val dataSaverPref = "dataSaverV5"
fun getDataSaverPreferenceKey(dexLang: String): String { fun getDataSaverPreferenceKey(dexLang: String): String {
return "${dataSaverPref}_$dexLang" return "${dataSaverPref}_$dexLang"
} }
private const val standardHttpsPortPref = "usePort443" private const val standardHttpsPortPref = "usePort443"
fun getStandardHttpsPreferenceKey(dexLang: String): String { fun getStandardHttpsPreferenceKey(dexLang: String): String {
return "${standardHttpsPortPref}_$dexLang" return "${standardHttpsPortPref}_$dexLang"
} }
private const val blockedGroupsPref = "blockedGroups" private const val blockedGroupsPref = "blockedGroups"
fun getBlockedGroupsPrefKey(dexLang: String): String { fun getBlockedGroupsPrefKey(dexLang: String): String {
return "${blockedGroupsPref}_$dexLang" return "${blockedGroupsPref}_$dexLang"
} }
private const val blockedUploaderPref = "blockedUploader" private const val blockedUploaderPref = "blockedUploader"
fun getBlockedUploaderPrefKey(dexLang: String): String { fun getBlockedUploaderPrefKey(dexLang: String): String {
return "${blockedUploaderPref}_$dexLang" return "${blockedUploaderPref}_$dexLang"
} }
private const val coverQualityPref = "thumbnailQuality" private const val coverQualityPref = "thumbnailQuality"
fun getCoverQualityPrefKey(dexLang: String): String { fun getCoverQualityPrefKey(dexLang: String): String {
return "${coverQualityPref}_$dexLang" return "${coverQualityPref}_$dexLang"
} }
private const val tryUsingFirstVolumeCover = "tryUsingFirstVolumeCover" private const val tryUsingFirstVolumeCoverPref = "tryUsingFirstVolumeCover"
fun getTryUsingFirstVolumeCoverKey(dexLang: String): String { fun getTryUsingFirstVolumeCoverKey(dexLang: String): String {
return "${tryUsingFirstVolumeCover}_$dexLang" return "${tryUsingFirstVolumeCoverPref}_$dexLang"
} }
private const val altTitlesInDesc = "altTitlesInDesc" private const val altTitlesInDescPref = "altTitlesInDesc"
fun getAltTitlesInDescKey(dexLang: String): String { fun getAltTitlesInDescKey(dexLang: String): String {
return "${altTitlesInDesc}_$dexLang" return "${altTitlesInDescPref}_$dexLang"
}
private const val finalChapterInDescPref = "finalChapterInDesc"
fun getFinalChapterInDescPrefKey(dexLang: String): String {
return "${finalChapterInDescPref}_$dexLang"
}
private const val preferExtensionLangTitlePref = "preferExtensionLangTitle"
fun getPreferExtensionLangTitlePrefKey(dexLang: String): String {
return "${preferExtensionLangTitlePref}_$dexLang"
} }
} }
} }
@@ -50,79 +50,46 @@ class ExtensionsScreenModel(
ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle) ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle)
} }
} }
val queryFilter: (String) -> ((Extension) -> Boolean) = { query ->
filter@{ extension ->
if (query.isEmpty()) return@filter true
query.split(",").any { _input ->
val input = _input.trim()
if (input.isEmpty()) return@any false
when (extension) {
is Extension.Available -> {
extension.sources.any {
it.name.contains(input, ignoreCase = true) ||
it.baseUrl.contains(input, ignoreCase = true) ||
it.id == input.toLongOrNull()
} ||
extension.name.contains(input, ignoreCase = true)
}
is Extension.Installed -> {
extension.sources.any {
it.name.contains(input, ignoreCase = true) ||
it.id == input.toLongOrNull() ||
if (it is HttpSource) {
it.baseUrl.contains(input, ignoreCase = true)
} else {
false
}
} ||
extension.name.contains(input, ignoreCase = true)
}
is Extension.Untrusted -> extension.name.contains(input, ignoreCase = true)
}
}
}
}
screenModelScope.launchIO { screenModelScope.launchIO {
combine( combine(
state.map { it.searchQuery }.distinctUntilChanged().debounce(SEARCH_DEBOUNCE_MILLIS), state.map { it.searchQuery }
.distinctUntilChanged()
.debounce(SEARCH_DEBOUNCE_MILLIS)
.map { searchQueryPredicate(it ?: "") },
currentDownloads, currentDownloads,
getExtensions.subscribe(), getExtensions.subscribe(),
) { query, downloads, (_updates, _installed, _available, _untrusted) -> ) { predicate, downloads, (_updates, _installed, _available, _untrusted) ->
val searchQuery = query ?: "" buildMap {
val updates = _updates.filter(predicate).map(extensionMapper(downloads))
val itemsGroups: ItemGroups = mutableMapOf() if (updates.isNotEmpty()) {
put(ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending), updates)
val updates = _updates.filter(queryFilter(searchQuery)).map(extensionMapper(downloads))
if (updates.isNotEmpty()) {
itemsGroups[ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending)] = updates
}
val installed = _installed.filter(queryFilter(searchQuery)).map(extensionMapper(downloads))
val untrusted = _untrusted.filter(queryFilter(searchQuery)).map(extensionMapper(downloads))
if (installed.isNotEmpty() || untrusted.isNotEmpty()) {
itemsGroups[ExtensionUiModel.Header.Resource(MR.strings.ext_installed)] = installed + untrusted
}
val languagesWithExtensions = _available
.filter(queryFilter(searchQuery))
.groupBy { it.lang }
.toSortedMap(LocaleHelper.comparator)
.map { (lang, exts) ->
ExtensionUiModel.Header.Text(LocaleHelper.getSourceDisplayName(lang, context)) to
exts.map(extensionMapper(downloads))
} }
if (languagesWithExtensions.isNotEmpty()) {
itemsGroups.putAll(languagesWithExtensions)
}
itemsGroups val installed = _installed.filter(predicate).map(extensionMapper(downloads))
val untrusted = _untrusted.filter(predicate).map(extensionMapper(downloads))
if (installed.isNotEmpty() || untrusted.isNotEmpty()) {
put(ExtensionUiModel.Header.Resource(MR.strings.ext_installed), installed + untrusted)
}
val languagesWithExtensions = _available
.filter(predicate)
.groupBy { it.lang }
.toSortedMap(LocaleHelper.comparator)
.map { (lang, exts) ->
ExtensionUiModel.Header.Text(LocaleHelper.getSourceDisplayName(lang, context)) to
exts.map(extensionMapper(downloads))
}
if (languagesWithExtensions.isNotEmpty()) {
putAll(languagesWithExtensions)
}
}
} }
.collectLatest { .collectLatest { items ->
mutableState.update { state -> mutableState.update { state ->
state.copy( state.copy(
isLoading = false, isLoading = false,
items = it, items = items,
) )
} }
} }
@@ -139,6 +106,36 @@ class ExtensionsScreenModel(
.launchIn(screenModelScope) .launchIn(screenModelScope)
} }
fun searchQueryPredicate(query: String): (Extension) -> Boolean {
val subqueries = query.split(",")
.map { it.trim() }
.filterNot { it.isBlank() }
if (subqueries.isEmpty()) return { true }
return { extension ->
subqueries.any { subquery ->
if (extension.name.contains(subquery, ignoreCase = true)) return@any true
when (extension) {
is Extension.Installed -> extension.sources.any { source ->
source.name.contains(subquery, ignoreCase = true) ||
(source as? HttpSource)?.baseUrl?.contains(subquery, ignoreCase = true) == true ||
source.id == subquery.toLongOrNull()
}
is Extension.Available -> extension.sources.any {
it.name.contains(subquery, ignoreCase = true) ||
it.baseUrl.contains(subquery, ignoreCase = true) ||
it.id == subquery.toLongOrNull()
}
else -> false
}
}
}
}
fun search(query: String?) { fun search(query: String?) {
mutableState.update { mutableState.update {
it.copy(searchQuery = query) it.copy(searchQuery = query)
@@ -222,7 +219,7 @@ class ExtensionsScreenModel(
} }
} }
typealias ItemGroups = MutableMap<ExtensionUiModel.Header, List<ExtensionUiModel.Item>> typealias ItemGroups = Map<ExtensionUiModel.Header, List<ExtensionUiModel.Item>>
object ExtensionUiModel { object ExtensionUiModel {
sealed interface Header { sealed interface Header {
@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.browse.extension package eu.kanade.tachiyomi.ui.browse.extension
import androidx.activity.compose.BackHandler
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -49,6 +50,10 @@ fun extensionsTab(
), ),
), ),
content = { contentPadding, _ -> content = { contentPadding, _ ->
BackHandler(enabled = state.searchQuery != null) {
extensionsScreenModel.search(null)
}
ExtensionScreen( ExtensionScreen(
state = state, state = state,
contentPadding = contentPadding, contentPadding = contentPadding,
@@ -9,11 +9,14 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel
@@ -29,7 +32,6 @@ import mihon.feature.migration.config.MigrationConfigScreen
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.FastScrollLazyColumn import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen import tachiyomi.presentation.core.screens.EmptyScreen
@@ -75,20 +77,22 @@ data class MigrateMangaScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
if (state.selectionMode) { SmallExtendedFloatingActionButton(
ExtendedFloatingActionButton( text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) },
text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) }, icon = {
icon = { Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null)
Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null) },
}, onClick = {
onClick = { val selection = state.selection
val selection = state.selection screenModel.clearSelection()
screenModel.clearSelection() navigator.push(MigrationConfigScreen(selection))
navigator.push(MigrationConfigScreen(selection)) },
}, expanded = lazyListState.shouldExpandFAB(),
expanded = lazyListState.shouldExpandFAB(), modifier = Modifier.animateFloatingActionButton(
) visible = state.selectionMode,
} alignment = Alignment.BottomEnd,
),
)
}, },
) { contentPadding -> ) { contentPadding ->
if (state.isEmpty) { if (state.isEmpty) {
@@ -1,17 +1,20 @@
package eu.kanade.tachiyomi.ui.browse.migration.search package eu.kanade.tachiyomi.ui.browse.migration.search
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable 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.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel
@@ -35,7 +38,6 @@ import mihon.presentation.core.util.collectAsLazyPagingItems
import tachiyomi.core.common.Constants import tachiyomi.core.common.Constants
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.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
@@ -74,13 +76,15 @@ data class MigrateSourceSearchScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
AnimatedVisibility(visible = state.filters.isNotEmpty()) { SmallExtendedFloatingActionButton(
ExtendedFloatingActionButton( text = { Text(text = stringResource(MR.strings.action_filter)) },
text = { Text(text = stringResource(MR.strings.action_filter)) }, icon = { Icon(Icons.Outlined.FilterList, contentDescription = null) },
icon = { Icon(Icons.Outlined.FilterList, contentDescription = null) }, onClick = screenModel::openFilterSheet,
onClick = screenModel::openFilterSheet, modifier = Modifier.animateFloatingActionButton(
) visible = state.filters.isNotEmpty(),
} alignment = Alignment.BottomEnd,
),
)
}, },
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues -> ) { paddingValues ->
@@ -11,13 +11,13 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip import androidx.compose.material3.InputChip
import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -155,7 +155,7 @@ fun AutoCompleteTextField(
null null
}, },
modifier = Modifier modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryEditable) .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable)
.fillMaxWidth() .fillMaxWidth()
.runOnEnterKeyPressed { submit() }, .runOnEnterKeyPressed { submit() },
singleLine = true, singleLine = true,
@@ -190,7 +190,7 @@ fun AutoCompleteTextField(
if (value.text.length > 2 && filteredValues.isNotEmpty()) { if (value.text.length > 2 && filteredValues.isNotEmpty()) {
ExposedDropdownMenu( ExposedDropdownMenu(
modifier = Modifier modifier = Modifier
.exposedDropdownSize(matchTextFieldWidth = true), .exposedDropdownSize(matchAnchorWidth = true),
expanded = expanded, expanded = expanded,
onDismissRequest = { expanded = false }, onDismissRequest = { expanded = false },
) { ) {
@@ -1,9 +1,6 @@
package eu.kanade.tachiyomi.ui.download package eu.kanade.tachiyomi.ui.download
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -16,8 +13,10 @@ import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -56,7 +55,6 @@ import kotlinx.collections.immutable.persistentListOf
import tachiyomi.core.common.util.lang.launchUI import tachiyomi.core.common.util.lang.launchUI
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.Pill import tachiyomi.presentation.core.components.Pill
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen import tachiyomi.presentation.core.screens.EmptyScreen
@@ -201,39 +199,37 @@ object DownloadQueueScreen : Screen() {
) )
}, },
floatingActionButton = { floatingActionButton = {
AnimatedVisibility( val isRunning by screenModel.isDownloaderRunning.collectAsState()
visible = downloadList.isNotEmpty(), SmallExtendedFloatingActionButton(
enter = fadeIn(), text = {
exit = fadeOut(), val id = if (isRunning) {
) { MR.strings.action_pause
val isRunning by screenModel.isDownloaderRunning.collectAsState() } else {
ExtendedFloatingActionButton( MR.strings.action_resume
text = { }
val id = if (isRunning) { Text(text = stringResource(id))
MR.strings.action_pause },
} else { icon = {
MR.strings.action_resume val icon = if (isRunning) {
} Icons.Outlined.Pause
Text(text = stringResource(id)) } else {
}, Icons.Filled.PlayArrow
icon = { }
val icon = if (isRunning) { Icon(imageVector = icon, contentDescription = null)
Icons.Outlined.Pause },
} else { onClick = {
Icons.Filled.PlayArrow if (isRunning) {
} screenModel.pauseDownloads()
Icon(imageVector = icon, contentDescription = null) } else {
}, screenModel.startDownloads()
onClick = { }
if (isRunning) { },
screenModel.pauseDownloads() expanded = fabExpanded,
} else { modifier = Modifier.animateFloatingActionButton(
screenModel.startDownloads() visible = downloadList.isNotEmpty(),
} alignment = Alignment.BottomEnd,
}, ),
expanded = fabExpanded, )
)
}
}, },
) { contentPadding -> ) { contentPadding ->
if (downloadList.isEmpty()) { if (downloadList.isEmpty()) {
@@ -84,6 +84,7 @@ import tachiyomi.core.common.util.lang.launchNonCancellable
import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
import tachiyomi.domain.chapter.interactor.GetBookmarkedChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
@@ -122,6 +123,7 @@ class LibraryScreenModel(
private val getTracksPerManga: GetTracksPerManga = Injekt.get(), private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
private val getNextChapters: GetNextChapters = Injekt.get(), private val getNextChapters: GetNextChapters = Injekt.get(),
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(), private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
private val getBookmarkedChaptersByMangaId: GetBookmarkedChaptersByMangaId = Injekt.get(),
private val setReadStatus: SetReadStatus = Injekt.get(), private val setReadStatus: SetReadStatus = Injekt.get(),
private val updateManga: UpdateManga = Injekt.get(), private val updateManga: UpdateManga = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(), private val setMangaCategories: SetMangaCategories = Injekt.get(),
@@ -404,9 +406,7 @@ class LibraryScreenModel(
val filterFnTracking: (LibraryItem) -> Boolean = tracking@{ item -> val filterFnTracking: (LibraryItem) -> Boolean = tracking@{ item ->
if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true
val mangaTracks = trackMap val mangaTracks = trackMap[item.id].orEmpty().map { it.trackerId }
.mapValues { entry -> entry.value.map { it.trackerId } }[item.id]
.orEmpty()
val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks } val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks }
val isIncluded = includedTracks.isEmpty() || mangaTracks.fastAny { it in includedTracks } val isIncluded = includedTracks.isEmpty() || mangaTracks.fastAny { it in includedTracks }
@@ -736,15 +736,19 @@ class LibraryScreenModel(
* Queues the amount specified of unread chapters from the list of selected manga * Queues the amount specified of unread chapters from the list of selected manga
*/ */
fun performDownloadAction(action: DownloadAction) { fun performDownloadAction(action: DownloadAction) {
val mangas = state.value.selectedManga when (action) {
val amount = when (action) { DownloadAction.NEXT_1_CHAPTER -> downloadNextChapters(1)
DownloadAction.NEXT_1_CHAPTER -> 1 DownloadAction.NEXT_5_CHAPTERS -> downloadNextChapters(5)
DownloadAction.NEXT_5_CHAPTERS -> 5 DownloadAction.NEXT_10_CHAPTERS -> downloadNextChapters(10)
DownloadAction.NEXT_10_CHAPTERS -> 10 DownloadAction.NEXT_25_CHAPTERS -> downloadNextChapters(25)
DownloadAction.NEXT_25_CHAPTERS -> 25 DownloadAction.UNREAD_CHAPTERS -> downloadNextChapters(null)
DownloadAction.UNREAD_CHAPTERS -> null DownloadAction.BOOKMARKED_CHAPTERS -> downloadBookmarkedChapters()
} }
clearSelection() clearSelection()
}
private fun downloadNextChapters(amount: Int?) {
val mangas = state.value.selectedManga
screenModelScope.launchNonCancellable { screenModelScope.launchNonCancellable {
mangas.forEach { manga -> mangas.forEach { manga ->
// SY --> // SY -->
@@ -794,6 +798,54 @@ class LibraryScreenModel(
} }
} }
private fun downloadBookmarkedChapters() {
val mangas = state.value.selectedManga
screenModelScope.launchNonCancellable {
mangas.forEach { manga ->
// SY -->
if (manga.source == MERGED_SOURCE_ID) {
val mergedMangas = getMergedMangaById.await(manga.id)
.associateBy { it.id }
getBookmarkedChaptersByMangaId.await(manga.id)
.groupBy { it.mangaId }
.forEach ab@{ (mangaId, chapters) ->
val mergedManga = mergedMangas[mangaId] ?: return@ab
val downloadChapters = chapters.fastFilterNot { chapter ->
downloadManager.queueState.value.fastAny { chapter.id == it.chapter.id } ||
downloadManager.isChapterDownloaded(
chapter.name,
chapter.scanlator,
chapter.url,
mergedManga.ogTitle,
mergedManga.source,
)
}
downloadManager.downloadChapters(mergedManga, downloadChapters)
}
return@forEach
}
// SY <--
val chapters = getBookmarkedChaptersByMangaId.await(manga.id)
.fastFilterNot { chapter ->
downloadManager.getQueuedDownloadOrNull(chapter.id) != null ||
downloadManager.isChapterDownloaded(
chapter.name,
chapter.scanlator,
chapter.url,
// SY -->
manga.ogTitle,
// SY <--
manga.source,
)
}
downloadManager.downloadChapters(manga, chapters)
}
}
}
// SY --> // SY -->
fun cleanTitles() { fun cleanTitles() {
state.value.selectedManga.fastFilter { state.value.selectedManga.fastFilter {
@@ -177,7 +177,7 @@ class MainActivity : BaseActivity() {
} }
// SY --> // SY -->
@Suppress("KotlinConstantConditions") @Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
val hasDebugOverlay = (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "releaseTest") val hasDebugOverlay = (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "releaseTest")
// SY <-- // SY <--
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.manga package eu.kanade.tachiyomi.ui.manga
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
@@ -208,7 +207,9 @@ class MangaScreen(
previewsRowCount = successState.previewsRowCount, previewsRowCount = successState.previewsRowCount,
onMigrateClicked = { onMigrateClicked = {
navigator.push(MigrationConfigScreen(successState.manga.id)) navigator.push(MigrationConfigScreen(successState.manga.id))
}.takeIf { successState.manga.favorite }, }.takeIf {
successState.manga.favorite /* SY --> */ && successState.manga.source != MERGED_SOURCE_ID /* SY <-- */
},
onEditNotesClicked = { navigator.push(MangaNotesScreen(manga = successState.manga)) }, onEditNotesClicked = { navigator.push(MangaNotesScreen(manga = successState.manga)) },
// SY --> // SY -->
onMetadataViewerClicked = { openMetadataViewer(navigator, successState.manga) }, onMetadataViewerClicked = { openMetadataViewer(navigator, successState.manga) },
@@ -403,12 +404,7 @@ class MangaScreen(
try { try {
getMangaUrl(manga_, source_)?.let { url -> getMangaUrl(manga_, source_)?.let { url ->
val intent = url.toUri().toShareIntent(context, type = "text/plain") val intent = url.toUri().toShareIntent(context, type = "text/plain")
context.startActivity( context.startActivity(intent)
Intent.createChooser(
intent,
context.stringResource(MR.strings.action_share),
),
)
} }
} catch (e: Exception) { } catch (e: Exception) {
context.toast(e.message) context.toast(e.message)
@@ -1175,6 +1175,13 @@ class MangaScreenModel(
return if (manga.sortDescending()) chaptersSorted.reversed() else chaptersSorted return if (manga.sortDescending()) chaptersSorted.reversed() else chaptersSorted
} }
private fun getBookmarkedChapters(): List<Chapter> {
val chapterItems = if (skipFiltered) filteredChapters.orEmpty() else allChapters.orEmpty()
return chapterItems
.filter { (chapter, dlStatus) -> chapter.bookmark && dlStatus == Download.State.NOT_DOWNLOADED }
.map { it.chapter }
}
private fun startDownload( private fun startDownload(
chapters: List<Chapter>, chapters: List<Chapter>,
startNow: Boolean, startNow: Boolean,
@@ -1237,6 +1244,7 @@ class MangaScreenModel(
DownloadAction.NEXT_10_CHAPTERS -> getUnreadChaptersSorted().take(10) DownloadAction.NEXT_10_CHAPTERS -> getUnreadChaptersSorted().take(10)
DownloadAction.NEXT_25_CHAPTERS -> getUnreadChaptersSorted().take(25) DownloadAction.NEXT_25_CHAPTERS -> getUnreadChaptersSorted().take(25)
DownloadAction.UNREAD_CHAPTERS -> getUnreadChapters() DownloadAction.UNREAD_CHAPTERS -> getUnreadChapters()
DownloadAction.BOOKMARKED_CHAPTERS -> getBookmarkedChapters()
} }
if (chaptersToDownload.isNotEmpty()) { if (chaptersToDownload.isNotEmpty()) {
startDownload(chaptersToDownload, false) startDownload(chaptersToDownload, false)
@@ -1487,7 +1495,6 @@ class MangaScreenModel(
fun toggleSelection( fun toggleSelection(
item: ChapterList.Item, item: ChapterList.Item,
selected: Boolean, selected: Boolean,
userSelected: Boolean = false,
fromLongPress: Boolean = false, fromLongPress: Boolean = false,
) { ) {
updateSuccessState { successState -> updateSuccessState { successState ->
@@ -1502,7 +1509,7 @@ class MangaScreenModel(
set(selectedIndex, selectedItem.copy(selected = selected)) set(selectedIndex, selectedItem.copy(selected = selected))
selectedChapterIds.addOrRemove(item.id, selected) selectedChapterIds.addOrRemove(item.id, selected)
if (selected && userSelected && fromLongPress) { if (selected && fromLongPress) {
if (firstSelection) { if (firstSelection) {
selectedPositions[0] = selectedIndex selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex selectedPositions[1] = selectedIndex
@@ -1528,7 +1535,7 @@ class MangaScreenModel(
} }
} }
} }
} else if (userSelected && !fromLongPress) { } else if (!fromLongPress) {
if (!selected) { if (!selected) {
if (selectedIndex == selectedPositions[0]) { if (selectedIndex == selectedPositions[0]) {
selectedPositions[0] = indexOfFirst { it.selected } selectedPositions[0] = indexOfFirst { it.selected }
@@ -467,7 +467,9 @@ class ReaderActivity : BaseActivity() {
} }
override fun onPause() { override fun onPause() {
viewModel.flushReadTimer() lifecycleScope.launchNonCancellable {
viewModel.updateHistory()
}
super.onPause() super.onPause()
} }
@@ -932,7 +934,7 @@ class ReaderActivity : BaseActivity() {
private fun shareChapter() { private fun shareChapter() {
assistUrl?.let { assistUrl?.let {
val intent = it.toUri().toShareIntent(this, type = "text/plain") val intent = it.toUri().toShareIntent(this, type = "text/plain")
startActivity(Intent.createChooser(intent, stringResource(MR.strings.action_share))) startActivity(intent)
} }
} }
@@ -1137,7 +1139,7 @@ class ReaderActivity : BaseActivity() {
context = applicationContext, context = applicationContext,
message = /* SY --> */ text, // SY <-- message = /* SY --> */ text, // SY <--
) )
startActivity(Intent.createChooser(intent, stringResource(MR.strings.action_share))) startActivity(intent)
} }
private fun onCopyImageResult(uri: Uri) { private fun onCopyImageResult(uri: Uri) {
@@ -487,7 +487,7 @@ class ReaderViewModel @JvmOverloads constructor(
viewModelScope.launchIO { viewModelScope.launchIO {
logcat { "Loading ${chapter.chapter.url}" } logcat { "Loading ${chapter.chapter.url}" }
flushReadTimer() updateHistory()
restartReadTimer() restartReadTimer()
try { try {
@@ -655,7 +655,7 @@ class ReaderViewModel @JvmOverloads constructor(
* if setting is enabled and [currentChapter] is queued for download * if setting is enabled and [currentChapter] is queued for download
*/ */
private fun cancelQueuedDownloads(currentChapter: ReaderChapter): Download? { private fun cancelQueuedDownloads(currentChapter: ReaderChapter): Download? {
return downloadManager.getQueuedDownloadOrNull(currentChapter.chapter.id!!.toLong())?.also { return downloadManager.getQueuedDownloadOrNull(currentChapter.chapter.id!!)?.also {
downloadManager.cancelQueuedDownloads(listOf(it)) downloadManager.cancelQueuedDownloads(listOf(it))
} }
} }
@@ -767,40 +767,37 @@ class ReaderViewModel @JvmOverloads constructor(
chapter.chapterNumber.toFloat() == readerChapter.chapter.chapter_number chapter.chapterNumber.toFloat() == readerChapter.chapter.chapter_number
) { ) {
ChapterUpdate(id = chapter.id, read = true) ChapterUpdate(id = chapter.id, read = true)
// SY -->
.also { deleteChapterIfNeeded(ReaderChapter(chapter)) }
// SY <--
} else { } else {
null null
} }
} }
updateChapter.awaitAll(duplicateUnreadChapters) updateChapter.awaitAll(duplicateUnreadChapters)
// SY -->
duplicateUnreadChapters.forEach { chapterUpdate ->
val chapter = unfilteredChapterList.first { it.id == chapterUpdate.id }
deleteChapterIfNeeded(ReaderChapter(chapter))
}
// SY <--
} }
fun restartReadTimer() { fun restartReadTimer() {
chapterReadStartTime = Instant.now().toEpochMilli() chapterReadStartTime = Instant.now().toEpochMilli()
} }
fun flushReadTimer() {
getCurrentChapter()?.let {
viewModelScope.launchNonCancellable {
updateHistory(it)
}
}
}
/** /**
* Saves the chapter last read history if incognito mode isn't on. * Saves the chapter last read history if incognito mode isn't on.
*/ */
private suspend fun updateHistory(readerChapter: ReaderChapter) { suspend fun updateHistory() {
if (incognitoMode) return getCurrentChapter()?.let { readerChapter ->
if (incognitoMode) return@let
val chapterId = readerChapter.chapter.id!! val chapterId = readerChapter.chapter.id!!
val endTime = Date() val endTime = Date()
val sessionReadDuration = chapterReadStartTime?.let { endTime.time - it } ?: 0 val sessionReadDuration = chapterReadStartTime?.let { endTime.time - it } ?: 0
upsertHistory.await(HistoryUpdate(chapterId, endTime, sessionReadDuration)) upsertHistory.await(HistoryUpdate(chapterId, endTime, sessionReadDuration))
chapterReadStartTime = null chapterReadStartTime = null
}
} }
/** /**
@@ -851,7 +848,7 @@ class ReaderViewModel @JvmOverloads constructor(
viewModelScope.launchNonCancellable { viewModelScope.launchNonCancellable {
updateChapter.await( updateChapter.await(
ChapterUpdate( ChapterUpdate(
id = chapter.id!!.toLong(), id = chapter.id!!,
bookmark = bookmarked, bookmark = bookmarked,
), ),
) )
@@ -69,15 +69,8 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0 val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0 val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
// Add previous chapter pages and transition. // Add previous chapter pages and transition
if (chapters.prevChapter != null) { chapters.prevChapter?.pages?.let(newItems::addAll)
// We only need to add the last few pages of the previous chapter, because it'll be
// selected as the current chapter when one of those pages is selected.
val prevPages = chapters.prevChapter.pages
if (prevPages != null) {
newItems.addAll(prevPages.takeLast(2))
}
}
// Skip transition page if the chapter is loaded & current page is not a transition page // Skip transition page if the chapter is loaded & current page is not a transition page
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) { if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
@@ -119,14 +112,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
} }
} }
if (chapters.nextChapter != null) { chapters.nextChapter?.pages?.let(newItems::addAll)
// Add at most two pages, because this chapter will be selected before the user can
// swap more pages.
val nextPages = chapters.nextChapter.pages
if (nextPages != null) {
newItems.addAll(nextPages.take(2))
}
}
// Resets double-page splits, else insert pages get misplaced // Resets double-page splits, else insert pages get misplaced
subItems.filterIsInstance<InsertPage>().also { subItems.removeAll(it) } subItems.filterIsInstance<InsertPage>().also { subItems.removeAll(it) }
@@ -146,7 +132,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
// Will skip insert page otherwise // Will skip insert page otherwise
if (insertPageLastPage != null) { if (insertPageLastPage != null) {
viewer.moveToPage(insertPageLastPage!!) viewer.moveToPage(insertPageLastPage)
} }
} }
@@ -43,14 +43,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0 val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
// Add previous chapter pages and transition. // Add previous chapter pages and transition.
if (chapters.prevChapter != null) { chapters.prevChapter?.pages?.let(newItems::addAll)
// We only need to add the last few pages of the previous chapter, because it'll be
// selected as the current chapter when one of those pages is selected.
val prevPages = chapters.prevChapter.pages
if (prevPages != null) {
newItems.addAll(prevPages.takeLast(2))
}
}
// Skip transition page if the chapter is loaded & current page is not a transition page // Skip transition page if the chapter is loaded & current page is not a transition page
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) { if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
@@ -70,14 +63,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter)) newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
} }
if (chapters.nextChapter != null) { chapters.nextChapter?.pages?.let(newItems::addAll)
// Add at most two pages, because this chapter will be selected before the user can
// swap more pages.
val nextPages = chapters.nextChapter.pages
if (nextPages != null) {
newItems.addAll(nextPages.take(2))
}
}
updateItems(newItems) updateItems(newItems)
} }
@@ -14,7 +14,7 @@ abstract class BaseOAuthLoginActivity : BaseActivity() {
internal val trackerManager: TrackerManager by injectLazy() internal val trackerManager: TrackerManager by injectLazy()
abstract fun handleResult(data: Uri?) abstract fun handleResult(uri: Uri)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -23,7 +23,12 @@ abstract class BaseOAuthLoginActivity : BaseActivity() {
LoadingScreen() LoadingScreen()
} }
handleResult(intent.data) val data = intent.data
if (data == null) {
returnToSettings()
} else {
handleResult(data)
}
} }
internal fun returnToSettings() { internal fun returnToSettings() {
@@ -12,9 +12,9 @@ import uy.kohesive.injekt.api.get
class GoogleDriveLoginActivity : BaseOAuthLoginActivity() { class GoogleDriveLoginActivity : BaseOAuthLoginActivity() {
private val googleDriveService = Injekt.get<GoogleDriveService>() private val googleDriveService = Injekt.get<GoogleDriveService>()
override fun handleResult(data: Uri?) { override fun handleResult(uri: Uri) {
val code = data?.getQueryParameter("code") val code = uri.getQueryParameter("code")
val error = data?.getQueryParameter("error") val error = uri.getQueryParameter("error")
if (code != null) { if (code != null) {
lifecycleScope.launchIO { lifecycleScope.launchIO {
googleDriveService.handleAuthorizationCode( googleDriveService.handleAuthorizationCode(
@@ -2,69 +2,64 @@ package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri import android.net.Uri
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import tachiyomi.core.common.util.lang.launchIO import kotlinx.coroutines.launch
class TrackLoginActivity : BaseOAuthLoginActivity() { class TrackLoginActivity : BaseOAuthLoginActivity() {
override fun handleResult(data: Uri?) { override fun handleResult(uri: Uri) {
when (data?.host) { val data = when {
"anilist-auth" -> handleAnilist(data) !uri.encodedQuery.isNullOrBlank() -> uri.encodedQuery
"bangumi-auth" -> handleBangumi(data) !uri.encodedFragment.isNullOrBlank() -> uri.encodedFragment
"myanimelist-auth" -> handleMyAnimeList(data) else -> null
"shikimori-auth" -> handleShikimori(data) }
?.split("&")
?.filter { it.isNotBlank() }
?.associate {
val parts = it.split("=", limit = 2).map<String, String>(Uri::decode)
parts[0] to parts.getOrNull(1)
}
.orEmpty()
lifecycleScope.launch {
when (uri.host) {
"anilist-auth" -> handleAniList(data["access_token"])
"bangumi-auth" -> handleBangumi(data["code"])
"myanimelist-auth" -> handleMyAnimeList(data["code"])
"shikimori-auth" -> handleShikimori(data["code"])
}
returnToSettings()
} }
} }
private fun handleAnilist(data: Uri) { private suspend fun handleAniList(accessToken: String?) {
val regex = "(?:access_token=)(.*?)(?:&)".toRegex() if (accessToken != null) {
val matchResult = regex.find(data.fragment.toString()) trackerManager.aniList.login(accessToken)
if (matchResult?.groups?.get(1) != null) {
lifecycleScope.launchIO {
trackerManager.aniList.login(matchResult.groups[1]!!.value)
returnToSettings()
}
} else { } else {
trackerManager.aniList.logout() trackerManager.aniList.logout()
returnToSettings()
} }
} }
private fun handleBangumi(data: Uri) { private suspend fun handleBangumi(code: String?) {
val code = data.getQueryParameter("code")
if (code != null) { if (code != null) {
lifecycleScope.launchIO { trackerManager.bangumi.login(code)
trackerManager.bangumi.login(code)
returnToSettings()
}
} else { } else {
trackerManager.bangumi.logout() trackerManager.bangumi.logout()
returnToSettings()
} }
} }
private fun handleMyAnimeList(data: Uri) { private suspend fun handleMyAnimeList(code: String?) {
val code = data.getQueryParameter("code")
if (code != null) { if (code != null) {
lifecycleScope.launchIO { trackerManager.myAnimeList.login(code)
trackerManager.myAnimeList.login(code)
returnToSettings()
}
} else { } else {
trackerManager.myAnimeList.logout() trackerManager.myAnimeList.logout()
returnToSettings()
} }
} }
private fun handleShikimori(data: Uri) { private suspend fun handleShikimori(code: String?) {
val code = data.getQueryParameter("code")
if (code != null) { if (code != null) {
lifecycleScope.launchIO { trackerManager.shikimori.login(code)
trackerManager.shikimori.login(code)
returnToSettings()
}
} else { } else {
trackerManager.shikimori.logout() trackerManager.shikimori.logout()
returnToSettings()
} }
} }
} }
@@ -4,6 +4,7 @@ import android.app.Application
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.util.fastFilter
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 eu.kanade.core.preference.asState import eu.kanade.core.preference.asState
@@ -30,11 +31,16 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNonCancellable import tachiyomi.core.common.util.lang.launchNonCancellable
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
@@ -43,9 +49,11 @@ import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.ChapterUpdate import tachiyomi.domain.chapter.model.ChapterUpdate
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.model.applyFilter
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.domain.updates.interactor.GetUpdates import tachiyomi.domain.updates.interactor.GetUpdates
import tachiyomi.domain.updates.model.UpdatesWithRelations import tachiyomi.domain.updates.model.UpdatesWithRelations
import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.time.ZonedDateTime import java.time.ZonedDateTime
@@ -60,6 +68,7 @@ class UpdatesScreenModel(
private val getManga: GetManga = Injekt.get(), private val getManga: GetManga = Injekt.get(),
private val getChapter: GetChapter = Injekt.get(), private val getChapter: GetChapter = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(), private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val updatesPreferences: UpdatesPreferences = Injekt.get(),
val snackbarHostState: SnackbarHostState = SnackbarHostState(), val snackbarHostState: SnackbarHostState = SnackbarHostState(),
// SY --> // SY -->
readerPreferences: ReaderPreferences = Injekt.get(), readerPreferences: ReaderPreferences = Injekt.get(),
@@ -85,19 +94,35 @@ class UpdatesScreenModel(
val limit = ZonedDateTime.now().minusMonths(3).toInstant() val limit = ZonedDateTime.now().minusMonths(3).toInstant()
combine( combine(
getUpdates.subscribe(limit).distinctUntilChanged(), // needed for SQL filters (unread, started, bookmarked, etc)
getUpdatesItemPreferenceFlow()
.distinctUntilChanged()
.flatMapLatest {
getUpdates.subscribe(
limit,
unread = it.filterUnread.toBooleanOrNull(),
started = it.filterStarted.toBooleanOrNull(),
bookmarked = it.filterBookmarked.toBooleanOrNull(),
hideExcludedScanlators = it.filterExcludedScanlators,
).distinctUntilChanged()
},
downloadCache.changes, downloadCache.changes,
downloadManager.queueState, downloadManager.queueState,
) { updates, _, _ -> updates } // needed for Kotlin filters (downloaded)
.catch { getUpdatesItemPreferenceFlow().distinctUntilChanged { old, new ->
logcat(LogPriority.ERROR, it) old.filterDownloaded == new.filterDownloaded
_events.send(Event.InternalError) },
} ) { updates, _, _, itemPreferences ->
.collectLatest { updates -> updates
.toUpdateItems()
.applyFilters(itemPreferences)
.toPersistentList()
}
.collectLatest { updateItems ->
mutableState.update { mutableState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
items = updates.toUpdateItems(), items = updateItems,
) )
} }
} }
@@ -108,9 +133,43 @@ class UpdatesScreenModel(
.catch { logcat(LogPriority.ERROR, it) } .catch { logcat(LogPriority.ERROR, it) }
.collect(this@UpdatesScreenModel::updateDownloadState) .collect(this@UpdatesScreenModel::updateDownloadState)
} }
getUpdatesItemPreferenceFlow()
.map { prefs ->
listOf(
prefs.filterUnread,
prefs.filterDownloaded,
prefs.filterStarted,
prefs.filterBookmarked,
)
.any { it != TriState.DISABLED }
}
.distinctUntilChanged()
.onEach {
mutableState.update { state ->
state.copy(hasActiveFilters = it)
}
}
.launchIn(screenModelScope)
} }
private fun List<UpdatesWithRelations>.toUpdateItems(): PersistentList<UpdatesItem> { private fun List<UpdatesItem>.applyFilters(
preferences: ItemPreferences,
): List<UpdatesItem> {
val filterDownloaded = preferences.filterDownloaded
val filterFnDownloaded: (UpdatesItem) -> Boolean = {
applyFilter(filterDownloaded) {
it.downloadStateProvider() == Download.State.DOWNLOADED
}
}
return fastFilter {
filterFnDownloaded(it)
}
}
private fun List<UpdatesWithRelations>.toUpdateItems(): List<UpdatesItem> {
return this return this
.map { update -> .map { update ->
val activeDownload = downloadManager.getQueuedDownloadOrNull(update.chapterId) val activeDownload = downloadManager.getQueuedDownloadOrNull(update.chapterId)
@@ -135,7 +194,6 @@ class UpdatesScreenModel(
selected = update.chapterId in selectedChapterIds, selected = update.chapterId in selectedChapterIds,
) )
} }
.toPersistentList()
} }
fun updateLibrary(): Boolean { fun updateLibrary(): Boolean {
@@ -279,7 +337,6 @@ class UpdatesScreenModel(
fun toggleSelection( fun toggleSelection(
item: UpdatesItem, item: UpdatesItem,
selected: Boolean, selected: Boolean,
userSelected: Boolean = false,
fromLongPress: Boolean = false, fromLongPress: Boolean = false,
) { ) {
mutableState.update { state -> mutableState.update { state ->
@@ -294,7 +351,7 @@ class UpdatesScreenModel(
set(selectedIndex, selectedItem.copy(selected = selected)) set(selectedIndex, selectedItem.copy(selected = selected))
selectedChapterIds.addOrRemove(item.update.chapterId, selected) selectedChapterIds.addOrRemove(item.update.chapterId, selected)
if (selected && userSelected && fromLongPress) { if (selected && fromLongPress) {
if (firstSelection) { if (firstSelection) {
selectedPositions[0] = selectedIndex selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex selectedPositions[1] = selectedIndex
@@ -320,7 +377,7 @@ class UpdatesScreenModel(
} }
} }
} }
} else if (userSelected && !fromLongPress) { } else if (!fromLongPress) {
if (!selected) { if (!selected) {
if (selectedIndex == selectedPositions[0]) { if (selectedIndex == selectedPositions[0]) {
selectedPositions[0] = indexOfFirst { it.selected } selectedPositions[0] = indexOfFirst { it.selected }
@@ -373,9 +430,41 @@ class UpdatesScreenModel(
libraryPreferences.newUpdatesCount().set(0) libraryPreferences.newUpdatesCount().set(0)
} }
private fun getUpdatesItemPreferenceFlow(): Flow<ItemPreferences> {
return combine(
updatesPreferences.filterDownloaded().changes(),
updatesPreferences.filterUnread().changes(),
updatesPreferences.filterStarted().changes(),
updatesPreferences.filterBookmarked().changes(),
updatesPreferences.filterExcludedScanlators().changes(),
) { downloaded, unread, started, bookmarked, excludedScanlators ->
ItemPreferences(
filterDownloaded = downloaded,
filterUnread = unread,
filterStarted = started,
filterBookmarked = bookmarked,
filterExcludedScanlators = excludedScanlators,
)
}
}
fun showFilterDialog() {
mutableState.update { it.copy(dialog = Dialog.FilterSheet) }
}
@Immutable
private data class ItemPreferences(
val filterDownloaded: TriState,
val filterUnread: TriState,
val filterStarted: TriState,
val filterBookmarked: TriState,
val filterExcludedScanlators: Boolean,
)
@Immutable @Immutable
data class State( data class State(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val hasActiveFilters: Boolean = false,
val items: PersistentList<UpdatesItem> = persistentListOf(), val items: PersistentList<UpdatesItem> = persistentListOf(),
val dialog: Dialog? = null, val dialog: Dialog? = null,
) { ) {
@@ -399,6 +488,7 @@ class UpdatesScreenModel(
sealed interface Dialog { sealed interface Dialog {
data class DeleteConfirmation(val toDelete: List<UpdatesItem>) : Dialog data class DeleteConfirmation(val toDelete: List<UpdatesItem>) : Dialog
data object FilterSheet : Dialog
} }
sealed interface Event { sealed interface Event {
@@ -407,6 +497,14 @@ class UpdatesScreenModel(
} }
} }
private fun TriState.toBooleanOrNull(): Boolean? {
return when (this) {
TriState.DISABLED -> null
TriState.ENABLED_IS -> true
TriState.ENABLED_NOT -> false
}
}
@Immutable @Immutable
data class UpdatesItem( data class UpdatesItem(
val update: UpdatesWithRelations, val update: UpdatesWithRelations,
@@ -0,0 +1,20 @@
package eu.kanade.tachiyomi.ui.updates
import cafe.adriel.voyager.core.model.ScreenModel
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class UpdatesSettingsScreenModel(
val updatesPreferences: UpdatesPreferences = Injekt.get(),
) : ScreenModel {
fun toggleFilter(preference: (UpdatesPreferences) -> Preference<TriState>) {
preference(updatesPreferences).getAndSet {
it.next()
}
}
}
@@ -21,6 +21,7 @@ import eu.kanade.core.preference.asState
import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.updates.UpdateScreen import eu.kanade.presentation.updates.UpdateScreen
import eu.kanade.presentation.updates.UpdatesDeleteConfirmationDialog import eu.kanade.presentation.updates.UpdatesDeleteConfirmationDialog
import eu.kanade.presentation.updates.UpdatesFilterDialog
import eu.kanade.presentation.util.Tab import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.download.DownloadQueueScreen import eu.kanade.tachiyomi.ui.download.DownloadQueueScreen
@@ -70,6 +71,7 @@ data object UpdatesTab : Tab {
val context = LocalContext.current val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { UpdatesScreenModel() } val screenModel = rememberScreenModel { UpdatesScreenModel() }
val settingsScreenModel = rememberScreenModel { UpdatesSettingsScreenModel() }
val state by screenModel.state.collectAsState() val state by screenModel.state.collectAsState()
UpdateScreen( UpdateScreen(
@@ -93,6 +95,8 @@ data object UpdatesTab : Tab {
context.startActivity(intent) context.startActivity(intent)
}, },
onCalendarClicked = { navigator.push(UpcomingScreen()) }, onCalendarClicked = { navigator.push(UpcomingScreen()) },
onFilterClicked = screenModel::showFilterDialog,
hasActiveFilters = state.hasActiveFilters,
) )
val onDismissDialog = { screenModel.setDialog(null) } val onDismissDialog = { screenModel.setDialog(null) }
@@ -103,6 +107,12 @@ data object UpdatesTab : Tab {
onConfirm = { screenModel.deleteChapters(dialog.toDelete) }, onConfirm = { screenModel.deleteChapters(dialog.toDelete) },
) )
} }
is UpdatesScreenModel.Dialog.FilterSheet -> {
UpdatesFilterDialog(
onDismissRequest = onDismissDialog,
screenModel = settingsScreenModel,
)
}
null -> {} null -> {}
} }
@@ -24,7 +24,7 @@ class CrashLogUtil(
suspend fun dumpLogs(exception: Throwable? = null) = withNonCancellableContext { suspend fun dumpLogs(exception: Throwable? = null) = withNonCancellableContext {
try { try {
val file = context.createFileInCacheDir("tachiyomi_crash_logs.txt") val file = context.createFileInCacheDir("tachiyomi_sy_crash_logs.txt")
file.appendText(getDebugInfo() + "\n\n") file.appendText(getDebugInfo() + "\n\n")
getExtensionsInfo()?.let { file.appendText("$it\n\n") } getExtensionsInfo()?.let { file.appendText("$it\n\n") }
@@ -5,6 +5,7 @@ package androidx.preference
/** /**
* Returns package-private [EditTextPreference.getOnBindEditTextListener] * Returns package-private [EditTextPreference.getOnBindEditTextListener]
*/ */
@Suppress("EXTENSION_SHADOWED_BY_MEMBER")
fun EditTextPreference.getOnBindEditTextListener(): EditTextPreference.OnBindEditTextListener? { fun EditTextPreference.getOnBindEditTextListener(): EditTextPreference.OnBindEditTextListener? {
return onBindEditTextListener return onBindEditTextListener
} }
@@ -1,6 +1,5 @@
package eu.kanade.test package eu.kanade.test
import android.graphics.Color
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.Tracker import eu.kanade.tachiyomi.data.track.Tracker
@@ -20,8 +19,7 @@ data class DummyTracker(
override val supportsPrivateTracking: Boolean = false, override val supportsPrivateTracking: Boolean = false,
override val isLoggedIn: Boolean = false, override val isLoggedIn: Boolean = false,
override val isLoggedInFlow: Flow<Boolean> = flowOf(false), override val isLoggedInFlow: Flow<Boolean> = flowOf(false),
val valLogoColor: Int = Color.rgb(18, 25, 35), val valLogo: Int = R.drawable.brand_anilist,
val valLogo: Int = R.drawable.ic_tracker_anilist,
val valStatuses: List<Long> = (1L..6L).toList(), val valStatuses: List<Long> = (1L..6L).toList(),
val valReadingStatus: Long = 1L, val valReadingStatus: Long = 1L,
val valRereadingStatus: Long = 1L, val valRereadingStatus: Long = 1L,
@@ -34,8 +32,6 @@ data class DummyTracker(
override val client: OkHttpClient override val client: OkHttpClient
get() = TODO("Not yet implemented") get() = TODO("Not yet implemented")
override fun getLogoColor(): Int = valLogoColor
override fun getLogo(): Int = valLogo override fun getLogo(): Int = valLogo
override fun getStatusList(): List<Long> = valStatuses override fun getStatusList(): List<Long> = valStatuses
@@ -18,7 +18,6 @@ class XLogLogcatLogger : LogcatLogger {
LogPriority.INFO -> LogLevel.Info.int LogPriority.INFO -> LogLevel.Info.int
LogPriority.DEBUG -> LogLevel.Debug.int LogPriority.DEBUG -> LogLevel.Debug.int
LogPriority.VERBOSE -> LogLevel.Verbose.int LogPriority.VERBOSE -> LogLevel.Verbose.int
else -> LogLevel.All.int
} }
} }
} }
@@ -12,8 +12,8 @@ import uy.kohesive.injekt.api.get
class MangaDexLoginActivity : BaseOAuthLoginActivity() { class MangaDexLoginActivity : BaseOAuthLoginActivity() {
override fun handleResult(data: Uri?) { override fun handleResult(uri: Uri) {
val code = data?.getQueryParameter("code") val code = uri.getQueryParameter("code")
if (code != null) { if (code != null) {
lifecycleScope.launchIO { lifecycleScope.launchIO {
val sourceManager = Injekt.get<SourceManager>() val sourceManager = Injekt.get<SourceManager>()
@@ -44,6 +44,8 @@ class ApiMangaParser(
coverFileName: String?, coverFileName: String?,
coverQuality: String, coverQuality: String,
altTitlesInDesc: Boolean, altTitlesInDesc: Boolean,
finalChapterInDesc: Boolean,
preferExtensionLangTitle: Boolean,
): SManga { ): SManga {
val mangaId = getManga.await(manga.url, sourceId)?.id val mangaId = getManga.await(manga.url, sourceId)?.id
val metadata = if (mangaId != null) { val metadata = if (mangaId != null) {
@@ -53,7 +55,17 @@ class ApiMangaParser(
newMetaInstance() newMetaInstance()
} }
parseIntoMetadata(metadata, input, simpleChapters, statistics, coverFileName, coverQuality, altTitlesInDesc) parseIntoMetadata(
metadata,
input,
simpleChapters,
statistics,
coverFileName,
coverQuality,
altTitlesInDesc,
finalChapterInDesc,
preferExtensionLangTitle,
)
if (mangaId != null) { if (mangaId != null) {
metadata.mangaId = mangaId metadata.mangaId = mangaId
insertFlatMetadata.await(metadata.flatten()) insertFlatMetadata.await(metadata.flatten())
@@ -70,13 +82,17 @@ class ApiMangaParser(
coverFileName: String?, coverFileName: String?,
coverQuality: String, coverQuality: String,
altTitlesInDesc: Boolean, altTitlesInDesc: Boolean,
finalChapterInDesc: Boolean,
preferExtensionLangTitle: Boolean,
) { ) {
with(metadata) { with(metadata) {
try { try {
val mangaAttributesDto = mangaDto.data.attributes val mangaAttributesDto = mangaDto.data.attributes
mdUuid = mangaDto.data.id mdUuid = mangaDto.data.id
title = MdUtil.getTitleFromManga(mangaAttributesDto, lang) title = MdUtil.getTitleFromManga(mangaAttributesDto, lang, preferExtensionLangTitle)
altTitles = mangaAttributesDto.altTitles.mapNotNull { it[lang] }.nullIfEmpty() altTitles = mangaAttributesDto.altTitles
.filter { it.containsKey(lang) || it.containsKey("${mangaAttributesDto.originalLanguage}-ro") }
.mapNotNull { it.values.singleOrNull() }.nullIfEmpty()
val mangaRelationshipsDto = mangaDto.data.relationships val mangaRelationshipsDto = mangaDto.data.relationships
cover = if (!coverFileName.isNullOrEmpty()) { cover = if (!coverFileName.isNullOrEmpty()) {
@@ -96,9 +112,19 @@ class ApiMangaParser(
originalLanguage = mangaAttributesDto.originalLanguage, originalLanguage = mangaAttributesDto.originalLanguage,
).orEmpty() ).orEmpty()
val cleanDesc = MdUtil.cleanDescription(rawDesc) description = MdUtil.cleanDescription(rawDesc)
.let { if (altTitlesInDesc) MdUtil.addAltTitleToDesc(it, altTitles) else it }
description = if (altTitlesInDesc) MdUtil.addAltTitleToDesc(cleanDesc, altTitles) else cleanDesc .let {
if (finalChapterInDesc) {
MdUtil.addFinalChapterToDesc(
it,
mangaAttributesDto.lastVolume,
mangaAttributesDto.lastChapter,
)
} else {
it
}
}
authors = mangaRelationshipsDto.filter { relationshipDto -> authors = mangaRelationshipsDto.filter { relationshipDto ->
relationshipDto.type.equals(MdConstants.Types.author, true) relationshipDto.type.equals(MdConstants.Types.author, true)
@@ -148,7 +174,11 @@ class ApiMangaParser(
mangaAttributesDto.contentRating mangaAttributesDto.contentRating
?.takeUnless { it == "safe" } ?.takeUnless { it == "safe" }
?.let { ?.let {
RaisedTag("Content Rating", it.capitalize(Locale.US), MangaDexSearchMetadata.TAG_TYPE_DEFAULT) RaisedTag(
"Content Rating",
it.capitalize(Locale.US),
MangaDexSearchMetadata.TAG_TYPE_DEFAULT,
)
}, },
) )
@@ -8,7 +8,6 @@ import exh.md.service.MangaDexService
import exh.md.utils.MdConstants import exh.md.utils.MdConstants
import exh.md.utils.MdUtil import exh.md.utils.MdUtil
import exh.md.utils.mdListCall import exh.md.utils.mdListCall
import exh.metadata.metadata.MangaDexSearchMetadata
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -21,7 +20,6 @@ class MangaHandler(
private val lang: String, private val lang: String,
private val service: MangaDexService, private val service: MangaDexService,
private val apiMangaParser: ApiMangaParser, private val apiMangaParser: ApiMangaParser,
private val followsHandler: FollowsHandler,
) { ) {
suspend fun getMangaDetails( suspend fun getMangaDetails(
manga: SManga, manga: SManga,
@@ -29,6 +27,8 @@ class MangaHandler(
coverQuality: String, coverQuality: String,
tryUsingFirstVolumeCover: Boolean, tryUsingFirstVolumeCover: Boolean,
altTitlesInDesc: Boolean, altTitlesInDesc: Boolean,
finalChapterInDesc: Boolean,
preferExtensionLangTitle: Boolean,
): SManga { ): SManga {
return coroutineScope { return coroutineScope {
val mangaId = MdUtil.getMangaId(manga.url) val mangaId = MdUtil.getMangaId(manga.url)
@@ -55,13 +55,31 @@ class MangaHandler(
coverFileName?.await(), coverFileName?.await(),
coverQuality, coverQuality,
altTitlesInDesc, altTitlesInDesc,
finalChapterInDesc,
preferExtensionLangTitle,
) )
} }
} }
fun fetchMangaDetailsObservable(manga: SManga, sourceId: Long, coverQuality: String, tryUsingFirstVolumeCover: Boolean, altTitlesInDesc: Boolean): Observable<SManga> { fun fetchMangaDetailsObservable(
manga: SManga,
sourceId: Long,
coverQuality: String,
tryUsingFirstVolumeCover: Boolean,
altTitlesInDesc: Boolean,
finalChapterInDesc: Boolean,
preferExtensionLangTitle: Boolean,
): Observable<SManga> {
return runAsObservable { return runAsObservable {
getMangaDetails(manga, sourceId, coverQuality, tryUsingFirstVolumeCover, altTitlesInDesc) getMangaDetails(
manga,
sourceId,
coverQuality,
tryUsingFirstVolumeCover,
altTitlesInDesc,
finalChapterInDesc,
preferExtensionLangTitle,
)
} }
} }
@@ -92,11 +110,10 @@ class MangaHandler(
} }
private fun getGroupMap(results: List<ChapterDataDto>): Map<String, String> { private fun getGroupMap(results: List<ChapterDataDto>): Map<String, String> {
return results.map { chapter -> chapter.relationships } return results
.flatten() .flatMap { it.relationships }
.filter { it.type == MdConstants.Types.scanlator } .filter { it.type == MdConstants.Types.scanlator }
.map { it.id to it.attributes!!.name!! } .associate { it.id to it.attributes!!.name!! }
.toMap()
} }
suspend fun fetchRandomMangaId(): String { suspend fun fetchRandomMangaId(): String {
@@ -105,23 +122,6 @@ class MangaHandler(
} }
} }
suspend fun getTrackingInfo(track: Track): Pair<Track, MangaDexSearchMetadata?> {
return withIOContext {
/*val metadata = async {
val mangaUrl = MdUtil.buildMangaUrl(MdUtil.getMangaId(track.tracking_url))
val manga = MangaInfo(mangaUrl, track.title)
val response = client.newCall(mangaRequest(manga)).await()
val metadata = MangaDexSearchMetadata()
apiMangaParser.parseIntoMetadata(metadata, response, emptyList())
metadata
}*/
val remoteTrack = async {
followsHandler.fetchTrackingInfo(track.tracking_url)
}
remoteTrack.await() to null
}
}
suspend fun getMangaFromChapterId(chapterId: String): String? { suspend fun getMangaFromChapterId(chapterId: String): String? {
return withIOContext { return withIOContext {
apiMangaParser.chapterParseForMangaId(service.viewChapter(chapterId)) apiMangaParser.chapterParseForMangaId(service.viewChapter(chapterId))
@@ -134,7 +134,9 @@ class MangaHandler(
coverQuality: String, coverQuality: String,
tryUsingFirstVolumeCover: Boolean, tryUsingFirstVolumeCover: Boolean,
altTitlesInDesc: Boolean, altTitlesInDesc: Boolean,
): SManga? { finalChapterInDesc: Boolean,
preferExtensionLangTitle: Boolean,
): SManga {
return withIOContext { return withIOContext {
val mangaId = MdUtil.getMangaId(track.tracking_url) val mangaId = MdUtil.getMangaId(track.tracking_url)
val response = service.viewManga(mangaId) val response = service.viewManga(mangaId)
@@ -154,6 +156,8 @@ class MangaHandler(
coverFileName, coverFileName,
coverQuality, coverQuality,
altTitlesInDesc, altTitlesInDesc,
finalChapterInDesc,
preferExtensionLangTitle,
) )
} }
} }
+68 -82
View File
@@ -3,19 +3,15 @@ package exh.md.utils
import android.app.Application import android.app.Application
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.mdlist.MdList import eu.kanade.tachiyomi.data.track.mdlist.MdList
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.all.MangaDex import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.util.PkceUtil import eu.kanade.tachiyomi.util.PkceUtil
import exh.md.dto.MangaAttributesDto import exh.md.dto.MangaAttributesDto
import exh.md.dto.MangaDataDto import exh.md.dto.MangaDataDto
import exh.source.getMainSource import exh.source.getMainSource
import exh.util.dropBlank
import exh.util.floor
import exh.util.nullIfZero import exh.util.nullIfZero
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.FormBody import okhttp3.FormBody
@@ -25,7 +21,9 @@ import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.jsoup.parser.Parser import org.jsoup.parser.Parser
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.sy.SYMR
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -39,21 +37,10 @@ class MdUtil {
const val baseUrl = "https://mangadex.org" const val baseUrl = "https://mangadex.org"
const val chapterSuffix = "/chapter/" const val chapterSuffix = "/chapter/"
const val similarCacheMapping = "https://api.similarmanga.com/mapping/mdex2search.csv"
const val similarCacheMangas = "https://api.similarmanga.com/manga/"
const val similarBaseApi = "https://api.similarmanga.com/similar/" const val similarBaseApi = "https://api.similarmanga.com/similar/"
const val groupSearchUrl = "$baseUrl/groups/0/1/"
const val reportUrl = "https://api.mangadex.network/report"
const val mdAtHomeTokenLifespan = 10 * 60 * 1000
const val mangaLimit = 20 const val mangaLimit = 20
/**
* Get the manga offset pages are 1 based, so subtract 1
*/
fun getMangaListOffset(page: Int): String = (mangaLimit * (page - 1)).toString()
val jsonParser = val jsonParser =
Json { Json {
isLenient = true isLenient = true
@@ -65,15 +52,8 @@ class MdUtil {
private const val scanlatorSeparator = " & " private const val scanlatorSeparator = " & "
const val contentRatingSafe = "safe" val markdownLinksRegex = "\\[([^]]+)]\\(([^)]+)\\)".toRegex()
const val contentRatingSuggestive = "suggestive" val markdownItalicBoldRegex = "\\*+\\s*([^*]*)\\s*\\*+".toRegex()
const val contentRatingErotica = "erotica"
const val contentRatingPornographic = "pornographic"
val validOneShotFinalChapters = listOf("0", "1")
val markdownLinksRegex = "\\[([^]]+)\\]\\(([^)]+)\\)".toRegex()
val markdownItalicBoldRegex = "\\*+\\s*([^\\*]*)\\s*\\*+".toRegex()
val markdownItalicRegex = "_+\\s*([^_]*)\\s*_+".toRegex() val markdownItalicRegex = "_+\\s*([^_]*)\\s*_+".toRegex()
fun buildMangaUrl(mangaUuid: String): String { fun buildMangaUrl(mangaUuid: String): String {
@@ -94,47 +74,10 @@ class MdUtil {
.trim() .trim()
} }
fun getImageUrl(attr: String): String {
// Some images are hosted elsewhere
if (attr.startsWith("http")) {
return attr
}
return baseUrl + attr
}
fun getScanlators(scanlators: String?): Set<String> {
return scanlators?.split(scanlatorSeparator)?.dropBlank()?.toSet().orEmpty()
}
fun getScanlatorString(scanlators: Set<String>): String { fun getScanlatorString(scanlators: Set<String>): String {
return scanlators.sorted().joinToString(scanlatorSeparator) return scanlators.sorted().joinToString(scanlatorSeparator)
} }
fun getMissingChapterCount(chapters: List<SChapter>, mangaStatus: Int): String? {
if (mangaStatus == SManga.COMPLETED) return null
val remove0ChaptersFromCount = chapters.distinctBy {
/*if (it.chapter_txt.isNotEmpty()) {
it.vol + it.chapter_txt
} else {*/
it.name
/*}*/
}.sortedByDescending { it.chapter_number }
remove0ChaptersFromCount.firstOrNull()?.let { chapter ->
val chpNumber = chapter.chapter_number.floor()
val allChapters = (1..chpNumber).toMutableSet()
remove0ChaptersFromCount.forEach {
allChapters.remove(it.chapter_number.floor())
}
if (allChapters.isEmpty()) return null
return allChapters.size.toString()
}
return null
}
val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+SSS", Locale.US) val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+SSS", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") } .apply { timeZone = TimeZone.getTimeZone("UTC") }
@@ -144,7 +87,7 @@ class MdUtil {
fun createMangaEntry(json: MangaDataDto, lang: String): SManga { fun createMangaEntry(json: MangaDataDto, lang: String): SManga {
return SManga( return SManga(
url = buildMangaUrl(json.id), url = buildMangaUrl(json.id),
title = getTitleFromManga(json.attributes, lang), title = getTitleFromManga(json.attributes, lang, true),
thumbnail_url = json.relationships thumbnail_url = json.relationships
.firstOrNull { relationshipDto -> relationshipDto.type == MdConstants.Types.coverArt } .firstOrNull { relationshipDto -> relationshipDto.type == MdConstants.Types.coverArt }
?.attributes ?.attributes
@@ -155,12 +98,30 @@ class MdUtil {
) )
} }
fun getTitleFromManga(json: MangaAttributesDto, lang: String): String { fun getTitleFromManga(json: MangaAttributesDto, lang: String, preferExtensionLangTitle: Boolean): String {
return getFromLangMap(json.title.asMdMap(), lang, json.originalLanguage) val titleMap = json.title.asMdMap<String>()
?: getAltTitle(json.altTitles, lang, json.originalLanguage) val altTitles = json.altTitles
?: json.title.asMdMap<String>()[json.originalLanguage] val originalLang = json.originalLanguage
?: json.altTitles.firstNotNullOfOrNull { it[json.originalLanguage] }
.orEmpty() titleMap[lang]?.let { return it }
val mainTitle = titleMap.values.firstOrNull()
val langTitle = findTitleInMaps(lang, titleMap, altTitles)
val enTitle = findTitleInMaps("en", titleMap, altTitles)
val originalLangTitle = findTitleInMaps("$originalLang-ro", titleMap, altTitles) ?: findTitleInMaps(
originalLang,
titleMap,
altTitles,
)
val ordered = if (preferExtensionLangTitle) {
listOf(langTitle, mainTitle, enTitle, originalLangTitle)
} else {
listOf(mainTitle, langTitle, enTitle, originalLangTitle)
}
return ordered.firstOrNull { it != null }
?: ""
} }
fun getFromLangMap(langMap: Map<String, String>, currentLang: String, originalLanguage: String): String? { fun getFromLangMap(langMap: Map<String, String>, currentLang: String, originalLanguage: String): String? {
@@ -174,15 +135,12 @@ class MdUtil {
} }
} }
fun getAltTitle(langMaps: List<Map<String, String>>, currentLang: String, originalLanguage: String): String? { fun findTitleInMaps(
return langMaps.firstNotNullOfOrNull { it[currentLang] } lang: String,
?: langMaps.firstNotNullOfOrNull { it["en"] } titleMap: Map<String, String>,
?: if (originalLanguage == "ja") { altTitleMaps: List<Map<String, String>>,
langMaps.firstNotNullOfOrNull { it["ja-ro"] } ): String? {
?: langMaps.firstNotNullOfOrNull { it["jp-ro"] } return titleMap[lang] ?: altTitleMaps.firstNotNullOfOrNull { it[lang] }
} else {
null
}
} }
fun cdnCoverUrl(dexId: String, fileName: String): String { fun cdnCoverUrl(dexId: String, fileName: String): String {
@@ -200,7 +158,7 @@ class MdUtil {
fun loadOAuth(preferences: TrackPreferences, mdList: MdList): MALOAuth? { fun loadOAuth(preferences: TrackPreferences, mdList: MdList): MALOAuth? {
return try { return try {
jsonParser.decodeFromString<MALOAuth>(preferences.trackToken(mdList).get()) jsonParser.decodeFromString<MALOAuth>(preferences.trackToken(mdList).get())
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
} }
@@ -230,7 +188,10 @@ class MdUtil {
return codeVerifier ?: PkceUtil.generateCodeVerifier().also { codeVerifier = it } return codeVerifier ?: PkceUtil.generateCodeVerifier().also { codeVerifier = it }
} }
fun getEnabledMangaDex(sourcePreferences: SourcePreferences = Injekt.get(), sourceManager: SourceManager = Injekt.get()): MangaDex? { fun getEnabledMangaDex(
sourcePreferences: SourcePreferences = Injekt.get(),
sourceManager: SourceManager = Injekt.get(),
): MangaDex? {
return getEnabledMangaDexs(sourcePreferences, sourceManager).let { mangadexs -> return getEnabledMangaDexs(sourcePreferences, sourceManager).let { mangadexs ->
sourcePreferences.preferredMangaDexId().get().toLongOrNull()?.nullIfZero() sourcePreferences.preferredMangaDexId().get().toLongOrNull()?.nullIfZero()
?.let { preferredMangaDexId -> ?.let { preferredMangaDexId ->
@@ -240,7 +201,10 @@ class MdUtil {
} }
} }
fun getEnabledMangaDexs(preferences: SourcePreferences, sourceManager: SourceManager = Injekt.get()): List<MangaDex> { fun getEnabledMangaDexs(
preferences: SourcePreferences,
sourceManager: SourceManager = Injekt.get(),
): List<MangaDex> {
val languages = preferences.enabledLanguages().get() val languages = preferences.enabledLanguages().get()
val disabledSourceIds = preferences.disabledSources().get() val disabledSourceIds = preferences.disabledSources().get()
@@ -262,8 +226,30 @@ class MdUtil {
description description
} else { } else {
val altTitlesDesc = altTitles val altTitlesDesc = altTitles
.joinToString("\n", "${Injekt.get<Application>().getString(R.string.alt_titles)}:\n") { "$it" } .joinToString(
description + (if (description.isBlank()) "" else "\n\n") + Parser.unescapeEntities(altTitlesDesc, false) "\n",
"${Injekt.get<Application>().stringResource(SYMR.strings.alt_titles)}:\n",
) { "$it" }
description + (if (description.isBlank()) "" else "\n\n") + Parser.unescapeEntities(
altTitlesDesc,
false,
)
}
}
fun addFinalChapterToDesc(description: String, lastVolume: String?, lastChapter: String?): String {
val parts = listOfNotNull(
lastVolume?.takeIf { it.isNotEmpty() }?.let { "Vol.$it" },
lastChapter?.takeIf { it.isNotEmpty() }?.let { "Ch.$it" },
)
return if (parts.isEmpty()) {
description
} else {
description + (if (description.isBlank()) "" else "\n\n") + parts.joinToString(
" ",
"${Injekt.get<Application>().stringResource(SYMR.strings.final_chapter)}:\n",
)
} }
} }
} }
@@ -3,7 +3,6 @@ package exh.ui.login
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.webkit.CookieManager import android.webkit.CookieManager
@@ -13,6 +12,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import eu.kanade.presentation.webview.EhLoginWebViewScreen import eu.kanade.presentation.webview.EhLoginWebViewScreen
import eu.kanade.presentation.webview.components.IgneousDialog import eu.kanade.presentation.webview.components.IgneousDialog
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@@ -92,16 +92,32 @@ class EhLoginActivity : BaseActivity() {
private fun onPageFinished(view: WebView, url: String, customIgneous: String?) { private fun onPageFinished(view: WebView, url: String, customIgneous: String?) {
xLogD(url) xLogD(url)
val parsedUrl = Uri.parse(url) val parsedUrl = url.toUri()
if (parsedUrl.host.equals("forums.e-hentai.org", ignoreCase = true)) { if (parsedUrl.host.equals("forums.e-hentai.org", ignoreCase = true)) {
// Hide distracting content view.evaluateJavascript(
if (!parsedUrl.queryParameterNames.contains(PARAM_SKIP_INJECT)) { """
view.evaluateJavascript(HIDE_JS, null) (function() {
} let html = document.documentElement.innerHTML;
// Check login result return html.includes("/cdn-cgi/");
})();
""".trimIndent()
) { result ->
val isCloudflareBlock = result == "true"
if (parsedUrl.getQueryParameter("code")?.toInt() != 0) { if (isCloudflareBlock) {
if (checkLoginCookies(url)) view.loadUrl("https://exhentai.org/") xLogD("Cloudflare block detected — skipping logic")
return@evaluateJavascript
}
// Hide distracting content
if (!parsedUrl.queryParameterNames.contains(PARAM_SKIP_INJECT)) {
view.evaluateJavascript(HIDE_JS, null)
}
// Check login result
if (parsedUrl.getQueryParameter("code")?.toInt() != 0) {
if (checkLoginCookies(url)) view.loadUrl("https://exhentai.org/")
}
} }
} else if (parsedUrl.host.equals("exhentai.org", ignoreCase = true)) { } else if (parsedUrl.host.equals("exhentai.org", ignoreCase = true)) {
// At ExHentai, check that everything worked out... // At ExHentai, check that everything worked out...
@@ -155,9 +171,9 @@ class EhLoginActivity : BaseActivity() {
if (memberId == null || passHash == null || igneous == null) return false if (memberId == null || passHash == null || igneous == null) return false
// Update prefs // Update prefs
exhPreferences.memberIdVal().set(memberId!!) exhPreferences.memberIdVal().set(memberId)
exhPreferences.passHashVal().set(passHash!!) exhPreferences.passHashVal().set(passHash)
exhPreferences.igneousVal().set(igneous!!) exhPreferences.igneousVal().set(igneous)
return true return true
} }
+3 -1
View File
@@ -6,6 +6,7 @@ import exh.source.EH_SOURCE_ID
import exh.source.EXH_SOURCE_ID import exh.source.EXH_SOURCE_ID
import exh.source.PURURIN_SOURCE_ID import exh.source.PURURIN_SOURCE_ID
import exh.source.TSUMINO_SOURCE_ID import exh.source.TSUMINO_SOURCE_ID
import exh.source.lanraragiSourceIds
import exh.source.mangaDexSourceIds import exh.source.mangaDexSourceIds
import exh.source.nHentaiSourceIds import exh.source.nHentaiSourceIds
import java.util.Locale import java.util.Locale
@@ -23,7 +24,8 @@ object SourceTagsUtil {
sourceId in nHentaiSourceIds || sourceId in nHentaiSourceIds ||
sourceId in mangaDexSourceIds || sourceId in mangaDexSourceIds ||
sourceId == PURURIN_SOURCE_ID || sourceId == PURURIN_SOURCE_ID ||
sourceId == TSUMINO_SOURCE_ID sourceId == TSUMINO_SOURCE_ID ||
sourceId in lanraragiSourceIds
) { ) {
val parsed = when { val parsed = when {
fullTag != null -> parseTag(fullTag) fullTag != null -> parseTag(fullTag)
@@ -73,6 +73,7 @@ class MigrateMangaUseCase(
updatedChapter = updatedChapter.copy( updatedChapter = updatedChapter.copy(
dateFetch = prevChapter.dateFetch, dateFetch = prevChapter.dateFetch,
bookmark = prevChapter.bookmark, bookmark = prevChapter.bookmark,
lastPageRead = prevChapter.lastPageRead
) )
} }
@@ -20,6 +20,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -62,7 +63,6 @@ import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.FastScrollLazyColumn import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.Pill import tachiyomi.presentation.core.components.Pill
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@@ -144,7 +144,7 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
) )
}, },
floatingActionButton = { floatingActionButton = {
ExtendedFloatingActionButton( SmallExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) }, text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) },
icon = { Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null) }, icon = { Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null) },
onClick = { onClick = {
@@ -331,13 +331,13 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
} }
} }
private fun updateSources(save: Boolean = true, action: (List<MigrationSource>) -> List<MigrationSource>) { private fun updateSources(action: (List<MigrationSource>) -> List<MigrationSource>) {
mutableState.update { state -> mutableState.update { state ->
val updatedSources = action(state.sources) val updatedSources = action(state.sources)
val includedSources = updatedSources.mapNotNull { if (!it.isSelected) null else it.id } val includedSources = updatedSources.mapNotNull { if (!it.isSelected) null else it.id }
state.copy(sources = updatedSources.sortedWith(sourcesComparator(includedSources))) state.copy(sources = updatedSources.sortedWith(sourcesComparator(includedSources)))
} }
if (save) saveSources() saveSources()
} }
private fun initSources() { private fun initSources() {
@@ -370,7 +370,9 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
} }
.toList() .toList()
updateSources(save = false) { sources } mutableState.update { state ->
state.copy(sources = sources.sortedWith(sourcesComparator(includedSources)))
}
} }
fun toggleSelection(id: Long) { fun toggleSelection(id: Long) {
@@ -145,7 +145,7 @@ private class MigrateDialogScreenModel(
} }
val selectedFlags = sourcePreference.migrationFlags().get() val selectedFlags = sourcePreference.migrationFlags().get()
mutableState.update { mutableState.update {
it.copy( State(
current = current, current = current,
target = target, target = target,
applicableFlags = applicableFlags, applicableFlags = applicableFlags,
@@ -54,9 +54,11 @@ fun CalenderHeader(
} }
Row { Row {
IconButton(onClick = onPreviousClick) { IconButton(onClick = onPreviousClick) {
@Suppress("DEPRECATION")
Icon(Icons.Default.KeyboardArrowLeft, stringResource(MR.strings.upcoming_calendar_prev)) Icon(Icons.Default.KeyboardArrowLeft, stringResource(MR.strings.upcoming_calendar_prev))
} }
IconButton(onClick = onNextClick) { IconButton(onClick = onNextClick) {
@Suppress("DEPRECATION")
Icon(Icons.Default.KeyboardArrowRight, stringResource(MR.strings.upcoming_calendar_next)) Icon(Icons.Default.KeyboardArrowRight, stringResource(MR.strings.upcoming_calendar_next))
} }
} }

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