Compare commits

..

106 Commits

Author SHA1 Message Date
KaiserBh e96895345e feat(sync): prevent deleted "ghost chapters" from reappearing during sync. (#1575)
* feat(sync): prevent deleted "ghost chapters" from reappearing during sync.

- Pass lastSyncTime down to mergeChapters in SyncService.kt.
- Apply timestamp-based tombstoning logic to chapter merging. When a chapter is missing from either the local or remote backup, its `lastModifiedAt` timestamp is checked against the device's last sync time.
- Ensure that chapters deleted on one device (or removed by a source) are recognized as deletions and dropped from the merged backup, rather than being erroneously restored as "new" chapters on subsequent syncs.

* chore: change timestamp to use duration-based calculations

* chore: spotless
2026-04-06 13:08:30 -04:00
MediocreLegion eec1236b8b fix(delegate): migrate NH to the v2 api (#1581)
* fix(delegate): migrate NH to the v2 api

* remove extra comment

* remove redundant data

* linting

* Code cleanup

---------

Co-authored-by: Jobobby04 <jobobby04@users.noreply.github.com>
2026-04-03 12:59:13 -04:00
Weblate (bot) ee1e783126 Translations update from Hosted Weblate (#1577)
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/ja/
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/
Translation: Mihon/TachiyomiSY

Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: ScratchBuild <foobarbuzz@gmail.com>
Co-authored-by: ZenVinny <atdenada@gmail.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
2026-04-03 12:50:27 -04:00
renovate[bot] f3ab39cb1f Update dependency net.zetetic:sqlcipher-android to v4.14.1 (#1583)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 12:50:00 -04:00
KaiserBh ba75395648 refactor: improve sync merging categories (#1559)
* feat: Add versioning to categories

* feat: use random UID for categories.

For legacy and migration we should assign uid on insert, and modify existing one as well in the migration.

* feat: sync category metadata

Add version, uid and lastModifiedAt fields to Category model to allow syncing.

* chore: fix category merging logic

Improve the category merging logic by matching using UIDs first, with a fallback to matching by name for legacy remote categories.

Previously, categories were only matched by name, which could lead to incorrect merges if names were changed. This change ensures more accurate synchronization by prioritizing the unique identifier. Conflict resolution is now based on the `version` field, and logging has been added for better visibility into the merging process.

* refactor: prioritize UID when restoring categories

If a category with the same UID exists, update it instead of creating a new one. Fallback to matching by name if no UID match is found.

* chore: add isSyncing flag like before.

This make sure the version is consistent, and it's not accidentally appended by the trigger, if it does then one device will always be ahead, than previous, and they need to make multiple changes to increase the version.

* Apply suggestion from @jobobby04

Use SY specific numbers(601, 602 for now)

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>

* chore: commit review, re-order.

* chore: surround changes in // SY --> // SY <--

* refactor: fallback to existing category UID if backup UID is 0 during restore.

when dealing with old backups (backups created before we added UIDs). In those old backups, backupCategory.uid defaults to 0.
If a user restored an old backup, it would match by name, and then overwrite the newly generated local UID with 0. This would break the synchronization.

* refactor: change to 6xx

* feat: improve sync reliability for categories and settings

- Refactor `mergeCategoriesLists` to correctly match categories by name when UID matching fails, ensuring better reconciliation across devices.
- Fix a bug in category merging where multiple categories with UID 0 (common for non-synced items) caused data loss.
- Update `SyncManager` to detect changes in categories, sources, preferences, saved searches, and extension repos, ensuring they synchronize even when the library favorites haven't changed.
- Convert `BackupCategory` and `BackupExtensionRepos` to data classes to support robust content-aware comparison during the sync process.
- Fix data loss in `mergeSourcesLists`, `mergePreferencesLists`, and `mergeSavedSearchesLists` by retaining local versions when conflicting with remote data.

* fix(sync): properly sync category deletions across devices

Previously, the sync system could not distinguish between a category that was deleted locally and a new category created on another device, causing deleted categories to be restored from the remote backup.

- Update `SyncService` to use `lastSyncTimestamp` to deduce if a missing local category was deleted (if modified before last sync) or newly created remotely (if modified after).
- Update `SyncManager` to explicitly delete local categories that are absent from the merged remote backup, propagating deletions to other devices.
- Fix `RestoreOptions` in `SyncManager` to respect the user's sync preferences instead of hardcoding `categories = true`.

* chore: change it to 6xx and not 600.

* chore: don't need to change this.

* chore: use kotlin time duration units

---------

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>
2026-04-03 12:49:37 -04:00
Weblate (bot) fe0b14ab97 Translations update from Hosted Weblate (#1561)
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/be/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/ta/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy-plurals/tr/
Translate-URL: https://hosted.weblate.org/projects/mihon/tachiyomisy/be/
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/ta/
Translation: Mihon/TachiyomiSY
Translation: Mihon/TachiyomiSY Plurals

Co-authored-by: Kehribar <103407696+dpentx@users.noreply.github.com>
Co-authored-by: Lucas Correia <anicetoclucas@gmail.com>
Co-authored-by: lilp <felipegabriel.avila6@gmail.com>
Co-authored-by: nadevko <ormak@protonmail.com>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
2026-03-18 19:55:58 -04:00
renovate[bot] 91d2140288 Update koin to v4.2.0 (#1569)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-18 19:47:20 -04:00
renovate[bot] 0417969dd6 Update dependency net.zetetic:sqlcipher-android to v4.14.0 (#1567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-18 19:47:08 -04:00
AntsyLich 5d8d2ce48a Switch to AndroidX bundled sqlite driver (#3082)
# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt
2026-03-18 19:45:20 -04:00
Mend Renovate b15277f134 Update paging.version to v3.4.2 (#3063) 2026-03-18 19:21:01 -04:00
Mend Renovate 76ca27f681 Update kotlin monorepo to v2.3.20 (#3074) 2026-03-18 19:20:57 -04:00
Mend Renovate 56923c76d4 Update sqldelight to v2.3.2 (#3077) 2026-03-18 19:20:53 -04:00
MajorTanya 32e19736b9 Address bundleOf deprecation (#3073) 2026-03-18 19:20:48 -04:00
Mend Renovate 11b01b2771 Update sqldelight to v2.3.1 (#3071)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-03-18 19:20:45 -04:00
Mend Renovate 460ff13e54 Update dependency io.kotest:kotest-assertions-core to v6.1.7 (#3062) 2026-03-18 19:20:42 -04:00
Mend Renovate 57f77c8105 Update moko to v0.26.1 (#3068) 2026-03-18 19:20:39 -04:00
Mend Renovate a2eb22964a Update dependency com.squareup.okio:okio to v3.17.0 (#3070) 2026-03-18 19:20:36 -04:00
Mend Renovate 7158bae26a Update dependency androidx.activity:activity-compose to v1.13.0 (#3065) 2026-03-18 19:20:32 -04:00
Mend Renovate 807ce846d5 Update dependency androidx.core:core-ktx to v1.18.0 (#3067) 2026-03-18 19:20:29 -04:00
Mend Renovate 0b68f2c62a Update dependency androidx.compose:compose-bom to v2026.03.00 (#3066) 2026-03-18 19:20:26 -04:00
AntsyLich b7d6cc8dd0 Add installation id for feature flags (#3052)
# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt
#	app/src/main/java/mihon/core/migration/migrations/Migrations.kt
2026-03-18 19:20:17 -04:00
Mend Renovate 8b1fd30902 Update dependency androidx.compose:compose-bom to v2026.02.01 (#3009) 2026-03-18 19:09:37 -04:00
Mend Renovate aff43f3aeb Update dependency com.google.firebase:firebase-bom to v34.10.0 (#3006) 2026-03-18 19:09:29 -04:00
Mend Renovate 0047d2e5d8 Update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.3.0 (#3029) 2026-03-18 19:09:22 -04:00
Mend Renovate d87385f5b3 Update dependency com.materialkolor:material-kolor to v5.0.0-alpha07 (#3024) 2026-03-18 19:09:15 -04:00
AntsyLich c17e9573b7 Reapply "Fix cache invalidation isn't done at startup (#2970)"
This reverts commit d219c5e3bbcfb24c40fa69e40bff11b6fd81fd7f.

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt
2026-03-18 19:08:43 -04:00
AntsyLich 9c01119d24 Reapply "Fix thread starvation caused by not yielding or using an inappropriate thread pool (#2955)"
This reverts commit 1d7c838ae64e624d9dd0884722f0c6ae5d18e386.

# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt
#	app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt
2026-03-18 19:06:16 -04:00
Jobobby04 bbc839e234 Lint 2026-02-27 22:44:01 -05:00
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
187 changed files with 3459 additions and 1574 deletions
+1 -1
View File
@@ -100,5 +100,5 @@ body:
required: true
- label: I have filled out all of the requested information in this form, including specific version numbers.
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
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
run: ./gradlew spotlessCheck assembleDevDebug
- name: Upload APK
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: TachiyomiSY-${{ github.sha }}.apk
path: app/build/outputs/apk/dev/debug/app-dev-debug.apk
+5 -2
View File
@@ -31,7 +31,7 @@ android {
defaultConfig {
applicationId = "eu.kanade.tachiyomi.sy"
versionCode = 75
versionCode = 77
versionName = "1.12.0"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
@@ -150,12 +150,14 @@ kotlin {
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-Xannotation-default-target=param-property",
)
}
}
@@ -190,7 +192,7 @@ dependencies {
implementation(androidx.paging.runtime)
implementation(androidx.paging.compose)
implementation(libs.bundles.sqlite)
implementation(androidx.sqlite.bundled)
// SY -->
implementation(sylibs.sqlcipher)
// SY <--
@@ -265,6 +267,7 @@ dependencies {
implementation(libs.compose.grid)
implementation(libs.reorderable)
implementation(libs.bundles.markdown)
implementation(libs.materialKolor)
// Logging
implementation(libs.logcat)
+3 -1
View File
@@ -298,4 +298,6 @@
-dontwarn org.ietf.jgss.GSSException
-dontwarn org.ietf.jgss.GSSManager
-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.UpdateCategory
import tachiyomi.domain.category.repository.CategoryRepository
import tachiyomi.domain.chapter.interactor.GetBookmarkedChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetChapter
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
@@ -156,6 +157,7 @@ class DomainModule : InjektModule {
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
addFactory { GetChapter(get()) }
addFactory { GetChaptersByMangaId(get()) }
addFactory { GetBookmarkedChaptersByMangaId(get(), get(), get()) }
addFactory { GetChapterByUrlAndMangaId(get()) }
addFactory { UpdateChapter(get()) }
addFactory { SetReadStatus(get(), get(), get(), get(), get()) }
@@ -35,4 +35,6 @@ class BasePreferences(
fun hardwareBitmapThreshold() = preferenceStore.getInt("pref_hardware_bitmap_threshold", GLUtil.SAFE_TEXTURE_LIMIT)
fun alwaysDecodeLongStripWithSSIV() = preferenceStore.getBoolean("pref_always_decode_long_strip_with_ssiv", false)
fun installationId() = preferenceStore.getString(Preference.appStateKey("installation_id"), "")
}
@@ -30,8 +30,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import com.gowtham.ratingbar.RatingBar
import com.gowtham.ratingbar.RatingBarConfig
import com.gowtham.ratingbar.ComposeStars
import com.gowtham.ratingbar.RatingBarStyle
import dev.icerock.moko.resources.StringResource
import eu.kanade.presentation.manga.components.MangaCover
import exh.metadata.MetadataUtil
@@ -222,17 +222,18 @@ fun BrowseSourceEHentaiListItem(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
horizontalAlignment = Alignment.Start,
) {
RatingBar(
ComposeStars(
value = rating,
onValueChange = {},
onRatingChanged = {},
config = RatingBarConfig().apply {
isIndicator(true)
numStars(5)
size(18.dp)
activeColor(Color(0xFF005ED7))
inactiveColor(Color(0xE1E2ECFF))
},
numOfStars = 5,
size = 18.dp,
spaceBetween = 2.dp,
hideInactiveStars = false,
style = RatingBarStyle.Fill(
activeColor = Color(0xFF005ED7),
inActiveColor = Color(0xE1E2ECFF),
),
painterEmpty = null,
painterFilled = null,
)
val color = genre?.first?.color
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.outlined.FilterList
import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.i18n.stringResource
@Composable
@@ -17,7 +17,7 @@ fun BrowseSourceFloatingActionButton(
onFabClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ExtendedFloatingActionButton(
SmallExtendedFloatingActionButton(
modifier = modifier,
text = {
Text(
@@ -4,11 +4,11 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.shouldExpandFAB
@@ -18,7 +18,7 @@ fun CategoryFloatingActionButton(
onCreate: () -> Unit,
modifier: Modifier = Modifier,
) {
ExtendedFloatingActionButton(
SmallExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.action_add)) },
icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) },
onClick = onCreate,
@@ -21,8 +21,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TooltipAnchorPosition
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.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
@@ -195,7 +196,7 @@ fun AppBarActions(
actions.filterIsInstance<AppBar.Action>().map {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(it.title)
@@ -220,7 +221,7 @@ fun AppBarActions(
val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>()
if (overflowActions.isNotEmpty()) {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(stringResource(MR.strings.action_menu_overflow_description))
@@ -349,7 +350,7 @@ fun SearchToolbar(
// Don't show search action
} else if (searchQuery == null) {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(stringResource(MR.strings.action_search))
@@ -369,7 +370,7 @@ fun SearchToolbar(
}
} else if (searchQuery.isNotEmpty()) {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(stringResource(MR.strings.action_reset))
@@ -1,6 +1,5 @@
package eu.kanade.presentation.components
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -14,11 +13,11 @@ import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun DownloadDropdownMenu(
modifier: Modifier = Modifier,
expanded: Boolean,
onDismissRequest: () -> Unit,
onDownloadClicked: (DownloadAction) -> Unit,
offset: DpOffset? = null,
modifier: Modifier = Modifier,
) {
if (offset != null) {
DropdownMenu(
@@ -49,7 +48,7 @@ fun DownloadDropdownMenu(
}
@Composable
private fun ColumnScope.DownloadDropdownMenuItems(
private fun DownloadDropdownMenuItems(
onDismissRequest: () -> 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_25_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 25, 25),
DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread),
DownloadAction.BOOKMARKED_CHAPTERS to stringResource(MR.strings.download_bookmarked),
)
options.map { (downloadAction, string) ->
@@ -1,10 +1,7 @@
package eu.kanade.presentation.manga
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
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.Column
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.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -101,7 +100,6 @@ import tachiyomi.domain.source.model.StubSource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.TwoPanelBox
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.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
@@ -167,7 +165,7 @@ fun MangaScreen(
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
// Chapter selection
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
) {
@@ -331,7 +329,7 @@ private fun MangaScreenSmallImpl(
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
// Chapter selection
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
) {
@@ -418,25 +416,23 @@ private fun MangaScreenSmallImpl(
val isFABVisible = remember(chapters) {
chapters.fastAny { !it.chapter.read } && !isAnySelected
}
AnimatedVisibility(
visible = isFABVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val isReading = remember(state.chapters) {
state.chapters.fastAny { it.chapter.read }
}
Text(
text = stringResource(if (isReading) MR.strings.action_resume else MR.strings.action_start),
)
},
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
expanded = chapterListState.shouldExpandFAB(),
)
}
SmallExtendedFloatingActionButton(
text = {
val isReading = remember(state.chapters) {
state.chapters.fastAny { it.chapter.read }
}
Text(
text = stringResource(if (isReading) MR.strings.action_resume else MR.strings.action_start),
)
},
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
expanded = chapterListState.shouldExpandFAB(),
modifier = Modifier.animateFloatingActionButton(
visible = isFABVisible,
alignment = Alignment.BottomEnd,
),
)
},
) { contentPadding ->
val topPadding = contentPadding.calculateTopPadding()
@@ -654,7 +650,7 @@ fun MangaScreenLargeImpl(
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
// Chapter selection
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
) {
@@ -737,27 +733,25 @@ fun MangaScreenLargeImpl(
val isFABVisible = remember(chapters) {
chapters.fastAny { !it.chapter.read } && !isAnySelected
}
AnimatedVisibility(
visible = isFABVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val isReading = remember(state.chapters) {
state.chapters.fastAny { it.chapter.read }
}
Text(
text = stringResource(
if (isReading) MR.strings.action_resume else MR.strings.action_start,
),
)
},
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
expanded = chapterListState.shouldExpandFAB(),
)
}
SmallExtendedFloatingActionButton(
text = {
val isReading = remember(state.chapters) {
state.chapters.fastAny { it.chapter.read }
}
Text(
text = stringResource(
if (isReading) MR.strings.action_resume else MR.strings.action_start,
),
)
},
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
expanded = chapterListState.shouldExpandFAB(),
modifier = Modifier.animateFloatingActionButton(
visible = isFABVisible,
alignment = Alignment.BottomEnd,
),
)
},
) { contentPadding ->
PullRefresh(
@@ -953,7 +947,7 @@ private fun LazyListScope.sharedChapterItems(
// SY <--
onChapterClicked: (Chapter) -> 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,
) {
items(
@@ -1020,14 +1014,14 @@ private fun LazyListScope.sharedChapterItems(
chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction,
onLongClick = {
onChapterSelected(item, !item.selected, true, true)
onChapterSelected(item, !item.selected, true)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
onClick = {
onChapterItemClick(
chapterItem = item,
isAnyChapterSelected = isAnyChapterSelected,
onToggleSelection = { onChapterSelected(item, !item.selected, true, false) },
onToggleSelection = { onChapterSelected(item, !item.selected, false) },
onChapterClicked = onChapterClicked,
)
},
@@ -6,6 +6,7 @@ enum class DownloadAction {
NEXT_10_CHAPTERS,
NEXT_25_CHAPTERS,
UNREAD_CHAPTERS,
BOOKMARKED_CHAPTERS,
}
enum class EditCoverAction {
@@ -93,10 +93,10 @@ fun MangaBottomActionMenu(
) {
val haptic = LocalHapticFeedback.current
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 ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
(0..<7).forEach { i -> confirm[i] = i == toConfirmIndex }
confirm.indices.forEach { i -> confirm[i] = i == toConfirmIndex }
resetJob?.cancel()
resetJob = scope.launch {
delay(1.seconds)
@@ -260,10 +260,10 @@ fun LibraryBottomActionMenu(
) {
val haptic = LocalHapticFeedback.current
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 ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
(0..5).forEach { i -> confirm[i] = i == toConfirmIndex }
confirm.indices.forEach { i -> confirm[i] = i == toConfirmIndex }
resetJob?.cancel()
resetJob = scope.launch {
delay(1.seconds)
@@ -605,44 +605,47 @@ private fun ColumnScope.MangaContentInfo(
}
}
private fun descriptionAnnotator(loadImages: Boolean, linkStyle: SpanStyle) = markdownAnnotator(
annotate = { content, child ->
if (!loadImages && child.type == MarkdownElementTypes.IMAGE) {
val inlineLink = child.findChildOfType(MarkdownElementTypes.INLINE_LINK)
@Composable
private fun descriptionAnnotator(loadImages: Boolean, linkStyle: SpanStyle) = remember(loadImages, linkStyle) {
markdownAnnotator(
annotate = { content, child ->
if (!loadImages && child.type == MarkdownElementTypes.IMAGE) {
val inlineLink = child.findChildOfType(MarkdownElementTypes.INLINE_LINK)
val url = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_DESTINATION)
?.getUnescapedTextInNode(content)
?: inlineLink?.findChildOfType(MarkdownElementTypes.AUTOLINK)
?.findChildOfType(MarkdownTokenTypes.AUTOLINK)
val url = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_DESTINATION)
?.getUnescapedTextInNode(content)
?: return@markdownAnnotator false
?: inlineLink?.findChildOfType(MarkdownElementTypes.AUTOLINK)
?.findChildOfType(MarkdownTokenTypes.AUTOLINK)
?.getUnescapedTextInNode(content)
?: return@markdownAnnotator false
val textNode = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TITLE)
?: inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TEXT)
val altText = textNode?.findChildOfType(MarkdownTokenTypes.TEXT)
?.getUnescapedTextInNode(content).orEmpty()
val textNode = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TITLE)
?: inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TEXT)
val altText = textNode?.findChildOfType(MarkdownTokenTypes.TEXT)
?.getUnescapedTextInNode(content).orEmpty()
withLink(LinkAnnotation.Url(url = url)) {
pushStyle(linkStyle)
appendInlineContent(MARKDOWN_INLINE_IMAGE_TAG)
append(altText)
pop()
withLink(LinkAnnotation.Url(url = url)) {
pushStyle(linkStyle)
appendInlineContent(MARKDOWN_INLINE_IMAGE_TAG)
append(altText)
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) {
append(content.substring(child.startOffset, child.endOffset))
return@markdownAnnotator true
}
false
},
config = markdownAnnotatorConfig(
eolAsNewLine = true,
),
)
false
},
config = markdownAnnotatorConfig(
eolAsNewLine = true,
),
)
}
@Composable
private fun MangaSummary(
@@ -30,6 +30,7 @@ sealed class Preference {
override val title: String,
override val subtitle: CharSequence? = null,
override val enabled: Boolean = true,
val widget: @Composable (() -> Unit)? = null,
val onClick: (() -> Unit)? = null,
) : PreferenceItem<String, Unit>() {
override val icon: ImageVector? = null
@@ -147,6 +147,7 @@ internal fun PreferenceItem(
title = item.title,
subtitle = item.subtitle,
icon = item.icon,
widget = item.widget,
onPreferenceClick = item.onClick,
)
}
@@ -223,6 +223,7 @@ object SettingsAdvancedScreen : SearchableSettings {
private fun getDataGroup(): Preference.PreferenceGroup {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
return Preference.PreferenceGroup(
title = stringResource(MR.strings.label_data),
@@ -231,8 +232,10 @@ object SettingsAdvancedScreen : SearchableSettings {
title = stringResource(MR.strings.pref_invalidate_download_cache),
subtitle = stringResource(MR.strings.pref_invalidate_download_cache_summary),
onClick = {
Injekt.get<DownloadCache>().invalidateCache()
context.toast(MR.strings.download_cache_invalidated)
scope.launch {
Injekt.get<DownloadCache>().invalidateCache()
context.toast(MR.strings.download_cache_invalidated)
}
},
),
Preference.PreferenceItem.TextPreference(
@@ -303,7 +303,10 @@ object SettingsDataScreen : SearchableSettings {
val chapterCache = remember { Injekt.get<ChapterCache>() }
var cacheReadableSizeSema by remember { mutableIntStateOf(0) }
val cacheReadableSize = remember(cacheReadableSizeSema) { chapterCache.readableSize }
var cacheReadableSize by remember { mutableStateOf(context.stringResource(MR.strings.calculating)) }
LaunchedEffect(cacheReadableSizeSema) {
cacheReadableSize = chapterCache.getReadableSize()
}
// SY -->
val pagePreviewCache = remember { Injekt.get<PagePreviewCache>() }
@@ -245,7 +245,6 @@ object AboutScreen : Screen() {
is GetApplicationRelease.Result.OsTooOld -> {
context.toast(MR.strings.update_check_eol)
}
else -> {}
}
} catch (e: Exception) {
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.navigator.LocalNavigator
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.util.htmlReadyLicenseContent
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.R
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
@@ -27,7 +30,9 @@ class OpenSourceLicensesScreen : Screen() {
)
},
) { contentPadding ->
val libraries by produceLibraries(R.raw.aboutlibraries)
LibrariesContainer(
libraries = libraries,
modifier = Modifier
.fillMaxSize(),
contentPadding = contentPadding,
@@ -1,6 +1,7 @@
package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
@@ -61,6 +62,7 @@ fun ExtensionRepoCreateDialog(
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = name,
onValueChange = { name = it },
@@ -9,12 +9,18 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.util.storage.DiskUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
@@ -45,10 +51,24 @@ private fun StorageInfo(
) {
val context = LocalContext.current
val available = remember(file) { DiskUtil.getAvailableStorageSpace(file) }
val availableText = remember(available) { Formatter.formatFileSize(context, available) }
val total = remember(file) { DiskUtil.getTotalStorageSpace(file) }
val totalText = remember(total) { Formatter.formatFileSize(context, total) }
var available by remember(file) { mutableStateOf(-1L) }
var total by remember(file) { mutableStateOf(-1L) }
LaunchedEffect(file) {
available = withContext(Dispatchers.IO) { DiskUtil.getAvailableStorageSpace(file) }
total = withContext(Dispatchers.IO) { DiskUtil.getTotalStorageSpace(file) }
}
val availableText = if (available == -1L) {
stringResource(MR.strings.calculating)
} else {
Formatter.formatFileSize(context, available)
}
val totalText = if (total == -1L) {
stringResource(MR.strings.calculating)
} else {
Formatter.formatFileSize(context, total)
}
Column(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
@@ -58,13 +78,15 @@ private fun StorageInfo(
style = MaterialTheme.typography.header,
)
LinearProgressIndicator(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.fillMaxWidth()
.height(12.dp),
progress = { (1 - (available / total.toFloat())) },
)
if (total > 0) {
LinearProgressIndicator(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.fillMaxWidth()
.height(12.dp),
progress = { (1 - (available / total.toFloat())) },
)
}
Text(
text = stringResource(MR.strings.available_disk_space_info, availableText, totalText),
@@ -1,24 +1,38 @@
package eu.kanade.presentation.more.settings.screen.debug
import android.os.Build
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Autorenew
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.profileinstaller.ProfileVerifier
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.base.BasePreferences
import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.PreferenceScaffold
import eu.kanade.presentation.more.settings.screen.about.AboutScreen
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
import mihon.core.common.FeatureFlags
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class DebugInfoScreen : Screen() {
@@ -47,6 +61,12 @@ class DebugInfoScreen : Screen() {
@Composable
private fun getAppInfoGroup(): Preference.PreferenceGroup {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val installationIdPref = remember { Injekt.get<BasePreferences>().installationId() }
val installationId by installationIdPref.collectAsState()
return Preference.PreferenceGroup(
title = "App info",
preferenceItems = persistentListOf(
@@ -58,6 +78,28 @@ class DebugInfoScreen : Screen() {
title = "Build time",
subtitle = AboutScreen.getFormattedBuildTime(),
),
Preference.PreferenceItem.TextPreference(
title = "Installation ID",
subtitle = installationId,
widget = {
IconButton(
onClick = {
scope.launch {
installationIdPref.set(FeatureFlags.newInstallationId())
}
},
) {
Icon(
imageVector = Icons.Outlined.Autorenew,
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
)
}
},
onClick = {
context.copyToClipboard(installationId, installationId)
},
),
getProfileVerifierPreference(),
Preference.PreferenceItem.TextPreference(
title = "WebView version",
@@ -78,7 +120,7 @@ class DebugInfoScreen : Screen() {
val status by produceState(initialValue = "-") {
val result = ProfileVerifier.getCompilationStatusAsync().await().profileInstallResultCode
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_NON_MATCHING ->
"Compiled non-matching"
@@ -1,10 +1,11 @@
package eu.kanade.presentation.theme
import android.content.Context
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.domain.ui.model.AppTheme
@@ -53,26 +54,36 @@ private fun BaseTachiyomiTheme(
isAmoled: Boolean,
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = getThemeColorScheme(appTheme, isAmoled),
val context = LocalContext.current
val isDark = isSystemInDarkTheme()
MaterialExpressiveTheme(
colorScheme = remember(appTheme, isDark, isAmoled) {
getThemeColorScheme(
context = context,
appTheme = appTheme,
isDark = isDark,
isAmoled = isAmoled,
)
},
content = content,
)
}
@Composable
@ReadOnlyComposable
private fun getThemeColorScheme(
context: Context,
appTheme: AppTheme,
isDark: Boolean,
isAmoled: Boolean,
): ColorScheme {
val colorScheme = if (appTheme == AppTheme.MONET) {
MonetColorScheme(LocalContext.current)
MonetColorScheme(context)
} else {
colorSchemes.getOrDefault(appTheme, TachiyomiColorScheme)
}
return colorScheme.getColorScheme(
isSystemInDarkTheme(),
isAmoled,
isDark = isDark,
isAmoled = isAmoled,
overrideDarkSurfaceContainers = appTheme != AppTheme.MONET,
)
}
@@ -14,16 +14,25 @@ internal abstract class BaseColorScheme {
private val surfaceContainerHigh = Color(0xFF131313)
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 (!isAmoled) return darkScheme
return darkScheme.copy(
val amoledScheme = darkScheme.copy(
background = Color.Black,
onBackground = Color.White,
surface = Color.Black,
onSurface = Color.White,
)
if (!overrideDarkSurfaceContainers) return amoledScheme
return amoledScheme.copy(
surfaceVariant = surfaceContainer, // Navigation bar background (ThemePrefWidget)
surfaceContainerLowest = surfaceContainer,
surfaceContainerLow = surfaceContainer,
@@ -1,22 +1,17 @@
package eu.kanade.presentation.theme.colorscheme
import android.annotation.SuppressLint
import android.app.UiModeManager
import android.app.WallpaperManager
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.ui.graphics.Color
import androidx.core.content.getSystemService
import com.google.android.material.color.utilities.Hct
import com.google.android.material.color.utilities.MaterialDynamicColors
import com.google.android.material.color.utilities.QuantizerCelebi
import com.google.android.material.color.utilities.SchemeContent
import com.google.android.material.color.utilities.Score
import com.materialkolor.PaletteStyle
import com.materialkolor.dynamiccolor.ColorSpec
import com.materialkolor.ktx.DynamicScheme
import com.materialkolor.toColorScheme
internal class MonetColorScheme(context: Context) : BaseColorScheme() {
@@ -28,7 +23,7 @@ internal class MonetColorScheme(context: Context) : BaseColorScheme() {
?.primaryColor
?.toArgb()
if (seed != null) {
MonetCompatColorScheme(context, seed)
MonetCompatColorScheme(Color(seed))
} else {
TachiyomiColorScheme
}
@@ -41,19 +36,6 @@ internal class MonetColorScheme(context: Context) : BaseColorScheme() {
override val 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)
@@ -62,64 +44,19 @@ private class MonetSystemColorScheme(context: Context) : BaseColorScheme() {
override val darkScheme = dynamicDarkColorScheme(context)
}
private class MonetCompatColorScheme(context: Context, seed: Int) : BaseColorScheme() {
override val lightScheme = generateColorSchemeFromSeed(context = context, seed = seed, dark = false)
override val darkScheme = generateColorSchemeFromSeed(context = context, seed = seed, dark = true)
internal class MonetCompatColorScheme(seed: Color) : BaseColorScheme() {
override val lightScheme = generateColorSchemeFromSeed(seed = seed, dark = false)
override val darkScheme = generateColorSchemeFromSeed(seed = seed, dark = true)
companion object {
private fun Int.toComposeColor(): Color = Color(this)
@SuppressLint("PrivateResource", "RestrictedApi")
private fun generateColorSchemeFromSeed(context: Context, seed: Int, dark: Boolean): ColorScheme {
val scheme = SchemeContent(
Hct.fromInt(seed),
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(),
fun generateColorSchemeFromSeed(seed: Color, dark: Boolean): ColorScheme {
return DynamicScheme(
seedColor = seed,
isDark = dark,
specVersion = ColorSpec.SpecVersion.SPEC_2025,
style = PaletteStyle.Expressive,
)
.toColorScheme(isAmoled = false)
}
}
}
@@ -1,5 +1,6 @@
package eu.kanade.presentation.track
import android.content.ClipData
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -47,6 +48,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -55,11 +57,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
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.input.ImeAction
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.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.util.system.openInBrowser
import kotlinx.coroutines.launch
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.components.material.Scaffold
@@ -240,7 +243,7 @@ private fun SearchResultItem(
onClick: () -> Unit,
) {
val context = LocalContext.current
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val clipboard: Clipboard = LocalClipboard.current
val focusManager = LocalFocusManager.current
val type = trackSearch.publishing_type.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 borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent
var dropDownMenuExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxWidth()
@@ -295,7 +299,13 @@ private fun SearchResultItem(
expanded = dropDownMenuExpanded,
onCollapseMenu = { dropDownMenuExpanded = false },
onCopyName = {
clipboardManager.setText(AnnotatedString(trackSearch.title))
scope.launch {
val clipEntry = ClipData.newPlainText(
trackSearch.title,
trackSearch.title,
).toClipEntry()
clipboard.setClipEntry(clipEntry)
}
},
onOpenInBrowser = {
val url = trackSearch.tracking_url
@@ -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.material.icons.Icons
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.Refresh
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.SnackbarHostState
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.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.theme.active
import java.time.LocalDate
import kotlin.time.Duration.Companion.seconds
@@ -57,8 +61,10 @@ fun UpdateScreen(
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
onMultiDeleteClicked: (List<UpdatesItem>) -> Unit,
onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit,
onUpdateSelected: (UpdatesItem, Boolean, Boolean) -> Unit,
onOpenChapter: (UpdatesItem) -> Unit,
onFilterClicked: () -> Unit,
hasActiveFilters: Boolean,
) {
BackHandler(enabled = state.selectionMode) {
onSelectAll(false)
@@ -69,6 +75,8 @@ fun UpdateScreen(
UpdatesAppBar(
onCalendarClicked = { onCalendarClicked() },
onUpdateLibrary = { onUpdateLibrary() },
onFilterClicked = { onFilterClicked() },
hasFilters = hasActiveFilters,
actionModeCounter = state.selected.size,
onSelectAll = { onSelectAll(true) },
onInvertSelection = { onInvertSelection() },
@@ -139,6 +147,8 @@ fun UpdateScreen(
private fun UpdatesAppBar(
onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Unit,
onFilterClicked: () -> Unit,
hasFilters: Boolean,
// For action mode
actionModeCounter: Int,
onSelectAll: () -> Unit,
@@ -153,6 +163,12 @@ private fun UpdatesAppBar(
actions = {
AppBarActions(
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(
title = stringResource(MR.strings.action_view_upcoming),
icon = Icons.Outlined.CalendarMonth,
@@ -72,7 +72,7 @@ internal fun LazyListScope.updatesUiItems(
// SY -->
preserveReadingPosition: Boolean,
// SY <--
onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit,
onUpdateSelected: (UpdatesItem, Boolean, Boolean) -> Unit,
onClickCover: (UpdatesItem) -> Unit,
onClickUpdate: (UpdatesItem) -> Unit,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
@@ -120,11 +120,11 @@ internal fun LazyListScope.updatesUiItems(
)
},
onLongClick = {
onUpdateSelected(updatesItem, !updatesItem.selected, true, true)
onUpdateSelected(updatesItem, !updatesItem.selected, true)
},
onClick = {
when {
selectionMode -> onUpdateSelected(updatesItem, !updatesItem.selected, true, false)
selectionMode -> onUpdateSelected(updatesItem, !updatesItem.selected, false)
else -> onClickUpdate(updatesItem)
}
},
@@ -9,21 +9,21 @@ import tachiyomi.domain.source.model.SourceNotInstalledException
import tachiyomi.i18n.MR
import java.net.UnknownHostException
context(Context)
context(context: Context)
val Throwable.formattedMessage: String
get() {
when (this) {
is HttpException -> return stringResource(MR.strings.exception_http, code)
is HttpException -> return context.stringResource(MR.strings.exception_http, code)
is UnknownHostException -> {
return if (!isOnline()) {
stringResource(MR.strings.exception_offline)
return if (!context.isOnline()) {
context.stringResource(MR.strings.exception_offline)
} 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 SourceNotInstalledException -> return stringResource(MR.strings.loader_not_implemented_error)
is NoResultsException -> return context.stringResource(MR.strings.no_results_found)
is SourceNotInstalledException -> return context.stringResource(MR.strings.loader_not_implemented_error)
}
return when (val className = this::class.simpleName) {
"Exception", "IOException" -> message ?: className
@@ -4,5 +4,7 @@ import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.ui.Modifier
// https://issuetracker.google.com/352584409
context(LazyItemScope)
fun Modifier.animateItemFastScroll() = this.animateItem(fadeInSpec = null, fadeOutSpec = null)
context(itemScope: LazyItemScope)
fun Modifier.animateItemFastScroll() = with(itemScope) {
this@animateItemFastScroll.animateItem(fadeInSpec = null, fadeOutSpec = null)
}
@@ -80,7 +80,7 @@ fun EhLoginWebViewScreen(
)
is LoadingState.Loading -> {
val animatedProgress by animateFloatAsState(
(loadingState as? LoadingState.Loading)?.progress ?: 1f,
loadingState.progress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "webview_loading",
)
@@ -273,7 +273,7 @@ fun WebViewScreenContent(
.align(Alignment.BottomCenter),
)
is LoadingState.Loading -> LinearProgressIndicator(
progress = { (loadingState as? LoadingState.Loading)?.progress ?: 1f },
progress = { loadingState.progress },
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
@@ -13,12 +13,18 @@ class BackupCategory(
@ProtoNumber(100) var flags: Long = 0,
// SY specific values
/*@ProtoNumber(600) var mangaOrder: List<Long> = emptyList(),*/
@ProtoNumber(601) var version: Long = 0,
@ProtoNumber(602) var uid: Long = 0,
@ProtoNumber(603) var lastModifiedAt: Long = 0,
) {
fun toCategory(id: Long) = Category(
id = id,
name = this@BackupCategory.name,
flags = this@BackupCategory.flags,
order = this@BackupCategory.order,
version = this@BackupCategory.version,
uid = this@BackupCategory.uid,
lastModifiedAt = this@BackupCategory.lastModifiedAt,
/*mangaOrder = this@BackupCategory.mangaOrder*/
)
}
@@ -29,5 +35,8 @@ val backupCategoryMapper = { category: Category ->
name = category.name,
order = category.order,
flags = category.flags,
version = category.version,
uid = category.uid,
lastModifiedAt = category.lastModifiedAt,
)
}
@@ -17,20 +17,63 @@ class CategoriesRestorer(
if (backupCategories.isNotEmpty()) {
val dbCategories = getCategories.await()
val dbCategoriesByName = dbCategories.associateBy { it.name }
// SY -->
val dbCategoriesByUid = dbCategories.associateBy { it.uid } // Map by UID
// SY <--
var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0
val categories = backupCategories
.sortedBy { it.order }
.map {
val dbCategory = dbCategoriesByName[it.name]
if (dbCategory != null) return@map dbCategory
// SY -->
.map { backupCategory ->
var dbCategory = if (backupCategory.uid != 0L) {
dbCategoriesByUid[backupCategory.uid]
} else {
null
}
if (dbCategory == null) {
dbCategory = dbCategoriesByName[backupCategory.name]
}
if (dbCategory != null) {
handler.await {
categoriesQueries.update(
name = backupCategory.name,
order = backupCategory.order,
flags = backupCategory.flags,
version = backupCategory.version,
uid = if (backupCategory.uid != 0L) backupCategory.uid else dbCategory.uid,
last_modified_at = backupCategory.lastModifiedAt,
isSyncing = 1,
categoryId = dbCategory.id,
)
}
return@map dbCategory
}
val order = nextOrder++
handler.awaitOneExecutable {
categoriesQueries.insert(it.name, order, it.flags)
categoriesQueries.insert(
backupCategory.name,
order,
backupCategory.flags,
backupCategory.version,
backupCategory.uid,
backupCategory.lastModifiedAt,
)
categoriesQueries.selectLastInsertedRowId()
}
.let { id -> it.toCategory(id).copy(order = order) }
.let { id -> backupCategory.toCategory(id).copy(order = order) }
}
// SY <--
// SY -->
handler.await {
categoriesQueries.resetIsSyncing()
}
// SY <--
libraryPreferences.categorizedDisplaySettings().set(
(dbCategories + categories)
@@ -13,6 +13,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import logcat.LogPriority
import okhttp3.Response
@@ -63,17 +64,13 @@ class ChapterCache(
*/
private val cacheDir: File = diskCache.directory
/**
* Returns real size of directory.
*/
private val realSize: Long
get() = DiskUtil.getDirectorySize(cacheDir)
/**
* Returns real size of directory in human readable format.
*/
val readableSize: String
get() = Formatter.formatFileSize(context, realSize)
suspend fun getReadableSize(): String = withContext(Dispatchers.IO) {
val size = DiskUtil.getDirectorySize(cacheDir)
Formatter.formatFileSize(context, size)
}
// --> EH
// Cache size is in MB
@@ -12,14 +12,17 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
@@ -109,13 +112,19 @@ class DownloadCache(
ProtoBuf.decodeFromByteArray<RootDirectory>(it.readBytes())
}
rootDownloadsDir = diskCache
lastRenew = System.currentTimeMillis()
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to initialize from disk cache" }
diskCacheFile.delete()
}
}
sourceManager.catalogueSources
.map { sources -> sources.map { it.id }.toSet() }
.distinctUntilChanged()
.collect {
restartRenewal()
}
}
storageManager.changes
@@ -353,19 +362,34 @@ class DownloadCache(
notifyChanges()
}
fun invalidateCache() {
lastRenew = 0L
renewalJob?.cancel()
suspend fun invalidateCache() {
renewalJob?.cancelAndJoin()
diskCacheFile.delete()
renewCache()
lastRenew = 0L
renewCache(forceRenew = true)
}
/**
* Safely cancels any in-progress renewal job, resets the last-renew timestamp, and
* immediately starts a new renewal, bypassing the time-based throttle.
*/
private fun restartRenewal() {
renewalJob?.cancel()
lastRenew = 0L
renewCache(forceRenew = true)
}
/**
* Renews the downloads cache.
*
* @param forceRenew when `true`, the time-based throttle is bypassed. Use this after
* explicitly cancelling the previous job to avoid a race where the cancelled job's
* [invokeOnCompletion] handler sets [lastRenew] after the reset but before the new
* job's guard check.
*/
private fun renewCache() {
private fun renewCache(forceRenew: Boolean = false) {
// Avoid renewing cache if in the process nor too often
if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) {
if ((!forceRenew && lastRenew + renewInterval >= System.currentTimeMillis()) || renewalJob?.isActive == true) {
return
}
@@ -376,15 +400,14 @@ class DownloadCache(
// Try to wait until extensions and sources have loaded
// SY -->
var sources = emptyList<Source>()
withTimeoutOrNull(30.seconds) {
extensionManager.isInitialized.first { it }
sourceManager.isInitialized.first { it }
sources = getSources()
// SY <--
sourceManager.catalogueSources.first { it.isNotEmpty() }
// SY -->
}
// SY <--
val sources = getSources()
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
rootDownloadsDirMutex.withLock {
@@ -459,8 +482,9 @@ class DownloadCache(
private var updateDiskCacheJob: Job? = null
private fun updateDiskCache() {
updateDiskCacheJob?.cancel()
val previousJob = updateDiskCacheJob
updateDiskCacheJob = scope.launchIO {
previousJob?.cancelAndJoin()
delay(1000)
ensureActive()
val bytes = ProtoBuf.encodeToByteArray(rootDownloadsDir)
@@ -109,10 +109,10 @@ class DownloadManager(
return queueState.value.find { it.chapter.id == chapterId }
}
fun startDownloadNow(chapterId: Long) {
suspend fun startDownloadNow(chapterId: Long) {
val existingDownload = getQueuedDownloadOrNull(chapterId)
// If not in queue try to start a new download
val toAdd = existingDownload ?: runBlocking { Download.fromChapterId(chapterId) } ?: return
val toAdd = existingDownload ?: Download.fromChapterId(chapterId) ?: return
queueState.value.toMutableList().apply {
existingDownload?.let { remove(it) }
add(0, toAdd)
@@ -89,7 +89,7 @@ class DownloadStore(
/**
* Returns the list of downloads to restore. It should be called in a background thread.
*/
fun restore(): List<Download> {
suspend fun restore(): List<Download> {
val objs = preferences.all
.mapNotNull { it.value as? String }
.mapNotNull { deserialize(it) }
@@ -100,10 +100,10 @@ class DownloadStore(
val cachedManga = mutableMapOf<Long, Manga?>()
for ((mangaId, chapterId) in objs) {
val manga = cachedManga.getOrPut(mangaId) {
runBlocking { getManga.await(mangaId) }
getManga.await(mangaId)
} ?: continue
val source = sourceManager.get(manga.source) as? HttpSource ?: continue
val chapter = runBlocking { getChapter.await(chapterId) } ?: continue
val chapter = getChapter.await(chapterId) ?: continue
downloads.add(Download(source, manga, chapter))
}
}
@@ -121,9 +121,9 @@ class Downloader(
var isPaused: Boolean = false
init {
launchNow {
val chapters = async { store.restore() }
addAllToQueue(chapters.await())
scope.launch {
val chapters = store.restore()
addAllToQueue(chapters)
}
}
@@ -420,7 +420,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
is SourceNotInstalledException -> context.stringResource(
MR.strings.loader_not_implemented_error,
)
else -> e.message
}
failedUpdates.add(manga to errorMessage)
@@ -692,8 +691,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
}
return file
}
} catch (_: Exception) {
}
} catch (_: Exception) {}
return File("")
}
@@ -737,10 +735,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
const val KEY_GROUP_EXTRA = "group_extra"
// SY <--
fun cancelAllWorks(context: Context) {
context.workManager.cancelAllWorkByTag(TAG)
}
fun setupTask(
context: Context,
prefInterval: Int? = null,
@@ -754,16 +748,19 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
} else {
NetworkType.CONNECTED
}
val networkRequestBuilder = NetworkRequest.Builder()
if (DEVICE_ONLY_ON_WIFI in restrictions) {
networkRequestBuilder.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
}
if (DEVICE_NETWORK_NOT_METERED in restrictions) {
networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
val networkRequest = NetworkRequest.Builder().apply {
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
if (DEVICE_ONLY_ON_WIFI in restrictions) {
addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
}
if (DEVICE_NETWORK_NOT_METERED in restrictions) {
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
}
.build()
val constraints = Constraints.Builder()
// 'networkRequest' only applies to Android 9+, otherwise 'networkType' is used
.setRequiredNetworkRequest(networkRequestBuilder.build(), networkType)
.setRequiredNetworkRequest(networkRequest, networkType)
.setRequiresCharging(DEVICE_CHARGING in restrictions)
.setRequiresBatteryNotLow(true)
.build()
@@ -23,6 +23,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.runBlocking
import tachiyomi.core.common.Constants
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.domain.chapter.interactor.GetChapter
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.Chapter
@@ -84,11 +85,18 @@ class NotificationReceiver : BroadcastReceiver() {
ACTION_CANCEL_APP_UPDATE_DOWNLOAD -> cancelDownloadAppUpdate(context)
// Open reader activity
ACTION_OPEN_CHAPTER -> {
openChapter(
context,
intent.getLongExtra(EXTRA_MANGA_ID, -1),
intent.getLongExtra(EXTRA_CHAPTER_ID, -1),
)
val pendingResult = goAsync()
launchIO {
try {
openChapter(
context,
intent.getLongExtra(EXTRA_MANGA_ID, -1),
intent.getLongExtra(EXTRA_CHAPTER_ID, -1),
)
} finally {
pendingResult.finish()
}
}
}
// Mark updated manga chapters as read
ACTION_MARK_AS_READ -> {
@@ -153,16 +161,18 @@ class NotificationReceiver : BroadcastReceiver() {
* @param mangaId id of manga
* @param chapterId id of chapter
*/
private fun openChapter(context: Context, mangaId: Long, chapterId: Long) {
val manga = runBlocking { getManga.await(mangaId) }
val chapter = runBlocking { getChapter.await(chapterId) }
if (manga != null && chapter != null) {
val intent = ReaderActivity.newIntent(context, manga.id, chapter.id).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
private suspend fun openChapter(context: Context, mangaId: Long, chapterId: Long) {
val manga = getManga.await(mangaId)
val chapter = getChapter.await(chapterId)
withUIContext {
if (manga != null && chapter != null) {
val intent = ReaderActivity.newIntent(context, manga.id, chapter.id).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
context.startActivity(intent)
} else {
context.toast(MR.strings.chapter_error)
}
context.startActivity(intent)
} else {
context.toast(MR.strings.chapter_error)
}
}
@@ -73,6 +73,7 @@ class SyncManager(
handler.await(inTransaction = true) {
mangasQueries.resetIsSyncing()
chaptersQueries.resetIsSyncing()
categoriesQueries.resetIsSyncing()
}
val syncOptions = syncPreferences.getSyncSettings()
@@ -156,7 +157,7 @@ class SyncManager(
}
// Stop the sync early if the remote backup is null or empty
if (remoteBackup.backupManga.size == 0) {
if (remoteBackup.backupManga.isEmpty() && remoteBackup.backupCategories.isEmpty() && remoteBackup.backupSources.isEmpty()) {
notifier.showSyncError("No data found on remote server.")
return
}
@@ -185,14 +186,40 @@ class SyncManager(
// SY <--
)
// It's local sync no need to restore data. (just update remote data)
if (filteredFavorites.isEmpty()) {
val hasMangaChanges = filteredFavorites.isNotEmpty()
val hasCategoryChanges = remoteBackup.backupCategories != backup.backupCategories
val hasSourceChanges = remoteBackup.backupSources != backup.backupSources
val hasPreferenceChanges = remoteBackup.backupPreferences != backup.backupPreferences
val hasSourcePreferenceChanges = remoteBackup.backupSourcePreferences != backup.backupSourcePreferences
val hasExtensionRepoChanges = remoteBackup.backupExtensionRepo != backup.backupExtensionRepo
val hasSavedSearchChanges = remoteBackup.backupSavedSearches != backup.backupSavedSearches
if (!hasMangaChanges && !hasCategoryChanges && !hasSourceChanges &&
!hasPreferenceChanges && !hasSourcePreferenceChanges &&
!hasExtensionRepoChanges && !hasSavedSearchChanges
) {
// update the sync timestamp
syncPreferences.lastSyncTimestamp().set(Date().time)
notifier.showSyncSuccess("Sync completed successfully")
return
}
if (syncOptions.categories) {
val mergedUids = newSyncData.backupCategories.map { it.uid }.toSet()
val mergedNames = newSyncData.backupCategories.map { it.name }.toSet()
val localCategories = getCategories.await().filterNot { it.id == 0L } // Exclude system category
val categoriesToDelete = localCategories.filter {
it.uid !in mergedUids && it.name !in mergedNames
}
if (categoriesToDelete.isNotEmpty()) {
handler.await(inTransaction = true) {
categoriesToDelete.forEach {
categoriesQueries.delete(it.id)
}
}
}
}
val backupUri = writeSyncDataToCache(context, newSyncData)
logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" }
if (backupUri != null) {
@@ -201,10 +228,14 @@ class SyncManager(
backupUri,
sync = true,
options = RestoreOptions(
appSettings = true,
sourceSettings = true,
libraryEntries = true,
extensionRepoSettings = true,
appSettings = syncOptions.appSettings,
sourceSettings = syncOptions.sourceSettings,
libraryEntries = syncOptions.libraryEntries,
categories = syncOptions.categories,
extensionRepoSettings = syncOptions.extensionRepoSettings,
// SY -->
savedSearches = syncOptions.savedSearches,
// SY <--
),
)
@@ -14,6 +14,8 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import logcat.LogPriority
import logcat.logcat
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Serializable
data class SyncData(
@@ -134,14 +136,31 @@ abstract class SyncService(
"Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}"
}
val lastSyncTime = syncPreferences.lastSyncTimestamp().get().milliseconds.inWholeSeconds
val syncOptions = syncPreferences.getSyncSettings()
val mergedList = (localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey ->
val local = localMangaMap[compositeKey]
val remote = remoteMangaMap[compositeKey]
// New version comparison logic
when {
local != null && remote == null -> updateCategories(local, localCategoriesMapByOrder)
local == null && remote != null -> updateCategories(remote, remoteCategoriesMapByOrder)
local != null && remote == null -> {
if (lastSyncTime == 0L || local.lastModifiedAt > lastSyncTime) {
updateCategories(local, localCategoriesMapByOrder)
} else {
logcat(LogPriority.DEBUG, logTag) { "Dropping local manga deleted on remote: ${local.title}." }
null
}
}
local == null && remote != null -> {
if (lastSyncTime == 0L || remote.lastModifiedAt > lastSyncTime) {
updateCategories(remote, remoteCategoriesMapByOrder)
} else {
logcat(LogPriority.DEBUG, logTag) { "Dropping deleted remote manga: ${remote.title}." }
null
}
}
local != null && remote != null -> {
// Compare versions to decide which manga to keep
if (local.version >= remote.version) {
@@ -149,7 +168,7 @@ abstract class SyncService(
"Keeping local version of ${local.title} with merged chapters."
}
updateCategories(
local.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
local.copy(chapters = mergeChapters(local.chapters, remote.chapters, lastSyncTime, syncOptions.chapters)),
localCategoriesMapByOrder,
)
} else {
@@ -157,7 +176,7 @@ abstract class SyncService(
"Keeping remote version of ${remote.title} with merged chapters."
}
updateCategories(
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters, lastSyncTime, syncOptions.chapters)),
remoteCategoriesMapByOrder,
)
}
@@ -197,9 +216,15 @@ abstract class SyncService(
private fun mergeChapters(
localChapters: List<BackupChapter>,
remoteChapters: List<BackupChapter>,
lastSyncTime: Long,
syncingChapters: Boolean,
): List<BackupChapter> {
val logTag = "MergeChapters"
if (!syncingChapters) {
return remoteChapters // If not syncing chapters, keep remote untouched
}
fun chapterCompositeKey(chapter: BackupChapter): String {
return "${chapter.url}|${chapter.name}|${chapter.chapterNumber}"
}
@@ -223,12 +248,22 @@ abstract class SyncService(
when {
localChapter != null && remoteChapter == null -> {
logcat(LogPriority.DEBUG, logTag) { "Keeping local chapter: ${localChapter.name}." }
localChapter
if (lastSyncTime == 0L || localChapter.lastModifiedAt > lastSyncTime) {
logcat(LogPriority.DEBUG, logTag) { "Keeping local chapter: ${localChapter.name}." }
localChapter
} else {
logcat(LogPriority.DEBUG, logTag) { "Dropping local chapter deleted on remote: ${localChapter.name}." }
null
}
}
localChapter == null && remoteChapter != null -> {
logcat(LogPriority.DEBUG, logTag) { "Taking remote chapter: ${remoteChapter.name}." }
remoteChapter
if (lastSyncTime == 0L || remoteChapter.lastModifiedAt > lastSyncTime) {
logcat(LogPriority.DEBUG, logTag) { "Taking remote chapter: ${remoteChapter.name}." }
remoteChapter
} else {
logcat(LogPriority.DEBUG, logTag) { "Dropping deleted remote chapter: ${remoteChapter.name}." }
null
}
}
localChapter != null && remoteChapter != null -> {
// Use version number to decide which chapter to keep
@@ -274,37 +309,70 @@ abstract class SyncService(
localCategoriesList: List<BackupCategory>?,
remoteCategoriesList: List<BackupCategory>?,
): List<BackupCategory> {
val logTag = "MergeCategories"
if (localCategoriesList == null) return remoteCategoriesList ?: emptyList()
if (remoteCategoriesList == null) return localCategoriesList
val localCategoriesMap = localCategoriesList.associateBy { it.name }
val remoteCategoriesMap = remoteCategoriesList.associateBy { it.name }
val mergedCategoriesMap = mutableMapOf<String, BackupCategory>()
val result = mutableListOf<BackupCategory>()
val processedLocals = mutableSetOf<BackupCategory>()
localCategoriesMap.forEach { (name, localCategory) ->
val remoteCategory = remoteCategoriesMap[name]
if (remoteCategory != null) {
// Compare and merge local and remote categories
val mergedCategory = if (localCategory.order > remoteCategory.order) {
localCategory
val localMapByUid = localCategoriesList.filter { it.uid != 0L }.associateBy { it.uid }
val localMapByName = localCategoriesList.associateBy { it.name }
val lastSyncTime = syncPreferences.lastSyncTimestamp().get()
remoteCategoriesList.forEach { remote ->
var localMatch: BackupCategory? = null
// 1. Try match by UID
if (remote.uid != 0L) {
localMatch = localMapByUid[remote.uid]
}
// 2. Try match by Name (fallback)
if (localMatch == null) {
localMatch = localMapByName[remote.name]
}
if (localMatch != null) {
processedLocals.add(localMatch)
// Conflict resolution
if (localMatch.version >= remote.version) {
logcat(LogPriority.DEBUG, logTag) { "Keeping local category: ${localMatch.name} (UID: ${localMatch.uid})" }
result.add(localMatch)
} else {
remoteCategory
logcat(LogPriority.DEBUG, logTag) { "Keeping remote category: ${remote.name} (UID: ${remote.uid})" }
// Preserve Local UID if Remote was 0
if (remote.uid == 0L) {
remote.uid = localMatch.uid
}
result.add(remote)
}
mergedCategoriesMap[name] = mergedCategory
} else {
// If the category is only in the local list, add it to the merged list
mergedCategoriesMap[name] = localCategory
val remoteModifiedTimeMillis = remote.lastModifiedAt.seconds.inWholeMilliseconds
if (lastSyncTime == 0L || remoteModifiedTimeMillis > lastSyncTime) {
logcat(LogPriority.DEBUG, logTag) { "Adding new remote category: ${remote.name} (UID: ${remote.uid})" }
result.add(remote)
} else {
logcat(LogPriority.DEBUG, logTag) { "Dropping deleted remote category: ${remote.name} (UID: ${remote.uid})" }
}
}
}
// Add any categories from the remote list that are not in the local list
remoteCategoriesMap.forEach { (name, remoteCategory) ->
if (!mergedCategoriesMap.containsKey(name)) {
mergedCategoriesMap[name] = remoteCategory
// Add remaining Local Categories
localCategoriesList.forEach { local ->
if (local !in processedLocals) {
val localModifiedTimeMillis = local.lastModifiedAt.seconds.inWholeMilliseconds
if (lastSyncTime == 0L || localModifiedTimeMillis > lastSyncTime) {
logcat(LogPriority.DEBUG, logTag) { "Keeping local only category: ${local.name} (UID: ${local.uid})" }
result.add(local)
} else {
logcat(LogPriority.DEBUG, logTag) { "Dropping local category deleted on remote: ${local.name} (UID: ${local.uid})" }
}
}
}
return mergedCategoriesMap.values.toList()
return result.sortedBy { it.order }
}
private fun mergeSourcesLists(
@@ -341,8 +409,8 @@ abstract class SyncService(
remoteSource
}
else -> {
logcat(LogPriority.DEBUG, logTag) { "Remote and local is not empty: $sourceId. Skipping." }
null
logcat(LogPriority.DEBUG, logTag) { "Remote and local have the same source ID: $sourceId. Keeping local." }
localSource
}
}
}
@@ -387,8 +455,8 @@ abstract class SyncService(
remotePreference
}
else -> {
logcat(LogPriority.DEBUG, logTag) { "Both remote and local have keys. Skipping: $key" }
null
logcat(LogPriority.DEBUG, logTag) { "Both remote and local have the same preference key: $key. Keeping local." }
localPreference
}
}
}
@@ -507,10 +575,8 @@ abstract class SyncService(
}
else -> {
logcat(LogPriority.DEBUG, logTag) {
"No saved search found for composite key: $compositeKey. Skipping."
}
null
logcat(LogPriority.DEBUG, logTag) { "Both remote and local have the same saved search key: $compositeKey. Keeping local." }
localSearch
}
}
}
@@ -5,8 +5,14 @@ import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.sync.SyncNotifier
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT
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.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
@@ -34,7 +40,26 @@ class SyncYomiSyncService(
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? {
reportSyncEvent(SyncEventStatus.SYNC_STARTED)
try {
val (remoteData, etag) = pullSyncData()
@@ -52,11 +77,23 @@ class SyncYomiSyncService(
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
} catch (e: Exception) {
if (e is CancellationException) {
reportSyncEvent(SyncEventStatus.SYNC_CANCELLED, e.message)
throw e
}
logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" }
notifier.showSyncError(e.message)
reportSyncEvent(SyncEventStatus.SYNC_ERROR, e.message)
return null
}
}
@@ -123,8 +160,8 @@ class SyncYomiSyncService(
/**
* Return true if update success
*/
private suspend fun pushSyncData(syncData: SyncData, eTag: String) {
val backup = syncData.backup ?: return
private suspend fun pushSyncData(syncData: SyncData, eTag: String): Boolean {
val backup = syncData.backup ?: return true
val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get()
@@ -163,13 +200,49 @@ class SyncYomiSyncService(
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
syncPreferences.lastSyncEtag().set(newETag)
logcat(LogPriority.DEBUG) { "SyncYomi sync completed" }
return true
} else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) {
// other clients updated remote data, will try next time
logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" }
return false
} else {
val responseBody = response.body.string()
notifier.showSyncError("Failed to upload sync data: $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}" }
}
}
}
}
@@ -45,9 +45,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
suspend fun addLibManga(track: Track): Track {
return withIOContext {
val query = """
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}private: Boolean) {
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status, private: ${'$'}private) {
val query = $$"""
|mutation AddManga($mangaId: Int, $progress: Int, $status: MediaListStatus, $private: Boolean) {
|SaveMediaListEntry (mediaId: $mangaId, progress: $progress, status: $status, private: $private) {
| id
| status
|}
@@ -82,14 +82,14 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
suspend fun updateLibManga(track: Track): Track {
return withIOContext {
val query = """
val query = $$"""
|mutation UpdateManga(
|${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}private: Boolean,
|${'$'}score: Int, ${'$'}startedAt: FuzzyDateInput, ${'$'}completedAt: FuzzyDateInput
|$listId: Int, $progress: Int, $status: MediaListStatus, $private: Boolean,
|$score: Int, $startedAt: FuzzyDateInput, $completedAt: FuzzyDateInput
|) {
|SaveMediaListEntry(
|id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, private: ${'$'}private,
|scoreRaw: ${'$'}score, startedAt: ${'$'}startedAt, completedAt: ${'$'}completedAt
|id: $listId, progress: $progress, status: $status, private: $private,
|scoreRaw: $score, startedAt: $startedAt, completedAt: $completedAt
|) {
|id
|status
@@ -118,9 +118,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
suspend fun deleteLibManga(track: DomainTrack) {
withIOContext {
val query = """
|mutation DeleteManga(${'$'}listId: Int) {
|DeleteMediaListEntry(id: ${'$'}listId) {
val query = $$"""
|mutation DeleteManga($listId: Int) {
|DeleteMediaListEntry(id: $listId) {
|deleted
|}
|}
@@ -139,10 +139,10 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
suspend fun search(search: String): List<TrackSearch> {
return withIOContext {
val query = """
|query Search(${'$'}query: String) {
val query = $$"""
|query Search($query: String) {
|Page (perPage: 50) {
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|media(search: $query, type: MANGA, format_not_in: [NOVEL]) {
|id
|staff {
|edges {
@@ -201,10 +201,10 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
suspend fun findLibManga(track: Track, userid: Int): Track? {
return withIOContext {
val query = """
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
val query = $$"""
|query ($id: Int!, $manga_id: Int!) {
|Page {
|mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
|mediaList(userId: $id, type: MANGA, mediaId: $manga_id) {
|id
|status
|scoreRaw: score(format: POINT_100)
@@ -113,7 +113,7 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
// Users can set a 'username' (not nickname) once which effectively
// replaces the stringified ID in certain queries.
// 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)
} catch (_: Throwable) {
logout()
@@ -137,7 +137,7 @@ class Kavita(id: Long) : BaseTracker(id, "Kavita"), EnhancedTracker {
}
authentication.apiUrl = prefApiUrl
authentication.jwtToken = token.toString()
authentication.jwtToken = token
}
authentications = oauth
}
@@ -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.MALSearchResult
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.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.PkceUtil
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.Headers
@@ -80,15 +77,15 @@ class MyAnimeListApi(
// MAL API throws a 400 when the query is over 64 characters...
.appendQueryParameter("q", query.take(64))
.appendQueryParameter("nsfw", "true")
.appendQueryParameter("fields", SEARCH_FIELDS)
.build()
with(json) {
authClient.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<MALSearchResult>()
.data
.map { async { getMangaDetails(it.node.id) } }
.awaitAll()
.filter { !it.publishing_type.contains("novel") }
.filter { !(it.node.mediaType.contains("novel")) }
.map { parseSearchItem(it.node) }
}
}
}
@@ -97,29 +94,13 @@ class MyAnimeListApi(
return withIOContext {
val url = "$BASE_API_URL/manga".toUri().buildUpon()
.appendPath(id.toString())
.appendQueryParameter(
"fields",
"id,title,synopsis,num_chapters,mean,main_picture,status,media_type,start_date",
)
.appendQueryParameter("fields", SEARCH_FIELDS)
.build()
with(json) {
authClient.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<MALManga>()
.let {
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 ?: ""
}
}
.let { parseSearchItem(it) }
}
}
}
@@ -183,8 +164,7 @@ class MyAnimeListApi(
val matches = myListSearchResult.data
.filter { it.node.title.contains(query, ignoreCase = true) }
.map { async { getMangaDetails(it.node.id) } }
.awaitAll()
.map { parseSearchItem(it.node) }
// Check next page if there's more
if (!myListSearchResult.paging.next.isNullOrBlank()) {
@@ -216,12 +196,12 @@ class MyAnimeListApi(
description = it.synopsis,
authors = it.authors
.filter { it.role == "Story" || it.role == "Story & Art" }
.map { "${it.node.firstName} ${it.node.lastName}".trim() }
.mapNotNull { it.node.getFullName() }
.joinToString(separator = ", ")
.ifEmpty { null },
artists = it.authors
.filter { it.role == "Art" || it.role == "Story & Art" }
.map { "${it.node.firstName} ${it.node.lastName}".trim() }
.mapNotNull { it.node.getFullName() }
.joinToString(separator = ", ")
.ifEmpty { null },
)
@@ -230,10 +210,10 @@ class MyAnimeListApi(
}
}
private suspend fun getListPage(offset: Int): MALUserSearchResult {
private suspend fun getListPage(offset: Int): MALSearchResult {
return withIOContext {
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())
if (offset > 0) {
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 {
return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L
}
@@ -273,7 +275,7 @@ class MyAnimeListApi(
return try {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(epochTime)
} catch (e: Exception) {
} catch (_: Exception) {
null
}
}
@@ -284,6 +286,9 @@ class MyAnimeListApi(
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 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 var codeVerifier: String = ""
@@ -18,8 +18,26 @@ data class MALManga(
val mediaType: String,
@SerialName("start_date")
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
data class MALMangaCovers(
val large: String = "",
@@ -33,19 +51,5 @@ data class MALMangaMetadata(
val synopsis: String?,
@SerialName("main_picture")
val covers: MALMangaCovers,
val authors: List<MALAuthor>,
)
@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,
val authors: List<MALAuthorNode>,
)
@@ -5,14 +5,15 @@ import kotlinx.serialization.Serializable
@Serializable
data class MALSearchResult(
val data: List<MALSearchResultNode>,
val paging: MALSearchPaging,
)
@Serializable
data class MALSearchResultNode(
val node: MALSearchResultItem,
val node: MALManga,
)
@Serializable
data class MALSearchResultItem(
val id: Int,
data class MALSearchPaging(
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,
)
@@ -37,14 +37,14 @@ class SuwayomiApi(private val trackId: Long) {
public fun sourcePreferences(): SharedPreferences = configurableSource.sourcePreferences()
suspend fun getTrackSearch(mangaId: Long): TrackSearch = withIOContext {
val query = """
|query GetManga(${'$'}mangaId: Int!) {
| manga(id: ${'$'}mangaId) {
val query = $$"""
|query GetManga($mangaId: Int!) {
| manga(id: $mangaId) {
| ...MangaFragment
| }
|}
|
|$MangaFragment
|$$MangaFragment
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
@@ -87,9 +87,9 @@ class SuwayomiApi(private val trackId: Long) {
// TODO: Include a filter on the chapter number here
// Below, we only consider older chapters; since v2.1.1985 filtering works properly in the query
val chaptersQuery = """
|query GetMangaUnreadChapters(${'$'}mangaId: Int!) {
| chapters(condition: {mangaId: ${'$'}mangaId, isRead: false}) {
val chaptersQuery = $$"""
|query GetMangaUnreadChapters($mangaId: Int!) {
| chapters(condition: {mangaId: $mangaId, isRead: false}) {
| nodes {
| id
| chapterNumber
@@ -115,24 +115,24 @@ class SuwayomiApi(private val trackId: Long) {
.data
.entry
.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 = if (deleteDownloadsOnServer) {
"""
|mutation MarkChaptersRead(${'$'}chapters: [Int!]!) {
| updateChapters(input: {ids: ${'$'}chapters, patch: {isRead: true}}) {
$$"""
|mutation MarkChaptersRead($chapters: [Int!]!) {
| updateChapters(input: {ids: $chapters, patch: {isRead: true}}) {
| __typename
| }
| deleteDownloadedChapters(input: {ids: ${'$'}chapters}) {
| deleteDownloadedChapters(input: {ids: $chapters}) {
| __typename
| }
|}
""".trimMargin()
} else {
"""
|mutation MarkChaptersRead(${'$'}chapters: [Int!]!) {
| updateChapters(input: {ids: ${'$'}chapters, patch: {isRead: true}}) {
$$"""
|mutation MarkChaptersRead($chapters: [Int!]!) {
| updateChapters(input: {ids: $chapters, patch: {isRead: true}}) {
| __typename
| }
|}
@@ -156,9 +156,9 @@ class SuwayomiApi(private val trackId: Long) {
.awaitSuccess()
}
val trackQuery = """
|mutation TrackManga(${'$'}mangaId: Int!) {
| trackProgress(input: {mangaId: ${'$'}mangaId}) {
val trackQuery = $$"""
|mutation TrackManga($mangaId: Int!) {
| trackProgress(input: {mangaId: $mangaId}) {
| __typename
| }
|}
@@ -1,12 +1,15 @@
package eu.kanade.tachiyomi.di
import android.app.Application
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDriver
import com.eygraber.sqldelight.androidx.driver.FileProvider
import eu.kanade.domain.track.store.DelayedTrackingStore
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.core.security.SecurityPreferences
@@ -25,7 +28,6 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.AndroidSourceManager
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import exh.eh.EHentaiUpdateHelper
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
@@ -52,10 +54,6 @@ import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
// SY -->
private const val LEGACY_DATABASE_NAME = "tachiyomi.db"
// SY <--
class AppModule(val app: Application) : InjektModule {
// SY -->
private val securityPreferences: SecurityPreferences by injectLazy()
@@ -68,40 +66,37 @@ class AppModule(val app: Application) : InjektModule {
// SY -->
if (securityPreferences.encryptDatabase().get()) {
System.loadLibrary("sqlcipher")
}
return@addSingletonFactory AndroidSqliteDriver(
schema = Database.Schema,
context = app,
name = CbzCrypto.DATABASE_NAME,
factory = SupportOpenHelperFactory(CbzCrypto.getDecryptedPasswordSql(), null, false, 25),
callback = object : AndroidSqliteDriver.Callback(Database.Schema) {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
setPragma(db, "foreign_keys = ON")
setPragma(db, "journal_mode = WAL")
setPragma(db, "synchronous = NORMAL")
}
private fun setPragma(db: SupportSQLiteDatabase, pragma: String) {
val cursor = db.query("PRAGMA $pragma")
cursor.moveToFirst()
cursor.close()
}
},
)
}
// SY <--
AndroidSqliteDriver(
AndroidxSqliteDriver(
driver = BundledSQLiteDriver(),
databaseType = AndroidxSqliteDatabaseType.FileProvider(app, "tachiyomi.db"),
schema = Database.Schema,
context = app,
// SY -->
name = if (securityPreferences.encryptDatabase().get()) {
CbzCrypto.DATABASE_NAME
} else {
LEGACY_DATABASE_NAME
},
factory = if (securityPreferences.encryptDatabase().get()) {
SupportOpenHelperFactory(CbzCrypto.getDecryptedPasswordSql(), null, false, 25)
} else if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Support database inspector in Android Studio
FrameworkSQLiteOpenHelperFactory()
} else {
RequerySQLiteOpenHelperFactory()
},
// SY <--
callback = object : AndroidSqliteDriver.Callback(Database.Schema) {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
setPragma(db, "foreign_keys = ON")
setPragma(db, "journal_mode = WAL")
setPragma(db, "synchronous = NORMAL")
}
private fun setPragma(db: SupportSQLiteDatabase, pragma: String) {
val cursor = db.query("PRAGMA $pragma")
cursor.moveToFirst()
cursor.close()
}
},
configuration = AndroidxSqliteConfiguration(
isForeignKeyConstraintsEnabled = true,
),
)
}
addSingletonFactory {
@@ -18,6 +18,7 @@ import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.storage.service.StoragePreferences
import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.api.InjektRegistrar
class PreferenceModule(val app: Application) : InjektModule {
@@ -44,6 +45,9 @@ class PreferenceModule(val app: Application) : InjektModule {
addSingletonFactory {
LibraryPreferences(get())
}
addSingletonFactory {
UpdatesPreferences(get())
}
addSingletonFactory {
ReaderPreferences(get())
}
@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.logcat
@@ -140,20 +141,22 @@ class ExtensionManager(
* Loads and registers the installed extensions.
*/
private fun initExtensions() {
val extensions = ExtensionLoader.loadExtensions(context)
scope.launch {
val extensions = ExtensionLoader.loadExtensions(context)
installedExtensionMapFlow.value = extensions
.filterIsInstance<LoadResult.Success>()
.associate { it.extension.pkgName to it.extension }
installedExtensionMapFlow.value = extensions
.filterIsInstance<LoadResult.Success>()
.associate { it.extension.pkgName to it.extension }
untrustedExtensionMapFlow.value = extensions
.filterIsInstance<LoadResult.Untrusted>()
.associate { it.extension.pkgName to it.extension }
// SY -->
.filterNotBlacklisted()
// SY <--
untrustedExtensionMapFlow.value = extensions
.filterIsInstance<LoadResult.Untrusted>()
.associate { it.extension.pkgName to it.extension }
// SY -->
.filterNotBlacklisted()
// SY <--
_isInitialized.value = true
_isInitialized.value = true
}
}
// EXH -->
@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.extension.installer
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
@@ -82,6 +83,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
inputStream.copyTo(outputStream)
session.fsync(outputStream)
}
service.contentResolver.delete(entry.uri, null, null)
val intentSender = PendingIntent.getBroadcast(
service,
@@ -89,6 +91,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
Intent(INSTALL_ACTION).setPackage(service.packageName),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
).intentSender
@SuppressLint("RequestInstallPackagesPolicy")
session.commit(intentSender)
}
} catch (e: Exception) {
@@ -109,9 +109,10 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
override fun processEntry(entry: Entry) {
super.processEntry(entry)
try {
shellInterface?.install(
service.contentResolver.openAssetFileDescriptor(entry.uri, "r"),
)
service.contentResolver.openAssetFileDescriptor(entry.uri, "r").use {
shellInterface?.install(it)
}
service.contentResolver.delete(entry.uri, null, null)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
continueQueue(InstallStep.Error)
@@ -124,7 +125,13 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
override fun onDestroy() {
Shizuku.removeBinderDeadListener(shizukuDeadListener)
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)
logcat { "ShizukuInstaller destroy" }
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) {
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
val extensionManager = Injekt.get<ExtensionManager>()
@@ -1,66 +1,48 @@
package eu.kanade.tachiyomi.extension.util
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
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.getSystemService
import androidx.core.net.toUri
import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.installer.Installer
import eu.kanade.tachiyomi.extension.model.Extension
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.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.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.util.lang.withUIContext
import okhttp3.OkHttpClient
import okhttp3.Request
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import kotlin.time.Duration.Companion.seconds
/**
* The installer which installs, updates and uninstalls the extensions.
*
* @param context The application 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>>()
internal class ExtensionInstaller(
private val context: Context,
) {
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 httpClient: OkHttpClient = Injekt.get<NetworkHelper>().client
/**
* Adds the given extension to the downloads queue and returns an observable containing its
* step in the installation process.
@@ -69,129 +51,86 @@ internal class ExtensionInstaller(private val context: Context) {
* @param extension The extension to install.
*/
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]
if (oldDownload != null) {
deleteDownload(pkgName)
}
val step = MutableStateFlow(InstallStep.Pending)
activeSteps[downloadId] = step
// Register the receiver after removing (and unregistering) the previous download
downloadReceiver.register()
val job = scope.launch {
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()
val request = DownloadManager.Request(downloadUri)
.setTitle(extension.name)
.setMimeType(APK_MIME)
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
if (!response.isSuccessful) {
throw Exception("Failed to download extension")
}
response.body.byteStream().use { input ->
tmpFile.outputStream().use { output ->
input.copyTo(output)
}
}
val id = downloadManager.enqueue(request)
activeDownloads[pkgName] = id
val downloadStateFlow = MutableStateFlow(InstallStep.Pending)
downloadsStateFlows[id] = downloadStateFlow
// Poll download status
val pollStatusFlow = downloadStatusFlow(id).mapNotNull { downloadStatus ->
// Map to our model
when (downloadStatus) {
DownloadManager.STATUS_PENDING -> InstallStep.Pending
DownloadManager.STATUS_RUNNING -> InstallStep.Downloading
else -> null
step.value = InstallStep.Installing
installApk(downloadId, tmpFile)
} catch (e: Exception) {
if (e is InterruptedException) {
// Canceled
} else {
logcat(LogPriority.ERROR, e)
step.value = InstallStep.Error
}
}
}
return merge(downloadStateFlow, pollStatusFlow).transformWhile {
emit(it)
// Stop when the application is installed or errors
!it.isCompleted()
}.onCompletion {
// Always notify on main thread
withUIContext {
// Always remove the download when unsubscribed
deleteDownload(pkgName)
activeJobs[extension.pkgName] = job
return step.asStateFlow()
.onCompletion {
activeJobs.remove(extension.pkgName)
activeSteps.remove(downloadId)
job.cancel()
}
}
}
/**
* 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.
*
* @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()) {
BasePreferences.ExtensionInstaller.LEGACY -> {
val intent = Intent(context, ExtensionInstallActivity::class.java)
.setDataAndType(uri, APK_MIME)
.setDataAndType(tempFile.getUriCompat(context), APK_MIME)
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(intent)
}
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 {
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
extensionManager.updateInstallStep(downloadId, InstallStep.Installed)
updateInstallStep(downloadId, InstallStep.Installed)
} else {
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
updateInstallStep(downloadId, InstallStep.Error)
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
updateInstallStep(downloadId, InstallStep.Error)
}
tempFile.delete()
}
else -> {
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
val intent = ExtensionInstallService.getIntent(
context,
downloadId,
tempFile.getUriCompat(context),
installer,
)
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.
*/
fun cancelInstall(pkgName: String) {
val downloadId = activeDownloads.remove(pkgName) ?: return
downloadManager.remove(downloadId)
Installer.cancelInstallQueue(context, downloadId)
activeJobs.remove(pkgName)?.cancel()
Installer.cancelInstallQueue(context, pkgName.hashCode().toLong())
}
/**
@@ -230,91 +168,11 @@ internal class ExtensionInstaller(private val context: Context) {
* @param step New install step.
*/
fun updateInstallStep(downloadId: Long, step: InstallStep) {
downloadsStateFlows[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))
}
}
}
activeSteps[downloadId]?.let { it.value = step }
}
companion object {
const val APK_MIME = "application/vnd.android.package-archive"
const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID"
const val FILE_SCHEME = "file://"
}
}
@@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
import eu.kanade.tachiyomi.util.system.ChildFirstPathClassLoader
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
@@ -114,7 +115,7 @@ internal object ExtensionLoader {
*
* @param context The application context.
*/
fun loadExtensions(context: Context): List<LoadResult> {
suspend fun loadExtensions(context: Context): List<LoadResult> {
val pkgManager = context.packageManager
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -160,11 +161,10 @@ internal object ExtensionLoader {
if (extPkgs.isEmpty()) return emptyList()
// Load each extension concurrently and wait for completion
return runBlocking {
val deferred = extPkgs.map {
return coroutineScope {
extPkgs.map {
async { loadExtension(context, it) }
}
deferred.awaitAll()
}.awaitAll()
}
}
@@ -29,6 +29,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.CacheControl
import okhttp3.Response
import tachiyomi.core.common.util.lang.withIOContext
class NHentai(delegate: HttpSource, val context: Context) :
DelegatedHttpSource(delegate),
@@ -70,12 +71,8 @@ class NHentai(delegate: HttpSource, val context: Context) :
}
override suspend fun parseIntoMetadata(metadata: NHentaiSearchMetadata, input: Response) {
val body = input.body.string()
val server = MEDIA_SERVER_REGEX.find(body)?.groupValues?.get(1)?.toInt() ?: 1
val json = GALLERY_JSON_REGEX.find(body)!!.groupValues[1].replace(
UNICODE_ESCAPE_REGEX,
) { it.groupValues[1].toInt(radix = 16).toChar().toString() }
val jsonResponse = jsonParser.decodeFromString<JsonResponse>(json)
if (nhConfig == null) getNhConfig()
val jsonResponse = jsonParser.decodeFromString<JsonResponse>(input.body.string())
with(metadata) {
nhId = jsonResponse.id
@@ -86,8 +83,6 @@ class NHentai(delegate: HttpSource, val context: Context) :
mediaId = jsonResponse.mediaId
mediaServer = server
jsonResponse.title?.let { title ->
japaneseTitle = title.japanese
shortTitle = title.pretty
@@ -96,15 +91,11 @@ class NHentai(delegate: HttpSource, val context: Context) :
preferredTitle = this@NHentai.preferredTitle
jsonResponse.images?.let { images ->
coverImageType = images.cover?.type
images.pages.mapNotNull {
it.type
}.let {
pageImageTypes = it
}
thumbnailImageType = images.thumbnail?.type
}
coverImageUrl =
jsonResponse.cover?.path?.let { "$thumbServer/$it" }
?: jsonResponse.thumbnail?.path?.let { "$thumbServer/$it" }
pageImagePreviewUrls = jsonResponse.pages.mapNotNull { it.thumbnail }
scanlator = jsonResponse.scanlator?.trimOrNull()
@@ -125,13 +116,22 @@ class NHentai(delegate: HttpSource, val context: Context) :
}
}
@Serializable
data class JsonConfig(
@SerialName("image_servers")
val imageServers: List<String> = emptyList(),
@SerialName("thumb_servers")
val thumbServers: List<String> = emptyList(),
)
@Serializable
data class JsonResponse(
val id: Long,
@SerialName("media_id")
val mediaId: String? = null,
val title: JsonTitle? = null,
val images: JsonImages? = null,
val cover: JsonPage? = null,
val thumbnail: JsonPage? = null,
val scanlator: String? = null,
@SerialName("upload_date")
val uploadDate: Long? = null,
@@ -140,6 +140,7 @@ class NHentai(delegate: HttpSource, val context: Context) :
val numPages: Int? = null,
@SerialName("num_favorites")
val numFavorites: Long? = null,
val pages: List<JsonPage> = emptyList(),
)
@Serializable
@@ -149,21 +150,12 @@ class NHentai(delegate: HttpSource, val context: Context) :
val pretty: String? = null,
)
@Serializable
data class JsonImages(
val pages: List<JsonPage> = emptyList(),
val cover: JsonPage? = null,
val thumbnail: JsonPage? = null,
)
@Serializable
data class JsonPage(
@SerialName("t")
val type: String? = null,
@SerialName("w")
val path: String? = null,
val width: Long? = null,
@SerialName("h")
val height: Long? = null,
val thumbnail: String? = null,
)
@Serializable
@@ -188,15 +180,16 @@ class NHentai(delegate: HttpSource, val context: Context) :
}
override suspend fun getPagePreviewList(manga: SManga, chapters: List<SChapter>, page: Int): PagePreviewPage {
if (nhConfig == null) getNhConfig()
val metadata = fetchOrLoadMetadata(manga.id()) {
client.newCall(mangaDetailsRequest(manga)).awaitSuccess()
}
return PagePreviewPage(
page,
metadata.pageImageTypes.mapIndexed { index, s ->
metadata.pageImagePreviewUrls.mapIndexed { index, path ->
PagePreviewInfo(
index + 1,
imageUrl = thumbnailUrlFromType(metadata.mediaId!!, metadata.mediaServer ?: 1, index + 1, s)!!,
imageUrl = "$thumbServer/$path",
)
},
false,
@@ -204,15 +197,24 @@ class NHentai(delegate: HttpSource, val context: Context) :
)
}
private fun thumbnailUrlFromType(
mediaId: String,
mediaServer: Int,
page: Int,
t: String,
) = NHentaiSearchMetadata.typeToExtension(t)?.let {
"https://t$mediaServer.nhentai.net/galleries/$mediaId/${page}t.$it"
var nhConfig: JsonConfig? = null
suspend fun getNhConfig() {
try {
val response =
withIOContext { client.newCall(GET("https://nhentai.net/api/v2/config", headers)).awaitSuccess() }
val body = response.body.string()
nhConfig = jsonParser.decodeFromString<JsonConfig>(body)
} catch (_: Exception) {
nhConfig = JsonConfig(
(1..4).map { n -> "https://i$n.nhentai.net" },
(1..4).map { n -> "https://t$n.nhentai.net" },
)
}
}
val thumbServer
get() = nhConfig?.thumbServers?.random()
override suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl?): Response {
return client.newCachelessCallWithProgress(
if (cacheControl != null) {
@@ -230,10 +232,6 @@ class NHentai(delegate: HttpSource, val context: Context) :
private val jsonParser = Json {
ignoreUnknownKeys = true
}
private val GALLERY_JSON_REGEX = Regex(".parse\\(\"(.*)\"\\);")
private val MEDIA_SERVER_REGEX = Regex("media_server\\s*:\\s*(\\d+)")
private val UNICODE_ESCAPE_REGEX = Regex("\\\\u([0-9a-fA-F]{4})")
private const val TITLE_PREF = "Display manga title as:"
}
}
@@ -50,79 +50,46 @@ class ExtensionsScreenModel(
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 {
combine(
state.map { it.searchQuery }.distinctUntilChanged().debounce(SEARCH_DEBOUNCE_MILLIS),
state.map { it.searchQuery }
.distinctUntilChanged()
.debounce(SEARCH_DEBOUNCE_MILLIS)
.map { searchQueryPredicate(it ?: "") },
currentDownloads,
getExtensions.subscribe(),
) { query, downloads, (_updates, _installed, _available, _untrusted) ->
val searchQuery = query ?: ""
val itemsGroups: ItemGroups = mutableMapOf()
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))
) { predicate, downloads, (_updates, _installed, _available, _untrusted) ->
buildMap {
val updates = _updates.filter(predicate).map(extensionMapper(downloads))
if (updates.isNotEmpty()) {
put(ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending), updates)
}
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 ->
state.copy(
isLoading = false,
items = it,
items = items,
)
}
}
@@ -139,6 +106,36 @@ class ExtensionsScreenModel(
.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?) {
mutableState.update {
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 {
sealed interface Header {
@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.browse.extension
import androidx.activity.compose.BackHandler
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -49,6 +50,10 @@ fun extensionsTab(
),
),
content = { contentPadding, _ ->
BackHandler(enabled = state.searchQuery != null) {
extensionsScreenModel.search(null)
}
ExtensionScreen(
state = state,
contentPadding = contentPadding,
@@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.FragmentManager
@@ -182,7 +181,9 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
fun getInstance(sourceId: Long): SourcePreferencesFragment {
return SourcePreferencesFragment().apply {
arguments = bundleOf(SOURCE_ID to sourceId)
arguments = Bundle().apply {
putLong(SOURCE_ID, sourceId)
}
}
}
}
@@ -9,11 +9,14 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.i18n.MR
import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
@@ -75,20 +77,22 @@ data class MigrateMangaScreen(
)
},
floatingActionButton = {
if (state.selectionMode) {
ExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) },
icon = {
Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null)
},
onClick = {
val selection = state.selection
screenModel.clearSelection()
navigator.push(MigrationConfigScreen(selection))
},
expanded = lazyListState.shouldExpandFAB(),
)
}
SmallExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) },
icon = {
Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null)
},
onClick = {
val selection = state.selection
screenModel.clearSelection()
navigator.push(MigrationConfigScreen(selection))
},
expanded = lazyListState.shouldExpandFAB(),
modifier = Modifier.animateFloatingActionButton(
visible = state.selectionMode,
alignment = Alignment.BottomEnd,
),
)
},
) { contentPadding ->
if (state.isEmpty) {
@@ -1,17 +1,20 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.Icon
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.LocalUriHandler
import cafe.adriel.voyager.core.model.rememberScreenModel
@@ -35,7 +38,6 @@ import mihon.presentation.core.util.collectAsLazyPagingItems
import tachiyomi.core.common.Constants
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
@@ -74,13 +76,15 @@ data class MigrateSourceSearchScreen(
)
},
floatingActionButton = {
AnimatedVisibility(visible = state.filters.isNotEmpty()) {
ExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.action_filter)) },
icon = { Icon(Icons.Outlined.FilterList, contentDescription = null) },
onClick = screenModel::openFilterSheet,
)
}
SmallExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.action_filter)) },
icon = { Icon(Icons.Outlined.FilterList, contentDescription = null) },
onClick = screenModel::openFilterSheet,
modifier = Modifier.animateFloatingActionButton(
visible = state.filters.isNotEmpty(),
alignment = Alignment.BottomEnd,
),
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues ->
@@ -1,9 +1,6 @@
package eu.kanade.tachiyomi.ui.download
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.layout.Box
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.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -56,7 +55,6 @@ import kotlinx.collections.immutable.persistentListOf
import tachiyomi.core.common.util.lang.launchUI
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.Pill
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
@@ -201,39 +199,37 @@ object DownloadQueueScreen : Screen() {
)
},
floatingActionButton = {
AnimatedVisibility(
visible = downloadList.isNotEmpty(),
enter = fadeIn(),
exit = fadeOut(),
) {
val isRunning by screenModel.isDownloaderRunning.collectAsState()
ExtendedFloatingActionButton(
text = {
val id = if (isRunning) {
MR.strings.action_pause
} else {
MR.strings.action_resume
}
Text(text = stringResource(id))
},
icon = {
val icon = if (isRunning) {
Icons.Outlined.Pause
} else {
Icons.Filled.PlayArrow
}
Icon(imageVector = icon, contentDescription = null)
},
onClick = {
if (isRunning) {
screenModel.pauseDownloads()
} else {
screenModel.startDownloads()
}
},
expanded = fabExpanded,
)
}
val isRunning by screenModel.isDownloaderRunning.collectAsState()
SmallExtendedFloatingActionButton(
text = {
val id = if (isRunning) {
MR.strings.action_pause
} else {
MR.strings.action_resume
}
Text(text = stringResource(id))
},
icon = {
val icon = if (isRunning) {
Icons.Outlined.Pause
} else {
Icons.Filled.PlayArrow
}
Icon(imageVector = icon, contentDescription = null)
},
onClick = {
if (isRunning) {
screenModel.pauseDownloads()
} else {
screenModel.startDownloads()
}
},
expanded = fabExpanded,
modifier = Modifier.animateFloatingActionButton(
visible = downloadList.isNotEmpty(),
alignment = Alignment.BottomEnd,
),
)
},
) { contentPadding ->
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.SetMangaCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.chapter.interactor.GetBookmarkedChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
import tachiyomi.domain.chapter.model.Chapter
@@ -122,6 +123,7 @@ class LibraryScreenModel(
private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
private val getNextChapters: GetNextChapters = Injekt.get(),
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
private val getBookmarkedChaptersByMangaId: GetBookmarkedChaptersByMangaId = Injekt.get(),
private val setReadStatus: SetReadStatus = Injekt.get(),
private val updateManga: UpdateManga = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(),
@@ -404,9 +406,7 @@ class LibraryScreenModel(
val filterFnTracking: (LibraryItem) -> Boolean = tracking@{ item ->
if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true
val mangaTracks = trackMap
.mapValues { entry -> entry.value.map { it.trackerId } }[item.id]
.orEmpty()
val mangaTracks = trackMap[item.id].orEmpty().map { it.trackerId }
val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks }
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
*/
fun performDownloadAction(action: DownloadAction) {
val mangas = state.value.selectedManga
val amount = when (action) {
DownloadAction.NEXT_1_CHAPTER -> 1
DownloadAction.NEXT_5_CHAPTERS -> 5
DownloadAction.NEXT_10_CHAPTERS -> 10
DownloadAction.NEXT_25_CHAPTERS -> 25
DownloadAction.UNREAD_CHAPTERS -> null
when (action) {
DownloadAction.NEXT_1_CHAPTER -> downloadNextChapters(1)
DownloadAction.NEXT_5_CHAPTERS -> downloadNextChapters(5)
DownloadAction.NEXT_10_CHAPTERS -> downloadNextChapters(10)
DownloadAction.NEXT_25_CHAPTERS -> downloadNextChapters(25)
DownloadAction.UNREAD_CHAPTERS -> downloadNextChapters(null)
DownloadAction.BOOKMARKED_CHAPTERS -> downloadBookmarkedChapters()
}
clearSelection()
}
private fun downloadNextChapters(amount: Int?) {
val mangas = state.value.selectedManga
screenModelScope.launchNonCancellable {
mangas.forEach { manga ->
// 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 -->
fun cleanTitles() {
state.value.selectedManga.fastFilter {
@@ -163,13 +163,6 @@ class MainActivity : BaseActivity() {
super.onCreate(savedInstanceState)
val didMigration = if (isLaunch) {
addAnalytics()
Migrator.awaitAndRelease()
} else {
false
}
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
if (!isTaskRoot) {
finish()
@@ -177,11 +170,17 @@ class MainActivity : BaseActivity() {
}
// SY -->
@Suppress("KotlinConstantConditions")
@Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
val hasDebugOverlay = (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "releaseTest")
// SY <--
setComposeContent {
var didMigration by remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(Unit) {
addAnalytics()
didMigration = Migrator.awaitAndRelease()
}
val context = LocalContext.current
var incognito by remember { mutableStateOf(getIncognitoState.await(null)) }
@@ -309,7 +308,7 @@ class MainActivity : BaseActivity() {
}
// SY <--
var showChangelog by remember { mutableStateOf(didMigration && !BuildConfig.DEBUG) }
var showChangelog by remember { mutableStateOf(didMigration == true && !BuildConfig.DEBUG) }
if (showChangelog) {
// SY -->
WhatsNewDialog(onDismissRequest = { showChangelog = false })
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.manga
import android.content.Context
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.systemBarsPadding
@@ -208,7 +207,9 @@ class MangaScreen(
previewsRowCount = successState.previewsRowCount,
onMigrateClicked = {
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)) },
// SY -->
onMetadataViewerClicked = { openMetadataViewer(navigator, successState.manga) },
@@ -403,12 +404,7 @@ class MangaScreen(
try {
getMangaUrl(manga_, source_)?.let { url ->
val intent = url.toUri().toShareIntent(context, type = "text/plain")
context.startActivity(
Intent.createChooser(
intent,
context.stringResource(MR.strings.action_share),
),
)
context.startActivity(intent)
}
} catch (e: Exception) {
context.toast(e.message)
@@ -1175,6 +1175,13 @@ class MangaScreenModel(
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(
chapters: List<Chapter>,
startNow: Boolean,
@@ -1237,6 +1244,7 @@ class MangaScreenModel(
DownloadAction.NEXT_10_CHAPTERS -> getUnreadChaptersSorted().take(10)
DownloadAction.NEXT_25_CHAPTERS -> getUnreadChaptersSorted().take(25)
DownloadAction.UNREAD_CHAPTERS -> getUnreadChapters()
DownloadAction.BOOKMARKED_CHAPTERS -> getBookmarkedChapters()
}
if (chaptersToDownload.isNotEmpty()) {
startDownload(chaptersToDownload, false)
@@ -1487,7 +1495,6 @@ class MangaScreenModel(
fun toggleSelection(
item: ChapterList.Item,
selected: Boolean,
userSelected: Boolean = false,
fromLongPress: Boolean = false,
) {
updateSuccessState { successState ->
@@ -1502,7 +1509,7 @@ class MangaScreenModel(
set(selectedIndex, selectedItem.copy(selected = selected))
selectedChapterIds.addOrRemove(item.id, selected)
if (selected && userSelected && fromLongPress) {
if (selected && fromLongPress) {
if (firstSelection) {
selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex
@@ -1528,7 +1535,7 @@ class MangaScreenModel(
}
}
}
} else if (userSelected && !fromLongPress) {
} else if (!fromLongPress) {
if (!selected) {
if (selectedIndex == selectedPositions[0]) {
selectedPositions[0] = indexOfFirst { it.selected }
@@ -32,6 +32,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -78,6 +79,7 @@ import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.AddToLibraryFirst
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Error
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Success
import eu.kanade.tachiyomi.ui.reader.chapter.ReaderChapterItem
import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
@@ -101,6 +103,8 @@ import exh.source.isEhBasedSource
import exh.ui.ifSourcesLoaded
import exh.util.defaultReaderType
import exh.util.mangaType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
@@ -121,6 +125,7 @@ import tachiyomi.core.common.i18n.pluralStringResource
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNonCancellable
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.source.service.SourceManager
@@ -394,28 +399,36 @@ class ReaderActivity : BaseActivity() {
is ReaderViewModel.Dialog.ChapterList -> {
var chapters by remember {
mutableStateOf(viewModel.getChapters().toImmutableList())
mutableStateOf<ImmutableList<ReaderChapterItem>?>(null)
}
LaunchedEffect(state.dialog) {
withIOContext {
chapters = viewModel.getChapters().toImmutableList()
}
}
if (chapters != null) {
ChapterListDialog(
onDismissRequest = onDismissRequest,
screenModel = settingsScreenModel,
chapters = chapters ?: persistentListOf(),
onClickChapter = {
viewModel.loadNewChapterFromDialog(it)
onDismissRequest()
},
onBookmark = { chapter ->
viewModel.toggleBookmark(chapter.id, !chapter.bookmark)
chapters = chapters?.map {
if (it.chapter.id == chapter.id) {
it.copy(chapter = chapter.copy(bookmark = !chapter.bookmark))
} else {
it
}
}?.toImmutableList()
},
state.dateRelativeTime,
)
}
ChapterListDialog(
onDismissRequest = onDismissRequest,
screenModel = settingsScreenModel,
chapters = chapters,
onClickChapter = {
viewModel.loadNewChapterFromDialog(it)
onDismissRequest()
},
onBookmark = { chapter ->
viewModel.toggleBookmark(chapter.id, !chapter.bookmark)
chapters = chapters.map {
if (it.chapter.id == chapter.id) {
it.copy(chapter = chapter.copy(bookmark = !chapter.bookmark))
} else {
it
}
}.toImmutableList()
},
state.dateRelativeTime,
)
}
// SY -->
ReaderViewModel.Dialog.AutoScrollHelp -> AlertDialog(
@@ -591,8 +604,9 @@ class ReaderActivity : BaseActivity() {
} else {
cropBorderContinuousVertical
}
val readerBottomButtons by readerPreferences.readerBottomButtons().changes().map { it.toImmutableSet() }
.collectAsState(persistentSetOf())
val readerBottomButtons by remember {
readerPreferences.readerBottomButtons().changes().map { it.toImmutableSet() }
}.collectAsState(persistentSetOf())
val dualPageSplitPaged by readerPreferences.dualPageSplitPaged().collectAsState()
val forceHorizontalSeekbar by readerPreferences.forceHorizontalSeekbar().collectAsState()
@@ -934,7 +948,7 @@ class ReaderActivity : BaseActivity() {
private fun shareChapter() {
assistUrl?.let {
val intent = it.toUri().toShareIntent(this, type = "text/plain")
startActivity(Intent.createChooser(intent, stringResource(MR.strings.action_share)))
startActivity(intent)
}
}
@@ -1139,7 +1153,7 @@ class ReaderActivity : BaseActivity() {
context = applicationContext,
message = /* SY --> */ text, // SY <--
)
startActivity(Intent.createChooser(intent, stringResource(MR.strings.action_share)))
startActivity(intent)
}
private fun onCopyImageResult(uri: Uri) {
@@ -59,7 +59,6 @@ import exh.source.isEhBasedManga
import exh.util.defaultReaderType
import exh.util.mangaType
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -183,19 +182,26 @@ class ReaderViewModel @JvmOverloads constructor(
private var chapterToDownload: Download? = null
private val unfilteredChapterList by lazy {
val manga = manga!!
runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = false) }
private var unfilteredChapterListCache: List<tachiyomi.domain.chapter.model.Chapter>? = null
private suspend fun getUnfilteredChapterList(): List<tachiyomi.domain.chapter.model.Chapter> {
if (unfilteredChapterListCache == null) {
val manga = manga!!
unfilteredChapterListCache = getChaptersByMangaId.await(manga.id, applyScanlatorFilter = false)
}
return unfilteredChapterListCache!!
}
/**
* Chapter list for the active manga. It's retrieved lazily and should be accessed for the first
* time in a background thread to avoid blocking the UI.
*/
private val chapterList by lazy {
private var chapterListCache: List<ReaderChapter>? = null
private suspend fun getChapterList(): List<ReaderChapter> {
chapterListCache?.let { return it }
val manga = manga!!
// SY -->
val (chapters, mangaMap) = runBlocking {
val (chapters, mangaMap) =
if (manga.source == MERGED_SOURCE_ID) {
getMergedChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) to
getMergedMangaById.await(manga.id)
@@ -203,7 +209,7 @@ class ReaderViewModel @JvmOverloads constructor(
} else {
getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) to null
}
}
fun isChapterDownloaded(chapter: Chapter): Boolean {
val chapterManga = mangaMap?.get(chapter.mangaId) ?: manga
return downloadManager.isChapterDownloaded(
@@ -253,7 +259,7 @@ class ReaderViewModel @JvmOverloads constructor(
else -> chapters
}
chaptersForReader
val result = chaptersForReader
.sortedWith(getChapterSort(manga, sortDescending = false))
.run {
if (readerPreferences.skipDupe().get()) {
@@ -271,6 +277,8 @@ class ReaderViewModel @JvmOverloads constructor(
}
.map { it.toDbChapter() }
.map(::ReaderChapter)
chapterListCache = result
return result
}
private val incognitoMode: Boolean by lazy { getIncognitoState.await(manga?.source) }
@@ -409,7 +417,7 @@ class ReaderViewModel @JvmOverloads constructor(
loadChapter(
loader!!,
chapterList.first { chapterId == it.chapter.id },
getChapterList().first { chapterId == it.chapter.id },
/* SY --> */page, /* SY <-- */
)
Result.success(true)
@@ -427,10 +435,10 @@ class ReaderViewModel @JvmOverloads constructor(
}
// SY -->
fun getChapters(): List<ReaderChapterItem> {
suspend fun getChapters(): List<ReaderChapterItem> {
val currentChapter = getCurrentChapter()
return chapterList.map {
return getChapterList().map {
ReaderChapterItem(
chapter = it.chapter.toDomainChapter()!!,
manga = manga!!,
@@ -454,6 +462,7 @@ class ReaderViewModel @JvmOverloads constructor(
): ViewerChapters {
loader.loadChapter(chapter /* SY --> */, page/* SY <-- */)
val chapterList = getChapterList()
val chapterPos = chapterList.indexOf(chapter)
val newChapters = ViewerChapters(
chapter,
@@ -503,7 +512,7 @@ class ReaderViewModel @JvmOverloads constructor(
fun loadNewChapterFromDialog(chapter: Chapter) {
viewModelScope.launchIO {
val newChapter = chapterList.firstOrNull { it.chapter.id == chapter.id } ?: return@launchIO
val newChapter = getChapterList().firstOrNull { it.chapter.id == chapter.id } ?: return@launchIO
loadAdjacent(newChapter)
}
}
@@ -655,7 +664,7 @@ class ReaderViewModel @JvmOverloads constructor(
* if setting is enabled and [currentChapter] is queued for 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))
}
}
@@ -665,11 +674,12 @@ class ReaderViewModel @JvmOverloads constructor(
* If both conditions are satisfied enqueues chapter for delete
* @param currentChapter current chapter, which is going to be marked as read.
*/
private fun deleteChapterIfNeeded(currentChapter: ReaderChapter) {
private suspend fun deleteChapterIfNeeded(currentChapter: ReaderChapter) {
val removeAfterReadSlots = downloadPreferences.removeAfterReadSlots().get()
if (removeAfterReadSlots == -1) return
// Determine which chapter should be deleted and enqueue
val chapterList = getChapterList()
val currentChapterPosition = chapterList.indexOf(currentChapter)
val chapterToDelete = chapterList.getOrNull(currentChapterPosition - removeAfterReadSlots)
@@ -739,7 +749,7 @@ class ReaderViewModel @JvmOverloads constructor(
// SY -->
if (manga?.isEhBasedManga() == true) {
viewModelScope.launchNonCancellable {
val chapterUpdates = unfilteredChapterList
val chapterUpdates = getUnfilteredChapterList()
.filter { it.sourceOrder > readerChapter.chapter.source_order }
.map { chapter ->
ChapterUpdate(
@@ -759,7 +769,7 @@ class ReaderViewModel @JvmOverloads constructor(
.contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING)
if (!markDuplicateAsRead) return
val duplicateUnreadChapters = unfilteredChapterList
val duplicateUnreadChapters = getUnfilteredChapterList()
.mapNotNull { chapter ->
if (
!chapter.read &&
@@ -774,7 +784,7 @@ class ReaderViewModel @JvmOverloads constructor(
updateChapter.awaitAll(duplicateUnreadChapters)
// SY -->
duplicateUnreadChapters.forEach { chapterUpdate ->
val chapter = unfilteredChapterList.first { it.id == chapterUpdate.id }
val chapter = getUnfilteredChapterList().first { it.id == chapterUpdate.id }
deleteChapterIfNeeded(ReaderChapter(chapter))
}
// SY <--
@@ -848,7 +858,7 @@ class ReaderViewModel @JvmOverloads constructor(
viewModelScope.launchNonCancellable {
updateChapter.await(
ChapterUpdate(
id = chapter.id!!.toLong(),
id = chapter.id!!,
bookmark = bookmarked,
),
)
@@ -863,9 +873,9 @@ class ReaderViewModel @JvmOverloads constructor(
// SY -->
fun toggleBookmark(chapterId: Long, bookmarked: Boolean) {
val chapter = chapterList.find { it.chapter.id == chapterId }?.chapter ?: return
chapter.bookmark = bookmarked
viewModelScope.launchNonCancellable {
val chapter = getChapterList().find { it.chapter.id == chapterId }?.chapter ?: return@launchNonCancellable
chapter.bookmark = bookmarked
updateChapter.await(
ChapterUpdate(
id = chapterId,
@@ -900,7 +910,7 @@ class ReaderViewModel @JvmOverloads constructor(
*/
fun setMangaReadingMode(readingMode: ReadingMode) {
val manga = manga ?: return
runBlocking(Dispatchers.IO) {
viewModelScope.launchIO {
setMangaViewerFlags.awaitSetReadingMode(manga.id, readingMode.flagValue.toLong())
val currChapters = state.value.viewerChapters
if (currChapters != null) {
@@ -69,15 +69,8 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
// Add previous chapter pages and transition.
if (chapters.prevChapter != null) {
// 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))
}
}
// Add previous chapter pages and transition
chapters.prevChapter?.pages?.let(newItems::addAll)
// 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) {
@@ -119,14 +112,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
}
}
if (chapters.nextChapter != null) {
// 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))
}
}
chapters.nextChapter?.pages?.let(newItems::addAll)
// Resets double-page splits, else insert pages get misplaced
subItems.filterIsInstance<InsertPage>().also { subItems.removeAll(it) }
@@ -43,14 +43,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
// Add previous chapter pages and transition.
if (chapters.prevChapter != null) {
// 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))
}
}
chapters.prevChapter?.pages?.let(newItems::addAll)
// 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) {
@@ -70,14 +63,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
}
if (chapters.nextChapter != null) {
// 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))
}
}
chapters.nextChapter?.pages?.let(newItems::addAll)
updateItems(newItems)
}
@@ -4,6 +4,7 @@ import android.app.Application
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.ui.util.fastFilter
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.core.preference.asState
@@ -30,11 +31,16 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
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.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNonCancellable
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.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.model.applyFilter
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.domain.updates.interactor.GetUpdates
import tachiyomi.domain.updates.model.UpdatesWithRelations
import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.ZonedDateTime
@@ -60,6 +68,7 @@ class UpdatesScreenModel(
private val getManga: GetManga = Injekt.get(),
private val getChapter: GetChapter = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val updatesPreferences: UpdatesPreferences = Injekt.get(),
val snackbarHostState: SnackbarHostState = SnackbarHostState(),
// SY -->
readerPreferences: ReaderPreferences = Injekt.get(),
@@ -85,19 +94,35 @@ class UpdatesScreenModel(
val limit = ZonedDateTime.now().minusMonths(3).toInstant()
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,
downloadManager.queueState,
) { updates, _, _ -> updates }
.catch {
logcat(LogPriority.ERROR, it)
_events.send(Event.InternalError)
}
.collectLatest { updates ->
// needed for Kotlin filters (downloaded)
getUpdatesItemPreferenceFlow().distinctUntilChanged { old, new ->
old.filterDownloaded == new.filterDownloaded
},
) { updates, _, _, itemPreferences ->
updates
.toUpdateItems()
.applyFilters(itemPreferences)
.toPersistentList()
}
.collectLatest { updateItems ->
mutableState.update {
it.copy(
isLoading = false,
items = updates.toUpdateItems(),
items = updateItems,
)
}
}
@@ -108,9 +133,43 @@ class UpdatesScreenModel(
.catch { logcat(LogPriority.ERROR, it) }
.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
.map { update ->
val activeDownload = downloadManager.getQueuedDownloadOrNull(update.chapterId)
@@ -135,7 +194,6 @@ class UpdatesScreenModel(
selected = update.chapterId in selectedChapterIds,
)
}
.toPersistentList()
}
fun updateLibrary(): Boolean {
@@ -193,7 +251,7 @@ class UpdatesScreenModel(
}
}
private fun startDownloadingNow(chapterId: Long) {
private suspend fun startDownloadingNow(chapterId: Long) {
downloadManager.startDownloadNow(chapterId)
}
@@ -279,7 +337,6 @@ class UpdatesScreenModel(
fun toggleSelection(
item: UpdatesItem,
selected: Boolean,
userSelected: Boolean = false,
fromLongPress: Boolean = false,
) {
mutableState.update { state ->
@@ -294,7 +351,7 @@ class UpdatesScreenModel(
set(selectedIndex, selectedItem.copy(selected = selected))
selectedChapterIds.addOrRemove(item.update.chapterId, selected)
if (selected && userSelected && fromLongPress) {
if (selected && fromLongPress) {
if (firstSelection) {
selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex
@@ -320,7 +377,7 @@ class UpdatesScreenModel(
}
}
}
} else if (userSelected && !fromLongPress) {
} else if (!fromLongPress) {
if (!selected) {
if (selectedIndex == selectedPositions[0]) {
selectedPositions[0] = indexOfFirst { it.selected }
@@ -373,9 +430,41 @@ class UpdatesScreenModel(
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
data class State(
val isLoading: Boolean = true,
val hasActiveFilters: Boolean = false,
val items: PersistentList<UpdatesItem> = persistentListOf(),
val dialog: Dialog? = null,
) {
@@ -399,6 +488,7 @@ class UpdatesScreenModel(
sealed interface Dialog {
data class DeleteConfirmation(val toDelete: List<UpdatesItem>) : Dialog
data object FilterSheet : Dialog
}
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
data class UpdatesItem(
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.presentation.updates.UpdateScreen
import eu.kanade.presentation.updates.UpdatesDeleteConfirmationDialog
import eu.kanade.presentation.updates.UpdatesFilterDialog
import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.download.DownloadQueueScreen
@@ -70,6 +71,7 @@ data object UpdatesTab : Tab {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { UpdatesScreenModel() }
val settingsScreenModel = rememberScreenModel { UpdatesSettingsScreenModel() }
val state by screenModel.state.collectAsState()
UpdateScreen(
@@ -93,6 +95,8 @@ data object UpdatesTab : Tab {
context.startActivity(intent)
},
onCalendarClicked = { navigator.push(UpcomingScreen()) },
onFilterClicked = screenModel::showFilterDialog,
hasActiveFilters = state.hasActiveFilters,
)
val onDismissDialog = { screenModel.setDialog(null) }
@@ -103,6 +107,12 @@ data object UpdatesTab : Tab {
onConfirm = { screenModel.deleteChapters(dialog.toDelete) },
)
}
is UpdatesScreenModel.Dialog.FilterSheet -> {
UpdatesFilterDialog(
onDismissRequest = onDismissDialog,
screenModel = settingsScreenModel,
)
}
null -> {}
}
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.util
import android.content.Context
import android.os.Build
import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.util.storage.getUriCompat
@@ -20,11 +21,12 @@ import java.time.ZoneId
class CrashLogUtil(
private val context: Context,
private val extensionManager: ExtensionManager = Injekt.get(),
private val preferences: BasePreferences = Injekt.get(),
) {
suspend fun dumpLogs(exception: Throwable? = null) = withNonCancellableContext {
try {
val file = context.createFileInCacheDir("tachiyomi_crash_logs.txt")
val file = context.createFileInCacheDir("tachiyomi_sy_crash_logs.txt")
file.appendText(getDebugInfo() + "\n\n")
getExtensionsInfo()?.let { file.appendText("$it\n\n") }
@@ -44,6 +46,7 @@ class CrashLogUtil(
App ID: ${BuildConfig.APPLICATION_ID}
App version: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}, ${BuildConfig.COMMIT_SHA}, ${BuildConfig.VERSION_CODE}, ${BuildConfig.BUILD_TIME})
Preview build: $syDebugVersion
Installation ID: ${preferences.installationId().get()}
Android version: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}; build ${Build.DISPLAY})
Device brand: ${Build.BRAND}
Device manufacturer: ${Build.MANUFACTURER}
@@ -5,6 +5,7 @@ package androidx.preference
/**
* Returns package-private [EditTextPreference.getOnBindEditTextListener]
*/
@Suppress("EXTENSION_SHADOWED_BY_MEMBER")
fun EditTextPreference.getOnBindEditTextListener(): EditTextPreference.OnBindEditTextListener? {
return onBindEditTextListener
}
@@ -3,7 +3,6 @@ package exh.ui.login
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.webkit.CookieManager
@@ -13,6 +12,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import eu.kanade.presentation.webview.EhLoginWebViewScreen
import eu.kanade.presentation.webview.components.IgneousDialog
import eu.kanade.tachiyomi.R
@@ -92,16 +92,32 @@ class EhLoginActivity : BaseActivity() {
private fun onPageFinished(view: WebView, url: String, customIgneous: String?) {
xLogD(url)
val parsedUrl = Uri.parse(url)
val parsedUrl = url.toUri()
if (parsedUrl.host.equals("forums.e-hentai.org", ignoreCase = true)) {
// Hide distracting content
if (!parsedUrl.queryParameterNames.contains(PARAM_SKIP_INJECT)) {
view.evaluateJavascript(HIDE_JS, null)
}
// Check login result
view.evaluateJavascript(
"""
(function() {
let html = document.documentElement.innerHTML;
return html.includes("/cdn-cgi/");
})();
""".trimIndent(),
) { result ->
val isCloudflareBlock = result == "true"
if (parsedUrl.getQueryParameter("code")?.toInt() != 0) {
if (checkLoginCookies(url)) view.loadUrl("https://exhentai.org/")
if (isCloudflareBlock) {
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)) {
// At ExHentai, check that everything worked out...
@@ -60,8 +60,8 @@ fun NHentaiDescription(state: State.Success, openMetadataViewer: () -> Unit) {
binding.pages.text = context.pluralStringResource(
SYMR.plurals.num_pages,
meta.pageImageTypes.size,
meta.pageImageTypes.size,
meta.pageImagePreviewUrls.size,
meta.pageImagePreviewUrls.size,
)
binding.pages.bindDrawable(context, R.drawable.ic_baseline_menu_book_24)
@@ -35,7 +35,7 @@ object Migrator {
result = null
}
fun awaitAndRelease(): Boolean = runBlocking {
await().also { release() }
suspend fun awaitAndRelease(): Boolean {
return await().also { release() }
}
}
@@ -0,0 +1,18 @@
package mihon.core.migration.migrations
import eu.kanade.domain.base.BasePreferences
import mihon.core.common.FeatureFlags
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import kotlin.uuid.ExperimentalUuidApi
class InstallationIdMigration : Migration {
override val version: Float = Migration.ALWAYS
@OptIn(ExperimentalUuidApi::class)
override suspend fun invoke(migrationContext: MigrationContext): Boolean {
val installationId = migrationContext.get<BasePreferences>()?.installationId() ?: return false
if (!installationId.isSet()) installationId.set(FeatureFlags.newInstallationId())
return true
}
}
@@ -47,4 +47,5 @@ val migrations: List<Migration>
TrustExtensionRepositoryMigration(),
CategoryPreferencesCleanupMigration(),
RemoveDuplicateReaderPreferenceMigration(),
InstallationIdMigration(),
)
@@ -39,6 +39,10 @@ class MoveSortingModeSettingsMigration : Migration {
categoryId = it.id,
flags = it.flags and 0b00111100L.inv(),
name = null,
version = it.version,
uid = it.uid,
last_modified_at = null,
isSyncing = null,
order = null,
)
}
@@ -73,6 +73,7 @@ class MigrateMangaUseCase(
updatedChapter = updatedChapter.copy(
dateFetch = prevChapter.dateFetch,
bookmark = prevChapter.bookmark,
lastPageRead = prevChapter.lastPageRead,
)
}
@@ -20,6 +20,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallExtendedFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -62,7 +63,6 @@ import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.FastScrollLazyColumn
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.padding
import tachiyomi.presentation.core.i18n.stringResource
@@ -144,7 +144,7 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
SmallExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) },
icon = { Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null) },
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 ->
val updatedSources = action(state.sources)
val includedSources = updatedSources.mapNotNull { if (!it.isSelected) null else it.id }
state.copy(sources = updatedSources.sortedWith(sourcesComparator(includedSources)))
}
if (save) saveSources()
saveSources()
}
private fun initSources() {
@@ -370,7 +370,9 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
}
.toList()
updateSources(save = false) { sources }
mutableState.update { state ->
state.copy(sources = sources.sortedWith(sourcesComparator(includedSources)))
}
}
fun toggleSelection(id: Long) {
@@ -145,7 +145,7 @@ private class MigrateDialogScreenModel(
}
val selectedFlags = sourcePreference.migrationFlags().get()
mutableState.update {
it.copy(
State(
current = current,
target = target,
applicableFlags = applicableFlags,
@@ -54,9 +54,11 @@ fun CalenderHeader(
}
Row {
IconButton(onClick = onPreviousClick) {
@Suppress("DEPRECATION")
Icon(Icons.Default.KeyboardArrowLeft, stringResource(MR.strings.upcoming_calendar_prev))
}
IconButton(onClick = onNextClick) {
@Suppress("DEPRECATION")
Icon(Icons.Default.KeyboardArrowRight, stringResource(MR.strings.upcoming_calendar_next))
}
}
@@ -14,7 +14,6 @@ import org.gradle.kotlin.dsl.provideDelegate
import org.gradle.kotlin.dsl.the
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.File
@@ -42,7 +41,7 @@ internal fun Project.configureAndroid(commonExtension: CommonExtension<*, *, *,
compilerOptions {
jvmTarget.set(AndroidConfig.JvmTarget)
freeCompilerArgs.addAll(
"-Xcontext-receivers",
"-Xcontext-parameters",
"-opt-in=kotlin.RequiresOptIn",
)
@@ -73,8 +72,6 @@ internal fun Project.configureCompose(commonExtension: CommonExtension<*, *, *,
}
extensions.configure<ComposeCompilerGradlePluginExtension> {
featureFlags.set(setOf(ComposeFeatureFlag.OptimizeNonSkippingGroups))
val enableMetrics = project.providers.gradleProperty("enableComposeCompilerMetrics").orNull.toBoolean()
val enableReports = project.providers.gradleProperty("enableComposeCompilerReports").orNull.toBoolean()
@@ -134,18 +134,18 @@ fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: Progre
return progressClient.newCall(request)
}
context(Json)
context(_: Json)
inline fun <reified T> Response.parseAs(): T {
return decodeFromJsonResponse(serializer(), this)
}
context(Json)
context(json: Json)
fun <T> decodeFromJsonResponse(
deserializer: DeserializationStrategy<T>,
response: Response,
): T {
return response.body.source().use {
decodeFromBufferedSource(deserializer, it)
json.decodeFromBufferedSource(deserializer, it)
}
}
@@ -73,7 +73,7 @@ class CloudflareInterceptor(
executor.execute {
webview = createWebView(originalRequest)
webview?.webViewClient = object : WebViewClientCompat() {
webview.webViewClient = object : WebViewClientCompat() {
override fun onPageFinished(view: WebView, url: String) {
fun isCloudFlareBypassed(): Boolean {
return cookieManager.get(origRequestUrl.toHttpUrl())
@@ -111,7 +111,7 @@ class CloudflareInterceptor(
}
}
webview?.loadUrl(origRequestUrl, headers)
webview.loadUrl(origRequestUrl, headers)
}
latch.awaitFor30Seconds()
@@ -1,12 +1,12 @@
package eu.kanade.tachiyomi.util.system
import android.annotation.TargetApi
import android.os.Build
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.annotation.RequiresApi
@Suppress("OverridingDeprecatedMember")
abstract class WebViewClientCompat : WebViewClient() {
@@ -28,7 +28,7 @@ abstract class WebViewClientCompat : WebViewClient() {
) {
}
@TargetApi(Build.VERSION_CODES.N)
@RequiresApi(Build.VERSION_CODES.N)
final override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
@@ -36,6 +36,7 @@ abstract class WebViewClientCompat : WebViewClient() {
return shouldOverrideUrlCompat(view, request.url.toString())
}
@Deprecated("shouldOverrideUrlLoading(WebView, WebResourceRequest)")
final override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
return shouldOverrideUrlCompat(view, url)
}
@@ -47,6 +48,7 @@ abstract class WebViewClientCompat : WebViewClient() {
return shouldInterceptRequestCompat(view, request.url.toString())
}
@Deprecated("shouldInterceptRequest(WebView, WebResourceRequest)")
final override fun shouldInterceptRequest(view: WebView, url: String): WebResourceResponse? {
return shouldInterceptRequestCompat(view, url)
}
@@ -65,6 +67,7 @@ abstract class WebViewClientCompat : WebViewClient() {
)
}
@Deprecated("onReceivedError(WebView, WebResourceRequest, WebResourceError)")
final override fun onReceivedError(
view: WebView,
errorCode: Int,
@@ -0,0 +1,12 @@
package mihon.core.common
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
object FeatureFlags {
@OptIn(ExperimentalUuidApi::class)
fun newInstallationId(): String {
return Uuid.random().toHexDashString()
}
}
@@ -7,6 +7,7 @@ import me.zhanghai.android.libarchive.ArchiveException
import java.io.InputStream
import java.nio.ByteBuffer
import kotlin.concurrent.Volatile
import mihon.core.common.archive.ArchiveEntry as MihonArchiveEntry
class ArchiveInputStream(
buffer: Long,
@@ -67,18 +68,20 @@ class ArchiveInputStream(
Archive.readFree(archive)
}
fun getNextEntry() = Archive.readNextHeader(archive).takeUnless { it == 0L }?.let { entry ->
val name = ArchiveEntry.pathnameUtf8(entry) ?: ArchiveEntry.pathname(entry)?.decodeToString() ?: return null
val isFile = ArchiveEntry.filetype(entry) == ArchiveEntry.AE_IFREG
// SY -->
val isEncrypted = ArchiveEntry.isEncrypted(entry)
// SY <--
ArchiveEntry(
name,
isFile,
fun getNextEntry(): MihonArchiveEntry? {
return Archive.readNextHeader(archive).takeUnless { it == 0L }?.let { entry ->
val name = ArchiveEntry.pathnameUtf8(entry) ?: ArchiveEntry.pathname(entry)?.decodeToString() ?: return null
val isFile = ArchiveEntry.filetype(entry) == ArchiveEntry.AE_IFREG
// SY -->
isEncrypted,
val isEncrypted = ArchiveEntry.isEncrypted(entry)
// SY <--
)
MihonArchiveEntry(
name,
isFile,
// SY -->
isEncrypted,
// SY <--
)
}
}
}

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