Compare commits

...

100 Commits

Author SHA1 Message Date
Jobobby04 7b0b879d65 Downgrade crashlytics plugin 2024-05-05 00:07:41 -04:00
Dexroneum 8a622f6c7d [RU] Translations (#1161)
* [RU] Translations

* [RU] Deleted unused strings
2024-05-04 23:39:57 -04:00
Jobobby04 253060a3bc Minor cleanup 2024-05-04 23:15:17 -04:00
Jobobby04 b6b33e8c00 Get new page url on image fetch failure for EHentai 2024-05-04 23:13:52 -04:00
Jobobby04 2e4f811090 Add getImageUrl override to EHentai 2024-05-04 23:13:26 -04:00
Jobobby04 215a1908f7 Include lewd filter in filter highlight 2024-05-04 23:13:06 -04:00
Jobobby04 082acf000c Fix Local Manga details edit 2024-05-04 23:12:45 -04:00
Jobobby04 2d1240b274 Update dependencies and cleanup 2024-05-04 18:41:03 -04:00
Jobobby04 1e98709cc3 Revert "Fix badge count getting cut off on tab title"
This reverts commit f9148c0c5e.
2024-05-04 18:12:54 -04:00
AntsyLich 5550ddad4e Bump compose version
Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
(cherry picked from commit e473c7f09fc009161145aca94bd70027f042b0bf)

# Conflicts:
#	app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchResultItems.kt
#	app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt
2024-05-04 17:07:27 -04:00
AntsyLich f9148c0c5e Fix badge count getting cut off on tab title
Fixes #335

(cherry picked from commit 263e467cdeb948b8f3679e2ea0282a291cf2f131)
2024-05-04 16:57:20 -04:00
Radon Rosborough 089e6268e7 Massively improve findFile performance (#728)
* Massively improve findFile performance

* Update libs.versions.toml

---------

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit 7ec2108812fbe0483111dbe996e29e5a621b583a)
2024-05-04 16:56:45 -04:00
AntsyLich 712cd1493f Address firebase ktx module deprecation
(cherry picked from commit 28dca3b7b818ad095008e7cd49ec07a82b0ebcad)

# Conflicts:
#	gradle/libs.versions.toml
2024-05-04 16:52:36 -04:00
AntsyLich bbc8adc3e8 Trust extension by repo (#570)
(cherry picked from commit 70cd688ac245a70a3146e2ac7374f24b0c3453ab)
2024-05-04 16:51:47 -04:00
AntsyLich 077b673c0a Fix some extension related issue and cleanups
- Extension being marked as not installed instead of untrusted after updating with private installer
- Extension update counter not updating due to extension being marked as untrusted
- Minimize `Key "extension-XXX-YYY" was already used` crash

(cherry picked from commit 21145144cdf550aa775047603e06e261951ebc42)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
2024-05-04 16:51:26 -04:00
renovate[bot] 49eacf5178 fix(deps): update leakcanary to v2.14 (#715)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit fa6dba6cc76f0b08cbc9bf222b0e087f4fb16d76)

# Conflicts:
#	gradle/libs.versions.toml
2024-05-04 16:01:19 -04:00
renovate[bot] 98d1dddf4a fix(deps): update dependency com.android.tools.build:gradle to v8.4.0 (#753)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 8a51d56c594e1f7ae4ebc01fc6a639292dde78bd)
2024-05-04 16:00:27 -04:00
renovate[bot] 37a616f3db fix(deps): update dependency androidx.test.espresso:espresso-core to v3.6.0-alpha04 (#749)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit a2f7d47a0a65bf88ac609b2227d440a7a2f841bf)
2024-05-04 16:00:14 -04:00
renovate[bot] ad18696a1a fix(deps): update dependency androidx.core:core-ktx to v1.13.1 (#748)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit b720f34267e4111466cdabf3a298d006231e4b55)
2024-05-04 15:59:53 -04:00
renovate[bot] 34bb012a1c fix(deps): update dependency androidx.test.ext:junit-ktx to v1.2.0-alpha04 (#751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit c6a1412f18cb16f89c4ddeadb3448c141a49072e)
2024-05-04 15:59:43 -04:00
renovate[bot] 08c4989aa3 fix(deps): update aboutlib.version to v11.1.4 (#744)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 6290cf222df922240575e2199459ab7b707d6ae2)

# Conflicts:
#	gradle/libs.versions.toml
2024-05-04 15:59:38 -04:00
FooIbar 14dae420f5 Log app crash exceptions in dumped crash logs (#742)
(cherry picked from commit a3d438e2f5b427eb8b4c391ab9fe10c5a83baf29)
2024-05-04 15:59:04 -04:00
w 65ed3c5ae6 Update subsampling-scale-image-view (#687)
Update libs.versions.toml

(cherry picked from commit 80461d883f7d6ca2203ae7455223ff49e8ef96ab)
2024-05-04 15:58:49 -04:00
FooIbar 5ae3508665 Use Coil pipeline instead of SSIV for image decode (#692)
(cherry picked from commit c3e7bb12f4cccf42dd3ea169111c771876e640fe)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt
2024-05-04 15:58:41 -04:00
MajorTanya e32eb0e009 Add MyAnimeList issue autoclose (#703)
[skip ci] Add MyAnimeList issue autoclose

This rule is intended to automatically close issues that report
problems with linking MAL that would be solved with the standard
solution of updating & changing the default UA.

The RegEx might be too general, but there isn't any neat pattern in
the previously filed issues.

(cherry picked from commit 9a3ffe2ea6cbf7ef2c2966c304a54b715a5fa682)
2024-05-04 15:51:26 -04:00
renovate[bot] e0812ab5c8 fix(deps): update dependency androidx.compose.compiler:compiler to v1.5.12 (#685)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 213effa169e28e144f3e323290d865b02d0bf94b)
2024-05-04 15:51:13 -04:00
renovate[bot] df9f79c120 fix(deps): update dependency androidx.benchmark:benchmark-macro-junit4 to v1.2.4 (#684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 25570147a1ca8ac374a75d7f29cf105bd686954b)
2024-05-04 15:51:04 -04:00
renovate[bot] 990eb33b98 fix(deps): update dependency androidx.activity:activity-compose to v1.9.0 (#689)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 2ad462b4d882c4a03359092515aa6b8d3cb4fd5d)
2024-05-04 15:50:50 -04:00
renovate[bot] e1bab1172a fix(deps): update dependency androidx.core:core-ktx to v1.13.0 (#690)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 7fd8f653529b8e1488dd57c051000abf2a80ed12)
2024-05-04 15:50:41 -04:00
FooIbar aeeff72bed Use Okio instead of java.io for image processing (#691)
(cherry picked from commit b152e3881bffd9050a8a0ed4030823886e3fe04f)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/App.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt
#	core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt
2024-05-04 15:50:20 -04:00
FooIbar 5895e78b39 Use m3 ripple and clean up interactionSource usage (#675)
Also remove a leftover of scoped storage adaptation.

(cherry picked from commit f27ca3b1b2f92258c213bca6b27d8eff4c7363ad)

# Conflicts:
#	app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt
2024-05-04 15:05:16 -04:00
FooIbar b24719a3e9 Update compose bom and fix renovate config for it (#674)
(cherry picked from commit 843daa5304d0b1a93ba69f8cc69791e446a58596)

# Conflicts:
#	.github/renovate.json5
2024-05-04 15:04:40 -04:00
renovate[bot] d551619d9d fix(deps): update dependency com.google.firebase:firebase-analytics-ktx to v21.6.2 (#656)
Update dependency com.google.firebase:firebase-analytics-ktx to v21.6.2

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit f080a4937e61d3dde5473876c34db8f16844e30c)

# Conflicts:
#	gradle/libs.versions.toml
2024-05-04 15:04:12 -04:00
renovate[bot] 06ad6c2e16 Update aboutlib.version to v11.1.3 (#654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 4c43a0ef66e9c8a321fb745b860319aaa074c57f)

# Conflicts:
#	gradle/libs.versions.toml
2024-05-04 15:03:39 -04:00
renovate[bot] df7e470e08 Update dependency com.android.tools.build:gradle to v8.3.2 (#655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit ea0fe2414e1e30b6e82ddf65144035283b31a5c4)
2024-05-04 15:02:59 -04:00
Cologler 03f32ebffd fix: check order before restore from backup (#1156) 2024-04-14 21:24:38 -04:00
KaiserBh ed20d25452 feat: syncing etag and overall improvement. (#1155)
* chore: don't log the access token from google.

Signed-off-by: KaiserBh <kaiserbh@proton.me>

* chore: don't log the access token from google.

Signed-off-by: KaiserBh <kaiserbh@proton.me>

* chore: forgot to add sy stuff.

The customInfo and readEntries wasn't taken into account, so when it was disabled it will always sync it because it's true by default in BackupOptions.kt. Should be fixed and now it doesn't reset the check mark.

Signed-off-by: KaiserBh <kaiserbh@proton.me>

* fix: same device sync.

When same device is initiating the sync just update the remote that.

Signed-off-by: KaiserBh <kaiserbh@proton.me>

* refactor: throw early.

When there is network failure or any sort during downloading just throw exception and stop syncing.

Signed-off-by: KaiserBh <kaiserbh@proton.me>

* refactor(gdrive): stream the json.

People with over 3k library can't sync because we are hitting OOM ```java.util.concurrent.ExecutionException: java.lang.OutOfMemoryError: Failed to allocate a 370950192 byte allocation with 25165824 free bytes and 281MB until OOM, target footprint 333990992, growth limit 603979776```. This should fix that for them but only gdrive.

Signed-off-by: KaiserBh <kaiserbh@proton.me>

* feat: a demo for sync with new api

* refactor: perform early null checks

* feat: restore even if push failed

* feat: switch to protobuf

* chore: show error notification when sync fails.

Signed-off-by: KaiserBh <kaiserbh@proton.me>

* fix: update order by merge

* fix: call pushSyncData twice

---------

Signed-off-by: KaiserBh <kaiserbh@proton.me>
Co-authored-by: Cologler <skyoflw@gmail.com>
2024-04-14 19:50:48 -04:00
Shamicen 596a8d002f Safer password handling (#1146)
* no longer convert passwords to string

* also clear backing array of outputStream

* use fill and small refactor
2024-04-14 19:48:32 -04:00
Dexroneum 206d824ed2 [RU] Translations (#1129)
* [RU] Translations

* [RU] Translated the remaining lines
2024-04-14 19:48:03 -04:00
Howard Wu 97ed4e55ad Update Simplified Chinese Translation (#1126) 2024-04-14 19:47:39 -04:00
Jobobby04 739f7bc848 Move sync strings to SY files 2024-04-13 15:50:23 -04:00
AntsyLich e866e60b19 Revert "Update Scaffold fork (#10143)" + Cleanup
Causes delay of one frame before actual contentPadding is measured

This reverts commit ea15bc782a2cd603c78de7567a59e973dd50fd7f.

Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
(cherry picked from commit 56e66e041d22ebd680654df4aefa81578c0f5f11)
2024-04-13 12:46:19 -04:00
AntsyLich f135daeca5 MangaCoverFetcher: Small cleanups
Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
(cherry picked from commit 13656959ae0606736f6ca9eb62699dc23e467c2f)
2024-04-13 12:46:05 -04:00
AntsyLich d80c19eb03 Remove unused imports
(cherry picked from commit 20e4cb26d6c264b13edb357ecfa25a6db21f19b8)
2024-04-13 12:42:52 -04:00
AntsyLich 2d47147172 Rework buildSrc and remove usage of subprojects
(cherry picked from commit e448e40406e8d9916120a278e42829a6f1b25a7a)

# Conflicts:
#	app/build.gradle.kts
#	buildSrc/src/main/kotlin/AndroidConfig.kt
#	i18n/build.gradle.kts
#	source-api/build.gradle.kts
2024-04-13 12:42:20 -04:00
AntsyLich de3570107e Fix build time zone in about screen
And slight cleanup

(cherry picked from commit aed53d3bdc85ce0e899fbb90b9f9cad0f1b86480)
2024-04-13 12:15:20 -04:00
renovate[bot] 5480495619 fix(deps): update sqldelight to v2.0.2 (#544)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit d77f2f429d2603a5c2b805f2dc7255af41474cf8)
2024-04-13 11:59:22 -04:00
AntsyLich 694ef5f285 Disable mpp and agp compability warning
(cherry picked from commit c3fd2df6f55b70e49088f84209668c4530a9a9c6)
2024-04-13 11:59:14 -04:00
Weblate (bot) 472c97c580 Translations update from Hosted Weblate (#609)
* Translated using Weblate (Greek)

Currently translated at 99.8% (793 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/el/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/tr/

* Translated using Weblate (German)

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/de/

* Translated using Weblate (Persian)

Currently translated at 84.7% (673 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fa/

* Translated using Weblate (German)

Currently translated at 100.0% (794 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (Greek)

Currently translated at 100.0% (794 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/el/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (794 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pt_BR/

* Translated using Weblate (Galician)

Currently translated at 95.9% (762 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/gl/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ja/

* Translated using Weblate (Javanese)

Currently translated at 38.8% (7 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/jv/

* Translated using Weblate (Galician)

Currently translated at 96.5% (767 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/gl/

* Translated using Weblate (Galician)

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/gl/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (793 of 793 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/

* Translated using Weblate (Croatian)

Currently translated at 100.0% (793 of 793 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hr/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (793 of 793 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

* Translated using Weblate (Russian)

Currently translated at 100.0% (795 of 795 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (795 of 795 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (795 of 795 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (795 of 795 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Russian)

Currently translated at 99.7% (796 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/

* Translated using Weblate (Filipino)

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/

* Translated using Weblate (German)

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

* Translated using Weblate (Japanese)

Currently translated at 99.4% (794 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Czech)

Currently translated at 99.8% (797 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/

* Translated using Weblate (Italian)

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/it/

* Translated using Weblate (Nepali)

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ne/

* Translated using Weblate (Czech)

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/cs/

* Translated using Weblate (Italian)

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/it/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Japanese)

Currently translated at 99.7% (801 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Russian)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Filipino)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/

* Translated using Weblate (Japanese)

Currently translated at 99.7% (801 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (German)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Japanese)

Currently translated at 99.7% (801 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

* Translated using Weblate (Amharic)

Currently translated at 34.3% (276 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/am/

* Translated using Weblate (Arabic)

Currently translated at 98.5% (791 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ar/

* Translated using Weblate (Belarusian)

Currently translated at 42.0% (338 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/be/

* Translated using Weblate (Bulgarian)

Currently translated at 79.8% (641 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bg/

* Translated using Weblate (Bengali)

Currently translated at 79.2% (636 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/bn/

* Translated using Weblate (Catalan)

Currently translated at 98.5% (791 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ca/

* Translated using Weblate (Cebuano)

Currently translated at 55.0% (442 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ceb/

* Translated using Weblate (Czech)

Currently translated at 99.2% (797 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/

* Translated using Weblate (Chuvash)

Currently translated at 74.5% (599 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cv/

* Translated using Weblate (Danish)

Currently translated at 39.9% (321 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/da/

* Translated using Weblate (German)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (Greek)

Currently translated at 98.6% (792 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/el/

* Translated using Weblate (Esperanto)

Currently translated at 64.2% (516 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eo/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/

* Translated using Weblate (Basque)

Currently translated at 74.4% (598 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/eu/

* Translated using Weblate (Persian)

Currently translated at 83.5% (671 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fa/

* Translated using Weblate (Finnish)

Currently translated at 84.0% (675 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fi/

* Translated using Weblate (Filipino)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/

* Translated using Weblate (French)

Currently translated at 98.5% (791 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fr/

* Translated using Weblate (Galician)

Currently translated at 95.2% (765 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/gl/

* Translated using Weblate (Hebrew)

Currently translated at 89.7% (721 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/he/

* Translated using Weblate (Hindi)

Currently translated at 82.6% (664 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hi/

* Translated using Weblate (Croatian)

Currently translated at 98.7% (793 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hr/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Indonesian)

Currently translated at 98.6% (792 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/id/

* Translated using Weblate (Italian)

Currently translated at 99.3% (798 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/it/

* Translated using Weblate (Japanese)

Currently translated at 99.7% (801 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Javanese)

Currently translated at 38.3% (308 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/jv/

* Translated using Weblate (Georgian)

Currently translated at 52.5% (422 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ka/

* Translated using Weblate (Kazakh)

Currently translated at 86.1% (692 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/kk/

* Translated using Weblate (Khmer (Central))

Currently translated at 26.7% (215 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/km/

* Translated using Weblate (Kannada)

Currently translated at 62.2% (500 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/kn/

* Translated using Weblate (Korean)

Currently translated at 98.5% (791 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ko/

* Translated using Weblate (Lithuanian)

Currently translated at 84.9% (682 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/lt/

* Translated using Weblate (Latvian)

Currently translated at 93.3% (750 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/lv/

* Translated using Weblate (Marathi)

Currently translated at 26.6% (214 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/mr/

* Translated using Weblate (Malay)

Currently translated at 98.5% (791 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ms/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 98.5% (791 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/nb_NO/

* Translated using Weblate (Nepali)

Currently translated at 99.3% (798 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ne/

* Translated using Weblate (Dutch)

Currently translated at 92.9% (746 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/nl/

* Translated using Weblate (Norwegian Nynorsk)

Currently translated at 33.6% (270 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/nn/

* Translated using Weblate (Polish)

Currently translated at 98.6% (792 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pl/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.6% (792 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/

* Translated using Weblate (Portuguese)

Currently translated at 88.6% (712 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt/

* Translated using Weblate (Romanian)

Currently translated at 97.8% (786 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ro/

* Translated using Weblate (Russian)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Sanskrit)

Currently translated at 71.3% (573 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sa/

* Translated using Weblate (Yakut)

Currently translated at 51.3% (412 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sah/

* Translated using Weblate (Sardinian)

Currently translated at 93.3% (750 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sc/

* Translated using Weblate (Kurdish (Southern))

Currently translated at 29.8% (240 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sdh/

* Translated using Weblate (Slovak)

Currently translated at 78.7% (632 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sk/

* Translated using Weblate (Albanian)

Currently translated at 86.6% (696 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sq/

* Translated using Weblate (Serbian)

Currently translated at 98.6% (792 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sr/

* Translated using Weblate (Swedish)

Currently translated at 98.5% (791 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/sv/

* Translated using Weblate (Telugu)

Currently translated at 24.5% (197 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/te/

* Translated using Weblate (Thai)

Currently translated at 98.5% (791 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/th/

* Translated using Weblate (Turkish)

Currently translated at 98.5% (791 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/tr/

* Translated using Weblate (Ukrainian)

Currently translated at 98.5% (791 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/uk/

* Translated using Weblate (Uzbek)

Currently translated at 44.4% (357 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/uz/

* Translated using Weblate (Vietnamese)

Currently translated at 96.3% (774 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/vi/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/

---------

Co-authored-by: Syrodil Eventalious <giannis.yalanskyi@gmail.com>
Co-authored-by: NukeSource <dede48076@gmail.com>
Co-authored-by: Lyfja <45209212+lyfja@users.noreply.github.com>
Co-authored-by: Arash <ara.khoram95@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Mr. Fakezay <fakezaydev@gmail.com>
Co-authored-by: kevans <albapazpi@gmail.com>
Co-authored-by: TheKingTermux <achmadmaulana0233@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: B4LiN7 <B4LiN7@users.noreply.hosted.weblate.org>
Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Co-authored-by: gekka <1778962971@qq.com>
Co-authored-by: akir45 <akkn0708@gmail.com>
Co-authored-by: Matyáš Caras <matyas@caras.cafe>
Co-authored-by: Federico Pierantoni <federico.pieranton@gmail.com>
Co-authored-by: FateXBlood <zecrofelix@gmail.com>
(cherry picked from commit 34bf5c6f87d74df2dcc6d0f23f5a73425d2fd6ef)
2024-04-13 11:59:05 -04:00
Weblate (bot) 8b098b38f8 Translations update from Hosted Weblate (#508)
* Translated using Weblate (Greek)

Currently translated at 99.8% (793 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/el/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/tr/

* Translated using Weblate (German)

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/de/

* Translated using Weblate (Persian)

Currently translated at 84.7% (673 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fa/

* Translated using Weblate (German)

Currently translated at 100.0% (794 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (Greek)

Currently translated at 100.0% (794 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/el/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (794 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/pt_BR/

* Translated using Weblate (Galician)

Currently translated at 95.9% (762 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/gl/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/ja/

* Translated using Weblate (Javanese)

Currently translated at 38.8% (7 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/jv/

* Translated using Weblate (Galician)

Currently translated at 96.5% (767 of 794 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/gl/

* Translated using Weblate (Galician)

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/gl/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (793 of 793 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/

* Translated using Weblate (Croatian)

Currently translated at 100.0% (793 of 793 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hr/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (793 of 793 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

* Translated using Weblate (Russian)

Currently translated at 100.0% (795 of 795 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (795 of 795 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (795 of 795 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (795 of 795 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Russian)

Currently translated at 99.7% (796 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/

* Translated using Weblate (Filipino)

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/

* Translated using Weblate (German)

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

* Translated using Weblate (Japanese)

Currently translated at 99.4% (794 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Czech)

Currently translated at 99.8% (797 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/cs/

* Translated using Weblate (Italian)

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/it/

* Translated using Weblate (Nepali)

Currently translated at 100.0% (798 of 798 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ne/

* Translated using Weblate (Czech)

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/cs/

* Translated using Weblate (Italian)

Currently translated at 100.0% (18 of 18 strings)

Translation: Mihon/Mihon Plurals
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon-plurals/it/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/es/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Japanese)

Currently translated at 99.7% (801 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Russian)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ru/

* Translated using Weblate (Filipino)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/fil/

* Translated using Weblate (Japanese)

Currently translated at 99.7% (801 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hans/

* Translated using Weblate (German)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/de/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/hu/

* Translated using Weblate (Japanese)

Currently translated at 99.7% (801 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/ja/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (803 of 803 strings)

Translation: Mihon/Mihon
Translate-URL: https://hosted.weblate.org/projects/mihon/mihon/zh_Hant/

---------

Co-authored-by: Syrodil Eventalious <giannis.yalanskyi@gmail.com>
Co-authored-by: NukeSource <dede48076@gmail.com>
Co-authored-by: Lyfja <45209212+lyfja@users.noreply.github.com>
Co-authored-by: Arash <ara.khoram95@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Mr. Fakezay <fakezaydev@gmail.com>
Co-authored-by: kevans <albapazpi@gmail.com>
Co-authored-by: TheKingTermux <achmadmaulana0233@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: B4LiN7 <B4LiN7@users.noreply.hosted.weblate.org>
Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Co-authored-by: gekka <1778962971@qq.com>
Co-authored-by: akir45 <akkn0708@gmail.com>
Co-authored-by: Matyáš Caras <matyas@caras.cafe>
Co-authored-by: Federico Pierantoni <federico.pieranton@gmail.com>
Co-authored-by: FateXBlood <zecrofelix@gmail.com>
(cherry picked from commit 6abaa47f5beacdc36a40cec98e3d7f02ac77f320)

# Conflicts:
#	i18n/src/commonMain/resources/MR/gl/strings.xml
#	i18n/src/commonMain/resources/MR/hu/strings.xml
#	i18n/src/commonMain/resources/MR/tr/plurals.xml
#	i18n/src/commonMain/resources/MR/zh-rTW/strings.xml
2024-04-13 11:58:56 -04:00
Maddie Witman 5e0585d724 Moves upcoming requirement from existence to current day or later. (#606)
* Moves upcoming requirement from existence to current day or later.

* Suppress millis conversion warning

(cherry picked from commit c9fddf9e388cff5e4071a89719825dee466deaf4)
2024-04-13 11:56:15 -04:00
MajorTanya 3e438a9e87 Add ProGuard rule to keep mihon namespace classes (#605)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit 555d2f834fb64df9a56fdf4f54d528c15fefa4cb)
2024-04-13 11:56:06 -04:00
Andreas 8046c1a540 Fix Migrator not doing work (#604)
(cherry picked from commit 6b3423a12b620dd2aae635ac4e859d00a4f62ceb)
2024-04-13 11:55:47 -04:00
renovate[bot] 1f3f6cd4df fix(deps): update detekt to v1.23.6 (#595)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 86fbd20665613cacb8d3c733aed9731792a07392)
2024-04-13 11:55:31 -04:00
renovate[bot] a62a5ed650 fix(deps): update aboutlib.version to v11.1.1 (#592)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit f1660beafc8303ba8d7ebfd160029e869a077f69)

# Conflicts:
#	gradle/libs.versions.toml
2024-04-13 11:55:24 -04:00
Maddie Witman a320903bc0 New Feature: Introduce Upcoming page to Mihon (#420)
* Work in progress upcoming feature

* Checkpointing WIP upcoming feature

* Functional Upcoming Screen

* Rename UpdateCalendar to UpdateUpcoming

* Converted Strings to resources

* Cleanup

* Fixed detekt issues

* Removed Link icon per @AntsyLich's suggestion.

* Detekt

* Fixed Calendar display on wide form factor devices

* Added Key to upcoming lazycolumn

* Updated tablet mode UI to support two column view

* Updated header creation logic

* Updated header creation logic... again

* Moved stray string to resources

* Fixed PR Comments and query refactor

* Tweaks to query, refactored to flow, comments on calendar

* Switched to Date Formatter

* Cleaned up date formatter

* More Refactor work

* Updated Calendar to support localized week formats

* Fixed year format

* Refactored Header animation

* Moved upcoming FAQ

* Completed YearMonth Migration

* Replaced currentYearMonth with delegate

* Even more cleanup

* cleaned up alignment modifiers

* Click Handler and other refactors

* Removed Wrapped Content Height/Size/extra clips

* Huge Refactor for CalendarDay

* Another cleanup attempt

* Migrated to new mihon.feature.* module pattern

* changed access modifier

* A Bunch of changes from the next round of reviews

* Cleanups

* Cleanup 2

---------

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

# Conflicts:
#	app/src/main/java/eu/kanade/domain/DomainModule.kt
#	gradle/libs.versions.toml
2024-03-28 17:35:51 -04:00
Andreas a6c4f01c74 Migrator improvements (#588)
(cherry picked from commit 0265c16eb239518d52b7e9fb4200b5b003418d5d)

# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
2024-03-28 17:26:09 -04:00
Jobobby04 a657c65261 Reduce build warnings 2024-03-28 17:18:38 -04:00
Jobobby04 5455daf96b Fix build 2024-03-28 17:13:03 -04:00
Jobobby04 d40bc2b41b Update Dependencies 2024-03-28 16:58:20 -04:00
Jobobby04 527ca85c39 Update WebView to support lower minSDK 2024-03-28 16:57:12 -04:00
Maddie Witman 189714eaf1 Migrated from Accompanist Webview to KevinZou WebView (#569)
* Migrated from Accompanist Webview to KevinZou WebView to preempt deprecation

* Removed old webview from version library

(cherry picked from commit ba9cfd867c028551c0b0740922c5130b14455c9f)
2024-03-28 16:54:28 -04:00
Andreas 90d5104bdc Rewrite Migrations (#577)
* Rewrite Migrations

* Fix Detekt errors

* Do migrations synchronous

* Filter and sort migrations

* Review changes

* Review changes 2

* Fix Detekt errors

(cherry picked from commit 666d6aa117756f0a9a57b31f91b7acb0ee5d7409)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/Migrations.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
2024-03-28 16:53:08 -04:00
Jobobby04 ceff887a10 Fix build 2024-03-27 17:23:51 -04:00
Jobobby04 2197bd0451 Revert "Migrated from Accompanist Webview to KevinZou WebView (#569)"
This reverts commit 268b483182.
2024-03-27 16:40:40 -04:00
AntsyLich 861a810961 Fix mishap in e020ae5ed558e80742ef0ad8bfa0f69af0959d5a
(cherry picked from commit 6965e59a643c67a2bf81b3c69ec70268e5da5797)
2024-03-27 16:28:52 -04:00
AntsyLich 81984c25df Fix more TypeReference issues and cleanup
(cherry picked from commit e020ae5ed558e80742ef0ad8bfa0f69af0959d5a)
2024-03-27 16:28:43 -04:00
MajorTanya b21d685a37 Fix extension repo crash with TypeReference issue (#574)
Fix by @AntsyLich.

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit 05071b420572a8fa93a55ab02c743c7da4fd3b3a)
2024-03-27 16:28:35 -04:00
MajorTanya fb4d9209f8 Fix repo name used for URL instead of baseUrl (#572)
* Fix repo name used for URL instead of baseUrl

This applies to both the item being shown in the screen as well as the
"copy to clipboard" button. Before, copying a repo url would return
"The Repo Name/index.json.min". This PR fixes that.

* Correct Misunderstanding

Passing the whole ExtensionRepo data class through now, using the name
for display purposes and the baseUrl for copying the URL.

(cherry picked from commit da20d00481f112802aade5d63fc1eca15c5496ba)
2024-03-27 16:28:19 -04:00
MajorTanya 9ee0034c9a Refactor the ExtensionRepoService to use DTOs (#573)
* Refactor the ExtensionRepoService to use DTOs

Slightly refactored the `ExtensionRepoService` so it uses a DTO with
`parseAs` to avoid parsing the JSON response by hand.

The default Json instance Injekt provides here has
`ignoreUnknownKeys` enabled, so the `ExtensionRepoMetaDto` only
specifies the meta key of the response content.

The extension function `toExtensionRepo` allows for mapping the new
DTO to the `domain` `ExtensionRepo` data class.

* Implement feedback

- Removed SerialName of the ExtensionRepoMetaDto property and renamed
it `meta`, same as the incoming attribute.
- Added a more general catch clause that also logs the occurring
Exception

Detekt likes to complain about TooGenericExceptionCaught, hence the
Suppress annotation on the function.

(cherry picked from commit 8c437ceecf3c5d8d944a70439d3549e21d751736)
2024-03-27 16:28:09 -04:00
Maddie Witman 268b483182 Migrated from Accompanist Webview to KevinZou WebView (#569)
* Migrated from Accompanist Webview to KevinZou WebView to preempt deprecation

* Removed old webview from version library

(cherry picked from commit ba9cfd867c028551c0b0740922c5130b14455c9f)
2024-03-27 16:27:47 -04:00
Maddie Witman 2af6e7be32 Grab extension repo detail from repo.json and include in DB (#506)
* WIP Extension Repo DB Support

* Wired in to extension screen, browse settings screen

* Detekt changes

* Ui tweaks and open in browser

* Migrate ExtensionRepos on Update

* Migration Cleanup

* Slight cleanup / error handling

* Update ExtensionRepo from Repo.json during extension search.
Added Manual refresh in extension repos page.

* Split repo fetching into separate API module, major refactor work

* Removed development strings

* Moved migration to #3

* Fixed rebase

* Detekt changes

* Added Replace Repository Dialog

* Cleanup, removed platform specific code, PR comments

* Removed extra function, reverted small change

* Detekt cleanup

* Apply suggestions from code review

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>

* Fixed error introduced in cleanup

* Tweak for multiline when

* Moved getCount() to flow

* changed getCount to non-suspend, used property delegation

* Apply suggestions from code review

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>

* Fixed formatting with updated comment string

* Big wave of PR comments, renaming/other tweaks

* onOpenWebsite changes

* onOpenWebsite changes

* trying to make single line

* Renamed ExtensionRepoApi.kt to ExtensionRepoService.kt

---------

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

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/Migrations.kt
#	app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
#	data/src/main/sqldelight/tachiyomi/migrations/3.sqm
2024-03-27 16:27:24 -04:00
renovate[bot] 3ecf86ae35 fix(deps): update aboutlib.version to v11 (major) (#473)
* fix(deps): update aboutlib.version to v11

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit e75488f5d98fb600408d065d1c5060520a8921a4)

# Conflicts:
#	gradle/libs.versions.toml
2024-03-27 16:23:15 -04:00
renovate[bot] 41bb0e08ba chore(deps): update dependency gradle to v8.7 (#567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 3838dbcf0805a9688ad6b0a03dc44f64de855e12)
2024-03-27 16:22:43 -04:00
renovate[bot] 0bb1eb2da1 chore(deps): update kotlin (#499)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit b3ca097e5a0a2feb582952f88a369f5a614c593f)
2024-03-27 16:22:34 -04:00
AntsyLich 919df9a7cf Add reference to compose compiler in compose.versions.toml so renovate can catch it
(cherry picked from commit 70c2443e82161378a3f653bac110767370b62c46)
2024-03-27 16:22:16 -04:00
Maddie Witman 2ea488bff5 Rework Duplicate Dialog and Allow Migration (#492)
* (Mostly) Working Manga screen migration via duplicate dialog

* Fully working migrate from Browse Search

* Small tweaks for Antsy

* Update app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt

* Update app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt

---------

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

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt
2024-03-27 16:21:57 -04:00
FooIbar ec30ccccc2 Fix webtoon last visible item position calculation (#562)
Covers the case when image height > screen height.

(cherry picked from commit 34930920a50be25ca05024200bf871512962e3d0)
2024-03-27 15:33:33 -04:00
renovate[bot] 780bdcbe55 fix(deps): update dependency com.google.firebase:firebase-analytics-ktx to v21.6.1 (#561)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 6682b5dd39ea46ecc57e040a558420d512893ed8)

# Conflicts:
#	gradle/libs.versions.toml
2024-03-27 15:33:26 -04:00
FooIbar a90bc4c7fa Fix recycled item's height being 0 in webtoon mode (#563)
Which will prevent the new image from being decoded until it's visible.

(cherry picked from commit ef6cad58fe0eeb7bfec7e8df33ada87946fa85d3)
2024-03-27 15:32:25 -04:00
AntsyLich 5e421c6f0e Address detekt issues
(cherry picked from commit 7e9340aa7f1021eabb4ae01eb0f4cbdfb6cc0589)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt
2024-03-27 15:32:17 -04:00
w 742fdc19ca Update image-decoder, color management (#523)
* Update image-decoder, color management

* move display profile pref

* remove true color pref

* Move Display Profile settings to a new section

* Partially revert "remove true color pref"

This partially reverts commit e1a75816950e100936699279e1618adb2fa341aa.

* Tweak label

---------

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

# Conflicts:
#	app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt
#	app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt
#	gradle/libs.versions.toml
2024-03-27 15:31:28 -04:00
renovate[bot] 74505565ef fix(deps): update dependency org.apache.commons:commons-compress to v1.26.1 (#502)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit a29870c01e3b79952b0882441e266009bf1119f2)
2024-03-27 15:28:21 -04:00
renovate[bot] f041ed5b2a fix(deps): update dependency com.android.tools.build:gradle to v8.3.1 (#543)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 583aa430ba9e8e7454076afc049f812bd3df21df)
2024-03-27 15:28:04 -04:00
MajorTanya 8762b20ab6 Switch to seconds for DATE_MODIFIED of saved pages (#552)
While most Android skins are seemingly able to handle the millisecond
format, the documentation technically specifies seconds. This seems to
be causing issues on Samsung devices using the Samsung Gallery app,
which renders the millisecond timestamps as if they were second ones,
causing the dates to be set at some point in the year 56189.

This change should fix that issue on Samsung devices and have no real
impact on the rest.

(cherry picked from commit 0ea0138a73466af3d371a48e344753844e9bc3d8)
2024-03-27 15:27:54 -04:00
AntsyLich 5a71889679 Fix regression from coil3 migration
Fixes #495

Co-authored-by: jobobby04 <17078382+jobobby04@users.noreply.github.com>
(cherry picked from commit 59bedb33ff59ad5db1df2e93567a2266fb63eacc)

# Conflicts:
#	core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt
2024-03-27 15:27:43 -04:00
renovate[bot] a4983eb004 fix(deps): update dependency io.kotest:kotest-assertions-core to v5.8.1 (#528)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit ebee2751109daf7be86d93736806374d6255be7c)
2024-03-27 15:27:07 -04:00
renovate[bot] 818bc7f75a fix(deps): update dependency com.squareup.okio:okio to v3.9.0 (#529)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 015d9b3bd057fe218d5bab77fa0736be5488eb4d)
2024-03-27 15:26:52 -04:00
AntsyLich 7de6fa8c23 Disable SerialVersionUIDInSerializableClass detekt rule
(cherry picked from commit bcdf17fe27dfb140e120ef2223aceb79668b8c16)
2024-03-27 15:26:08 -04:00
Jobobby04 edca9039e5 Fix sync stalled 2024-03-25 18:32:27 -04:00
Jobobby04 fb1649125c Actually fix animated images 2024-03-18 09:43:14 -04:00
Jobobby04 0767526f18 Revert "Re-Add Animated Image Decoders to Coil"
This reverts commit 5d1b1408eb.
2024-03-18 09:42:22 -04:00
Jobobby04 5d1b1408eb Re-Add Animated Image Decoders to Coil 2024-03-17 23:17:27 -04:00
Jobobby04 2f54f00bf7 Revert "Minor fix for history url"
This reverts commit 28edaca869.
2024-03-17 20:08:03 -04:00
ɴᴇᴋᴏ 87feb58055 Add files via upload (#1120) 2024-03-17 19:57:33 -04:00
Jobobby04 28edaca869 Minor fix for history url 2024-03-17 19:56:06 -04:00
Jobobby04 d14f012bbb Update firebase 2024-03-17 19:53:23 -04:00
Jobobby04 adc6bbf54f Minor doc fix 2024-03-17 19:53:12 -04:00
Jobobby04 2b064baca1 Update baseline 2024-03-17 19:52:59 -04:00
Jobobby04 983a80ba42 History url is not globally unique 2024-03-17 19:52:38 -04:00
333 changed files with 6378 additions and 4130 deletions
+6
View File
@@ -40,6 +40,12 @@ jobs:
"ignoreCase": true, "ignoreCase": true,
"labels": ["Cloudflare protected"], "labels": ["Cloudflare protected"],
"message": "Refer to the **Solving Cloudflare issues** section at https://mihon.app/docs/guides/troubleshooting/#cloudflare. If it doesn't work, migrate to other sources or wait until they lower their protection." "message": "Refer to the **Solving Cloudflare issues** section at https://mihon.app/docs/guides/troubleshooting/#cloudflare. If it doesn't work, migrate to other sources or wait until they lower their protection."
},
{
"type": "both",
"regex": "^.*(myanimelist|mal).*$",
"ignoreCase": true,
"message": "For issues with linking MyAnimeList, please follow these steps:\n1. Update Mihon to version 0.16.4 or newer\n2. Change your default User-Agent (`More → Settings → Advanced → Default user agent string`)\n3. Close and restart Mihon\n4. Attempt to link MyAnimeList again\n\nIf you had MyAnimeList linked before, try to unlink it first before trying these steps."
} }
] ]
auto-close-ignore-label: do-not-autoclose auto-close-ignore-label: do-not-autoclose
+1 -1
View File
@@ -65,7 +65,7 @@ When creating a fork, remember to:
8. Click publish 8. Click publish
9. Go to API & Services -> Credentials 9. Go to API & Services -> Credentials
10. Click Create credentials -> Oauth client ID 10. Click Create credentials -> Oauth client ID
11. Select Android, give it a name, and set eu.kanade.google.oauth as the package name 11. Select Android, give it a name, and set `eu.kanade.google.oauth` as the package name
12. To get the SHA-1 key, run `keytool -printcert -jarfile app-standard-universal-release.apk` on your apk, and copy the listed SHA-1 12. To get the SHA-1 key, run `keytool -printcert -jarfile app-standard-universal-release.apk` on your apk, and copy the listed SHA-1
13. Expand advanced settings, and enable Custom URL scheme 13. Expand advanced settings, and enable Custom URL scheme
14. After that just download the json, name it to `client_secrets.json` and put it in `app/src/main/assets/` 14. After that just download the json, name it to `client_secrets.json` and put it in `app/src/main/assets/`
+11 -31
View File
@@ -1,9 +1,12 @@
import mihon.buildlogic.getBuildTime
import mihon.buildlogic.getCommitCount
import mihon.buildlogic.getGitSha
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
id("com.android.application") id("mihon.android.application")
id("mihon.android.application.compose")
id("com.mikepenz.aboutlibraries.plugin") id("com.mikepenz.aboutlibraries.plugin")
kotlin("android")
kotlin("plugin.parcelize") kotlin("plugin.parcelize")
kotlin("plugin.serialization") kotlin("plugin.serialization")
// id("com.github.zellius.shortcut-helper") // id("com.github.zellius.shortcut-helper")
@@ -26,7 +29,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "eu.kanade.tachiyomi.sy" applicationId = "eu.kanade.tachiyomi.sy"
versionCode = 66 versionCode = 67
versionName = "1.10.5" versionName = "1.10.5"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
@@ -120,7 +123,6 @@ android {
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
compose = true
buildConfig = true buildConfig = true
// Disable some unused things // Disable some unused things
@@ -133,10 +135,6 @@ android {
abortOnError = false abortOnError = false
checkReleaseBuilds = false checkReleaseBuilds = false
} }
composeOptions {
kotlinCompilerExtensionVersion = compose.versions.compiler.get()
}
} }
dependencies { dependencies {
@@ -154,7 +152,6 @@ dependencies {
implementation(projects.presentationWidget) implementation(projects.presentationWidget)
// Compose // Compose
implementation(platform(compose.bom))
implementation(compose.activity) implementation(compose.activity)
implementation(compose.foundation) implementation(compose.foundation)
implementation(compose.material3.core) implementation(compose.material3.core)
@@ -165,7 +162,6 @@ dependencies {
debugImplementation(compose.ui.tooling) debugImplementation(compose.ui.tooling)
implementation(compose.ui.tooling.preview) implementation(compose.ui.tooling.preview)
implementation(compose.ui.util) implementation(compose.ui.util)
implementation(compose.accompanist.webview)
implementation(compose.accompanist.systemuicontroller) implementation(compose.accompanist.systemuicontroller)
implementation(androidx.paging.runtime) implementation(androidx.paging.runtime)
@@ -247,6 +243,9 @@ dependencies {
implementation(libs.bundles.voyager) implementation(libs.bundles.voyager)
implementation(libs.compose.materialmotion) implementation(libs.compose.materialmotion)
implementation(libs.swipe) implementation(libs.swipe)
implementation(libs.compose.webview)
implementation(libs.compose.grid)
implementation(libs.google.api.services.drive) implementation(libs.google.api.services.drive)
implementation(libs.google.api.client.oauth) implementation(libs.google.api.client.oauth)
@@ -255,7 +254,6 @@ dependencies {
implementation(libs.logcat) implementation(libs.logcat)
// Crash reports/analytics // Crash reports/analytics
// implementation(libs.bundles.acra)
// "standardImplementation"(libs.firebase.analytics) // "standardImplementation"(libs.firebase.analytics)
// Shizuku // Shizuku
@@ -268,6 +266,8 @@ dependencies {
// debugImplementation(libs.leakcanary.android) // debugImplementation(libs.leakcanary.android)
implementation(libs.leakcanary.plumber) implementation(libs.leakcanary.plumber)
testImplementation(kotlinx.coroutines.test)
// SY --> // SY -->
// Text distance (EH) // Text distance (EH)
implementation(sylibs.simularity) implementation(sylibs.simularity)
@@ -314,31 +314,11 @@ tasks {
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi", "-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi", "-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi", "-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview", "-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlinx.coroutines.InternalCoroutinesApi", "-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi", "-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
) )
if (project.findProperty("tachiyomi.enableComposeCompilerMetrics") == "true") {
kotlinOptions.freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
project.layout.buildDirectory.dir("compose_metrics").get().asFile.absolutePath,
)
kotlinOptions.freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
project.layout.buildDirectory.dir("compose_metrics").get().asFile.absolutePath,
)
}
// https://developer.android.com/jetpack/androidx/releases/compose-compiler#1.5.9
kotlinOptions.freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:nonSkippingGroupOptimization=true",
)
} }
} }
+1
View File
@@ -2,6 +2,7 @@
-keep,allowoptimization class eu.kanade.** -keep,allowoptimization class eu.kanade.**
-keep,allowoptimization class tachiyomi.** -keep,allowoptimization class tachiyomi.**
-keep,allowoptimization class mihon.**
# Keep common dependencies used in extensions # Keep common dependencies used in extensions
-keep,allowoptimization class androidx.preference.** { public protected *; } -keep,allowoptimization class androidx.preference.** { public protected *; }
@@ -4,10 +4,7 @@ import eu.kanade.domain.chapter.interactor.GetAvailableScanlators
import eu.kanade.domain.chapter.interactor.SetReadStatus import eu.kanade.domain.chapter.interactor.SetReadStatus
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.download.interactor.DeleteDownload import eu.kanade.domain.download.interactor.DeleteDownload
import eu.kanade.domain.extension.interactor.CreateExtensionRepo
import eu.kanade.domain.extension.interactor.DeleteExtensionRepo
import eu.kanade.domain.extension.interactor.GetExtensionLanguages import eu.kanade.domain.extension.interactor.GetExtensionLanguages
import eu.kanade.domain.extension.interactor.GetExtensionRepos
import eu.kanade.domain.extension.interactor.GetExtensionSources import eu.kanade.domain.extension.interactor.GetExtensionSources
import eu.kanade.domain.extension.interactor.GetExtensionsByType import eu.kanade.domain.extension.interactor.GetExtensionsByType
import eu.kanade.domain.extension.interactor.TrustExtension import eu.kanade.domain.extension.interactor.TrustExtension
@@ -26,6 +23,16 @@ import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.domain.track.interactor.RefreshTracks import eu.kanade.domain.track.interactor.RefreshTracks
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.interactor.TrackChapter import eu.kanade.domain.track.interactor.TrackChapter
import mihon.data.repository.ExtensionRepoRepositoryImpl
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.data.category.CategoryRepositoryImpl import tachiyomi.data.category.CategoryRepositoryImpl
import tachiyomi.data.chapter.ChapterRepositoryImpl import tachiyomi.data.chapter.ChapterRepositoryImpl
import tachiyomi.data.history.HistoryRepositoryImpl import tachiyomi.data.history.HistoryRepositoryImpl
@@ -111,6 +118,7 @@ class DomainModule : InjektModule {
addFactory { GetMangaByUrlAndSourceId(get()) } addFactory { GetMangaByUrlAndSourceId(get()) }
addFactory { GetManga(get()) } addFactory { GetManga(get()) }
addFactory { GetNextChapters(get(), get(), get(), get()) } addFactory { GetNextChapters(get(), get(), get(), get()) }
addFactory { GetUpcomingManga(get()) }
addFactory { ResetViewerFlags(get()) } addFactory { ResetViewerFlags(get()) }
addFactory { SetMangaChapterFlags(get()) } addFactory { SetMangaChapterFlags(get()) }
addFactory { FetchInterval(get()) } addFactory { FetchInterval(get()) }
@@ -171,10 +179,15 @@ class DomainModule : InjektModule {
addFactory { ToggleLanguage(get()) } addFactory { ToggleLanguage(get()) }
addFactory { ToggleSource(get()) } addFactory { ToggleSource(get()) }
addFactory { ToggleSourcePin(get()) } addFactory { ToggleSourcePin(get()) }
addFactory { TrustExtension(get()) } addFactory { TrustExtension(get(), get()) }
addFactory { CreateExtensionRepo(get()) } addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) }
addFactory { ExtensionRepoService(get(), get()) }
addFactory { GetExtensionRepo(get()) }
addFactory { GetExtensionRepoCount(get()) }
addFactory { CreateExtensionRepo(get(), get()) }
addFactory { DeleteExtensionRepo(get()) } addFactory { DeleteExtensionRepo(get()) }
addFactory { GetExtensionRepos(get()) } addFactory { ReplaceExtensionRepo(get()) }
addFactory { UpdateExtensionRepo(get(), get()) }
} }
} }
@@ -2,8 +2,6 @@ package eu.kanade.domain.base
import android.content.Context import android.content.Context
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.util.system.isPreviewBuildType
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -22,8 +20,6 @@ class BasePreferences(
fun extensionInstaller() = ExtensionInstallerPreference(context, preferenceStore) fun extensionInstaller() = ExtensionInstallerPreference(context, preferenceStore)
fun acraEnabled() = preferenceStore.getBoolean("acra.enable", isPreviewBuildType || isReleaseBuildType)
fun shownOnboardingFlow() = preferenceStore.getBoolean(Preference.appStateKey("onboarding_complete"), false) fun shownOnboardingFlow() = preferenceStore.getBoolean(Preference.appStateKey("onboarding_complete"), false)
enum class ExtensionInstaller(val titleRes: StringResource, val requiresSystemPermission: Boolean) { enum class ExtensionInstaller(val titleRes: StringResource, val requiresSystemPermission: Boolean) {
@@ -32,4 +28,6 @@ class BasePreferences(
SHIZUKU(MR.strings.ext_installer_shizuku, false), SHIZUKU(MR.strings.ext_installer_shizuku, false),
PRIVATE(MR.strings.ext_installer_private, false), PRIVATE(MR.strings.ext_installer_private, false),
} }
fun displayProfile() = preferenceStore.getString("pref_display_profile_key", "")
} }
@@ -1,25 +0,0 @@
package eu.kanade.domain.extension.interactor
import eu.kanade.domain.source.service.SourcePreferences
import tachiyomi.core.common.preference.plusAssign
class CreateExtensionRepo(private val preferences: SourcePreferences) {
fun await(name: String): Result {
// Do not allow invalid formats
if (!name.matches(repoRegex)) {
return Result.InvalidUrl
}
preferences.extensionRepos() += name.removeSuffix("/index.min.json")
return Result.Success
}
sealed interface Result {
data object InvalidUrl : Result
data object Success : Result
}
}
private val repoRegex = """^https://.*/index\.min\.json$""".toRegex()
@@ -1,11 +0,0 @@
package eu.kanade.domain.extension.interactor
import eu.kanade.domain.source.service.SourcePreferences
import tachiyomi.core.common.preference.minusAssign
class DeleteExtensionRepo(private val preferences: SourcePreferences) {
fun await(repo: String) {
preferences.extensionRepos() -= repo
}
}
@@ -1,11 +0,0 @@
package eu.kanade.domain.extension.interactor
import eu.kanade.domain.source.service.SourcePreferences
import kotlinx.coroutines.flow.Flow
class GetExtensionRepos(private val preferences: SourcePreferences) {
fun subscribe(): Flow<Set<String>> {
return preferences.extensionRepos().changes()
}
}
@@ -20,7 +20,7 @@ class GetExtensionsByType(
extensionManager.installedExtensionsFlow, extensionManager.installedExtensionsFlow,
extensionManager.untrustedExtensionsFlow, extensionManager.untrustedExtensionsFlow,
extensionManager.availableExtensionsFlow, extensionManager.availableExtensionsFlow,
) { _activeLanguages, _installed, _untrusted, _available -> ) { enabledLanguages, _installed, _untrusted, _available ->
val (updates, installed) = _installed val (updates, installed) = _installed
.filter { (showNsfwSources || !it.isNsfw) } .filter { (showNsfwSources || !it.isNsfw) }
.sortedWith( .sortedWith(
@@ -41,9 +41,9 @@ class GetExtensionsByType(
} }
.flatMap { ext -> .flatMap { ext ->
if (ext.sources.isEmpty()) { if (ext.sources.isEmpty()) {
return@flatMap if (ext.lang in _activeLanguages) listOf(ext) else emptyList() return@flatMap if (ext.lang in enabledLanguages) listOf(ext) else emptyList()
} }
ext.sources.filter { it.lang in _activeLanguages } ext.sources.filter { it.lang in enabledLanguages }
.map { .map {
ext.copy( ext.copy(
name = it.name, name = it.name,
@@ -3,15 +3,18 @@ package eu.kanade.domain.extension.interactor
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import androidx.core.content.pm.PackageInfoCompat import androidx.core.content.pm.PackageInfoCompat
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import tachiyomi.core.common.preference.getAndSet import tachiyomi.core.common.preference.getAndSet
class TrustExtension( class TrustExtension(
private val extensionRepoRepository: ExtensionRepoRepository,
private val preferences: SourcePreferences, private val preferences: SourcePreferences,
) { ) {
fun isTrusted(pkgInfo: PackageInfo, signatureHash: String): Boolean { suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:$signatureHash" val trustedFingerprints = extensionRepoRepository.getAll().map { it.signingKeyFingerprint }.toHashSet()
return key in preferences.trustedExtensions().get() val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions().get()
} }
fun trust(pkgName: String, versionCode: Long, signatureHash: String) { fun trust(pkgName: String, versionCode: Long, signatureHash: String) {
@@ -19,9 +22,7 @@ class TrustExtension(
// Remove previously trusted versions // Remove previously trusted versions
val removed = exts.filterNot { it.startsWith("$pkgName:") }.toMutableSet() val removed = exts.filterNot { it.startsWith("$pkgName:") }.toMutableSet()
removed.also { removed.also { it += "$pkgName:$versionCode:$signatureHash" }
it += "$pkgName:$versionCode:$signatureHash"
}
} }
} }
@@ -13,6 +13,8 @@ class SyncPreferences(
fun clientAPIKey() = preferenceStore.getString("sync_client_api_key", "") fun clientAPIKey() = preferenceStore.getString("sync_client_api_key", "")
fun lastSyncTimestamp() = preferenceStore.getLong(Preference.appStateKey("last_sync_timestamp"), 0L) fun lastSyncTimestamp() = preferenceStore.getLong(Preference.appStateKey("last_sync_timestamp"), 0L)
fun lastSyncEtag() = preferenceStore.getString("sync_etag", "")
fun syncInterval() = preferenceStore.getInt("sync_interval", 0) fun syncInterval() = preferenceStore.getInt("sync_interval", 0)
fun syncService() = preferenceStore.getInt("sync_service", 0) fun syncService() = preferenceStore.getInt("sync_service", 0)
@@ -53,6 +55,11 @@ class SyncPreferences(
appSettings = preferenceStore.getBoolean("appSettings", true).get(), appSettings = preferenceStore.getBoolean("appSettings", true).get(),
sourceSettings = preferenceStore.getBoolean("sourceSettings", true).get(), sourceSettings = preferenceStore.getBoolean("sourceSettings", true).get(),
privateSettings = preferenceStore.getBoolean("privateSettings", true).get(), privateSettings = preferenceStore.getBoolean("privateSettings", true).get(),
// SY -->
customInfo = preferenceStore.getBoolean("customInfo", true).get(),
readEntries = preferenceStore.getBoolean("readEntries", true).get()
// SY <--
) )
} }
@@ -65,6 +72,11 @@ class SyncPreferences(
preferenceStore.getBoolean("appSettings", true).set(syncSettings.appSettings) preferenceStore.getBoolean("appSettings", true).set(syncSettings.appSettings)
preferenceStore.getBoolean("sourceSettings", true).set(syncSettings.sourceSettings) preferenceStore.getBoolean("sourceSettings", true).set(syncSettings.sourceSettings)
preferenceStore.getBoolean("privateSettings", true).set(syncSettings.privateSettings) preferenceStore.getBoolean("privateSettings", true).set(syncSettings.privateSettings)
// SY -->
preferenceStore.getBoolean("customInfo", true).set(syncSettings.customInfo)
preferenceStore.getBoolean("readEntries", true).set(syncSettings.readEntries)
// SY <--
} }
fun getSyncTriggerOptions(): SyncTriggerOptions { fun getSyncTriggerOptions(): SyncTriggerOptions {
@@ -9,4 +9,9 @@ data class SyncSettings(
val appSettings: Boolean = true, val appSettings: Boolean = true,
val sourceSettings: Boolean = true, val sourceSettings: Boolean = true,
val privateSettings: Boolean = false, val privateSettings: Boolean = false,
// SY -->
val customInfo: Boolean = true,
val readEntries: Boolean = true
// SY <--
) )
@@ -5,7 +5,6 @@ import android.net.Uri
import android.provider.Settings import android.provider.Settings
import android.util.DisplayMetrics import android.util.DisplayMetrics
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -199,7 +198,7 @@ private fun ExtensionDetails(
key = { it.source.id }, key = { it.source.id },
) { source -> ) { source ->
SourceSwitchPreference( SourceSwitchPreference(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
source = source, source = source,
onClickSourcePreferences = onClickSourcePreferences, onClickSourcePreferences = onClickSourcePreferences,
onClickSource = onClickSource, onClickSource = onClickSource,
@@ -362,10 +361,8 @@ private fun InfoText(
primaryTextStyle: TextStyle = MaterialTheme.typography.bodyLarge, primaryTextStyle: TextStyle = MaterialTheme.typography.bodyLarge,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
val interactionSource = remember { MutableInteractionSource() }
val clickableModifier = if (onClick != null) { val clickableModifier = if (onClick != null) {
Modifier.clickable(interactionSource, indication = null) { onClick() } Modifier.clickable(interactionSource = null, indication = null, onClick = onClick)
} else { } else {
Modifier Modifier
} }
@@ -58,7 +58,7 @@ private fun ExtensionFilterContent(
) { ) {
items(state.languages) { language -> items(state.languages) { language ->
SwitchPreferenceWidget( SwitchPreferenceWidget(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
title = LocaleHelper.getSourceDisplayName(language, context), title = LocaleHelper.getSourceDisplayName(language, context),
checked = language in state.enabledLanguages, checked = language in state.enabledLanguages,
onCheckedChanged = { onClickLang(language) }, onCheckedChanged = { onClickLang(language) },
@@ -188,14 +188,14 @@ private fun ExtensionContent(
} }
ExtensionHeader( ExtensionHeader(
textRes = header.textRes, textRes = header.textRes,
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
action = action, action = action,
) )
} }
is ExtensionUiModel.Header.Text -> { is ExtensionUiModel.Header.Text -> {
ExtensionHeader( ExtensionHeader(
text = header.text, text = header.text,
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
) )
} }
} }
@@ -213,7 +213,7 @@ private fun ExtensionContent(
}, },
) { item -> ) { item ->
ExtensionItem( ExtensionItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
item = item, item = item,
onClickItem = { onClickItem = {
when (it) { when (it) {
@@ -70,7 +70,7 @@ fun FeedScreen(
onClickDelete: (FeedSavedSearch) -> Unit, onClickDelete: (FeedSavedSearch) -> Unit,
onClickManga: (Manga) -> Unit, onClickManga: (Manga) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
getMangaState: @Composable (Manga, CatalogueSource?) -> State<Manga>, getMangaState: @Composable (Manga) -> State<Manga>,
) { ) {
when { when {
state.isLoading -> LoadingScreen() state.isLoading -> LoadingScreen()
@@ -103,7 +103,7 @@ fun FeedScreen(
key = { it.feed.id }, key = { it.feed.id },
) { item -> ) { item ->
GlobalSearchResultItem( GlobalSearchResultItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
title = item.title, title = item.title,
subtitle = item.subtitle, subtitle = item.subtitle,
onLongClick = { onLongClick = {
@@ -119,7 +119,7 @@ fun FeedScreen(
) { ) {
FeedItem( FeedItem(
item = item, item = item,
getMangaState = { getMangaState(it, item.source) }, getMangaState = { getMangaState(it) },
onClickManga = onClickManga, onClickManga = onClickManga,
) )
} }
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import eu.kanade.presentation.browse.components.GlobalSearchCardRow import eu.kanade.presentation.browse.components.GlobalSearchCardRow
import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem
import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem
@@ -80,6 +81,7 @@ internal fun GlobalSearchContent(
} ?: source.name, } ?: source.name,
subtitle = LocaleHelper.getLocalizedDisplayName(source.lang), subtitle = LocaleHelper.getLocalizedDisplayName(source.lang),
onClick = { onClickSource(source) }, onClick = { onClickSource(source) },
modifier = Modifier.animateItem(),
) { ) {
when (result) { when (result) {
SearchItemResult.Loading -> { SearchItemResult.Loading -> {
@@ -144,7 +144,7 @@ private fun MigrateSourceList(
key = { (source, _) -> "migrate-${source.id}" }, key = { (source, _) -> "migrate-${source.id}" },
) { (source, count) -> ) { (source, count) ->
MigrateSourceItem( MigrateSourceItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
source = source, source = source,
count = count, count = count,
onClickItem = { onClickItem(source) }, onClickItem = { onClickItem(source) },
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material.icons.outlined.ArrowForward
import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.CopyAll import androidx.compose.material.icons.outlined.CopyAll
import androidx.compose.material.icons.outlined.Done import androidx.compose.material.icons.outlined.Done
@@ -95,7 +94,7 @@ fun MigrationListScreen(
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.animateItemPlacement() .animateItem()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -153,7 +153,7 @@ fun SourceFeedList(
key = { it.id }, key = { it.id },
) { item -> ) { item ->
GlobalSearchResultItem( GlobalSearchResultItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
title = item.title, title = item.title,
subtitle = null, subtitle = null,
onLongClick = if (item is SourceFeedUI.SourceSavedSearch) { onLongClick = if (item is SourceFeedUI.SourceSavedSearch) {
@@ -79,7 +79,7 @@ private fun SourcesFilterContent(
contentType = "source-filter-header", contentType = "source-filter-header",
) { ) {
SourcesFilterHeader( SourcesFilterHeader(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
language = language, language = language,
enabled = enabled, enabled = enabled,
onClickItem = onClickLanguage, onClickItem = onClickLanguage,
@@ -95,7 +95,7 @@ private fun SourcesFilterContent(
sources.none { it.id.toString() in state.disabledSources } sources.none { it.id.toString() in state.disabledSources }
} }
SourcesFilterToggle( SourcesFilterToggle(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
isEnabled = toggleEnabled, isEnabled = toggleEnabled,
onClickItem = { onClickItem = {
onClickSources(!toggleEnabled, sources) onClickSources(!toggleEnabled, sources)
@@ -109,7 +109,7 @@ private fun SourcesFilterContent(
contentType = { "source-filter-item" }, contentType = { "source-filter-item" },
) { source -> ) { source ->
SourcesFilterItem( SourcesFilterItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
source = source, source = source,
enabled = "${source.id}" !in state.disabledSources, enabled = "${source.id}" !in state.disabledSources,
onClickItem = onClickSource, onClickItem = onClickSource,
@@ -81,7 +81,7 @@ fun SourcesScreen(
when (model) { when (model) {
is SourceUiModel.Header -> { is SourceUiModel.Header -> {
SourceHeader( SourceHeader(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
language = model.language, language = model.language,
// SY --> // SY -->
isCategory = model.isCategory, isCategory = model.isCategory,
@@ -89,7 +89,7 @@ fun SourcesScreen(
) )
} }
is SourceUiModel.Item -> SourceItem( is SourceUiModel.Item -> SourceItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
source = model.source, source = model.source,
// SY --> // SY -->
showLatest = state.showLatest, showLatest = state.showLatest,
@@ -30,9 +30,6 @@ import tachiyomi.presentation.core.i18n.stringResource
@Composable @Composable
fun GlobalSearchResultItem( fun GlobalSearchResultItem(
// SY -->
modifier: Modifier = Modifier,
// SY <--
title: String, title: String,
// SY --> // SY -->
subtitle: String?, subtitle: String?,
@@ -41,9 +38,10 @@ fun GlobalSearchResultItem(
// SY --> // SY -->
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
// SY <-- // SY <--
modifier: Modifier = Modifier,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Column(modifier) { Column(modifier = modifier) {
Row( Row(
modifier = Modifier modifier = Modifier
.padding( .padding(
@@ -107,7 +107,7 @@ private fun CategoryContent(
key = { _, category -> "category-${category.id}" }, key = { _, category -> "category-${category.id}" },
) { index, category -> ) { index, category ->
CategoryListItem( CategoryListItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
category = category, category = category,
canMoveUp = index != 0, canMoveUp = index != 0,
canMoveDown = index != categories.lastIndex, canMoveDown = index != categories.lastIndex,
@@ -10,8 +10,7 @@ import androidx.compose.ui.Modifier
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd import tachiyomi.presentation.core.util.shouldExpandFAB
import tachiyomi.presentation.core.util.isScrollingUp
@Composable @Composable
fun CategoryFloatingActionButton( fun CategoryFloatingActionButton(
@@ -23,7 +22,7 @@ fun CategoryFloatingActionButton(
text = { Text(text = stringResource(MR.strings.action_add)) }, text = { Text(text = stringResource(MR.strings.action_add)) },
icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) }, icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) },
onClick = onCreate, onClick = onCreate,
expanded = lazyListState.isScrollingUp() || lazyListState.isScrolledToEnd(), expanded = lazyListState.shouldExpandFAB(),
modifier = modifier, modifier = modifier,
) )
} }
@@ -26,7 +26,7 @@ fun BiometricTimesContent(
) { ) {
items(timeRanges, key = { it.formattedString }) { timeRange -> items(timeRanges, key = { it.formattedString }) { timeRange ->
BiometricTimesListItem( BiometricTimesListItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
timeRange = timeRange, timeRange = timeRange,
onDelete = { onClickDelete(timeRange) }, onDelete = { onClickDelete(timeRange) },
) )
@@ -27,7 +27,7 @@ fun SortTagContent(
) { ) {
itemsIndexed(tags, key = { _, tag -> tag }) { index, tag -> itemsIndexed(tags, key = { _, tag -> tag }) { index, tag ->
SortTagListItem( SortTagListItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
tag = tag, tag = tag,
canMoveUp = index != 0, canMoveUp = index != 0,
canMoveDown = index != tags.lastIndex, canMoveDown = index != tags.lastIndex,
@@ -26,7 +26,7 @@ fun SourceCategoryContent(
) { ) {
items(categories, key = { it }) { category -> items(categories, key = { it }) { category ->
SourceCategoryListItem( SourceCategoryListItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
category = category, category = category,
onRename = { onClickRename(category) }, onRename = { onClickRename(category) },
onDelete = { onClickDelete(category) }, onDelete = { onClickDelete(category) },
@@ -37,7 +37,7 @@ fun CrashScreen(
acceptText = stringResource(MR.strings.pref_dump_crash_logs), acceptText = stringResource(MR.strings.pref_dump_crash_logs),
onAcceptClick = { onAcceptClick = {
scope.launch { scope.launch {
CrashLogUtil(context).dumpLogs() CrashLogUtil(context).dumpLogs(exception)
} }
}, },
rejectText = stringResource(MR.strings.crash_screen_restart_application), rejectText = stringResource(MR.strings.crash_screen_restart_application),
@@ -114,14 +114,14 @@ private fun HistoryScreenContent(
when (item) { when (item) {
is HistoryUiModel.Header -> { is HistoryUiModel.Header -> {
ListGroupHeader( ListGroupHeader(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
text = relativeDateText(item.date), text = relativeDateText(item.date),
) )
} }
is HistoryUiModel.Item -> { is HistoryUiModel.Item -> {
val value = item.item val value = item.item
HistoryItem( HistoryItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
history = value, history = value,
onClickCover = { onClickCover(value) }, onClickCover = { onClickCover(value) },
onClickResume = { onClickResume(value) }, onClickResume = { onClickResume(value) },
@@ -129,7 +129,7 @@ private fun LibraryRegularToolbar(
onClick = onClickOpenRandomManga, onClick = onClickOpenRandomManga,
), ),
AppBar.OverflowAction( AppBar.OverflowAction(
title = stringResource(MR.strings.sync_library), title = stringResource(SYMR.strings.sync_library),
onClick = onClickSyncNow, onClick = onClickSyncNow,
), ),
).builder().apply { ).builder().apply {
@@ -1,16 +1,33 @@
package eu.kanade.presentation.manga package eu.kanade.presentation.manga
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.AlertDialog import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Book
import androidx.compose.material.icons.outlined.SwapVert
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.presentation.more.settings.LocalPreferenceMinHeight
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@Composable @Composable
@@ -18,42 +35,92 @@ fun DuplicateMangaDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onConfirm: () -> Unit, onConfirm: () -> Unit,
onOpenManga: () -> Unit, onOpenManga: () -> Unit,
onMigrate: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
AlertDialog( val minHeight = LocalPreferenceMinHeight.current
AdaptiveSheet(
modifier = modifier,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
title = { ) {
Text(text = stringResource(MR.strings.are_you_sure)) Column(
}, modifier = Modifier
text = { .padding(
Text(text = stringResource(MR.strings.confirm_add_duplicate_manga)) vertical = TabbedDialogPaddings.Vertical,
}, horizontal = TabbedDialogPaddings.Horizontal,
confirmButton = { )
FlowRow( .fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall), ) {
Text(
modifier = Modifier.padding(TitlePadding),
text = stringResource(MR.strings.are_you_sure),
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = stringResource(MR.strings.confirm_add_duplicate_manga),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(PaddingSize))
TextPreferenceWidget(
title = stringResource(MR.strings.action_show_manga),
icon = Icons.Outlined.Book,
onPreferenceClick = {
onDismissRequest()
onOpenManga()
},
)
HorizontalDivider()
TextPreferenceWidget(
title = stringResource(MR.strings.action_migrate_duplicate),
icon = Icons.Outlined.SwapVert,
onPreferenceClick = {
onDismissRequest()
onMigrate()
},
)
HorizontalDivider()
TextPreferenceWidget(
title = stringResource(MR.strings.action_add_anyway),
icon = Icons.Outlined.Add,
onPreferenceClick = {
onDismissRequest()
onConfirm()
},
)
Row(
modifier = Modifier
.sizeIn(minHeight = minHeight)
.clickable { onDismissRequest.invoke() }
.padding(ButtonPadding)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) { ) {
TextButton( OutlinedButton(onClick = onDismissRequest, modifier = Modifier.fillMaxWidth()) {
onClick = { Text(
onDismissRequest() modifier = Modifier
onOpenManga() .padding(vertical = 8.dp),
}, text = stringResource(MR.strings.action_cancel),
) { color = MaterialTheme.colorScheme.primary,
Text(text = stringResource(MR.strings.action_show_manga)) style = MaterialTheme.typography.titleLarge,
} fontSize = 16.sp,
)
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
TextButton(
onClick = {
onDismissRequest()
onConfirm()
},
) {
Text(text = stringResource(MR.strings.action_add))
} }
} }
}, }
) }
} }
private val PaddingSize = 16.dp
private val ButtonPadding = PaddingValues(top = 16.dp, bottom = 16.dp)
private val TitlePadding = PaddingValues(bottom = 16.dp, top = 8.dp)
@@ -102,8 +102,7 @@ import tachiyomi.presentation.core.components.material.ExtendedFloatingActionBut
import tachiyomi.presentation.core.components.material.PullRefresh import tachiyomi.presentation.core.components.material.PullRefresh
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd import tachiyomi.presentation.core.util.shouldExpandFAB
import tachiyomi.presentation.core.util.isScrollingUp
import tachiyomi.source.local.isLocal import tachiyomi.source.local.isLocal
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
@@ -431,7 +430,7 @@ private fun MangaScreenSmallImpl(
}, },
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading, onClick = onContinueReading,
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(), expanded = chapterListState.shouldExpandFAB(),
) )
} }
}, },
@@ -755,7 +754,7 @@ fun MangaScreenLargeImpl(
}, },
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading, onClick = onContinueReading,
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(), expanded = chapterListState.shouldExpandFAB(),
) )
} }
}, },
@@ -9,13 +9,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.ArrowDownward import androidx.compose.material.icons.outlined.ArrowDownward
import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.ripple
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -8,7 +8,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -33,12 +32,12 @@ import androidx.compose.material.icons.outlined.Label
import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.RemoveDone import androidx.compose.material.icons.outlined.RemoveDone
import androidx.compose.material.icons.outlined.SwapCalls import androidx.compose.material.icons.outlined.SwapCalls
import androidx.compose.material.ripple
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@@ -199,7 +198,7 @@ private fun RowScope.Button(
.size(48.dp) .size(48.dp)
.weight(animatedWeight) .weight(animatedWeight)
.combinedClickable( .combinedClickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = null,
indication = ripple(bounded = false), indication = ripple(bounded = false),
onLongClick = onLongClick, onLongClick = onLongClick,
onClick = onClick, onClick = onClick,
@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -141,7 +141,7 @@ fun TagsChip(
border: ChipBorder? = SuggestionChipDefaults.suggestionChipBorder(), border: ChipBorder? = SuggestionChipDefaults.suggestionChipBorder(),
borderM3: BorderStroke? = SuggestionChipDefaultsM3.suggestionChipBorder(enabled = true), borderM3: BorderStroke? = SuggestionChipDefaultsM3.suggestionChipBorder(enabled = true),
) { ) {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
if (onClick != null) { if (onClick != null) {
SuggestionChip( SuggestionChip(
modifier = modifier, modifier = modifier,
@@ -32,8 +32,6 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.TextButton import tachiyomi.presentation.core.components.material.TextButton
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrolledToStart
@Composable @Composable
fun ScanlatorFilterDialog( fun ScanlatorFilterDialog(
@@ -97,8 +95,8 @@ fun ScanlatorFilterDialog(
} }
} }
} }
if (!state.isScrolledToStart()) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter)) if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (!state.isScrolledToEnd()) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter)) if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
} }
}, },
properties = DialogProperties( properties = DialogProperties(
@@ -28,11 +28,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -8,6 +8,8 @@ import android.provider.Settings
import android.webkit.WebStorage import android.webkit.WebStorage
import android.webkit.WebView import android.webkit.WebView
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -112,71 +114,54 @@ object SettingsAdvancedScreen : SearchableSettings {
val basePreferences = remember { Injekt.get<BasePreferences>() } val basePreferences = remember { Injekt.get<BasePreferences>() }
val networkPreferences = remember { Injekt.get<NetworkPreferences>() } val networkPreferences = remember { Injekt.get<NetworkPreferences>() }
return buildList { return listOf(
addAll( Preference.PreferenceItem.TextPreference(
listOf( title = stringResource(MR.strings.pref_dump_crash_logs),
/* SY --> Preference.PreferenceItem.SwitchPreference( subtitle = stringResource(MR.strings.pref_dump_crash_logs_summary),
pref = basePreferences.acraEnabled(), onClick = {
title = stringResource(MR.strings.pref_enable_acra), scope.launch {
subtitle = stringResource(MR.strings.pref_acra_summary), CrashLogUtil(context).dumpLogs()
enabled = isPreviewBuildType || isReleaseBuildType, }
), SY <-- */ },
Preference.PreferenceItem.TextPreference( ),
title = stringResource(MR.strings.pref_dump_crash_logs), /* SY --> Preference.PreferenceItem.SwitchPreference(
subtitle = stringResource(MR.strings.pref_dump_crash_logs_summary), pref = networkPreferences.verboseLogging(),
onClick = { title = stringResource(MR.strings.pref_verbose_logging),
scope.launch { subtitle = stringResource(MR.strings.pref_verbose_logging_summary),
CrashLogUtil(context).dumpLogs() onValueChanged = {
} context.toast(MR.strings.requires_app_restart)
}, true
), },
/* SY --> Preference.PreferenceItem.SwitchPreference( ), SY <-- */
pref = networkPreferences.verboseLogging(), Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_verbose_logging), title = stringResource(MR.strings.pref_debug_info),
subtitle = stringResource(MR.strings.pref_verbose_logging_summary), onClick = { navigator.push(DebugInfoScreen()) },
onValueChanged = { ),
context.toast(MR.strings.requires_app_restart) Preference.PreferenceItem.TextPreference(
true title = stringResource(MR.strings.pref_onboarding_guide),
}, onClick = { navigator.push(OnboardingScreen()) },
), SY <-- */ ),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_debug_info), title = stringResource(MR.strings.pref_manage_notifications),
onClick = { navigator.push(DebugInfoScreen()) }, onClick = {
), val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
Preference.PreferenceItem.TextPreference( putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
title = stringResource(MR.strings.pref_onboarding_guide), }
onClick = { navigator.push(OnboardingScreen()) }, context.startActivity(intent)
), },
), ),
) getBackgroundActivityGroup(),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { getDataGroup(),
add( getNetworkGroup(networkPreferences = networkPreferences),
Preference.PreferenceItem.TextPreference( getLibraryGroup(),
title = stringResource(MR.strings.pref_manage_notifications), getReaderGroup(basePreferences = basePreferences),
onClick = { getExtensionsGroup(basePreferences = basePreferences),
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { // SY -->
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) // getDownloaderGroup(),
} getDataSaverGroup(),
context.startActivity(intent) getDeveloperToolsGroup(),
}, // SY <--
), )
)
}
addAll(
listOf(
getBackgroundActivityGroup(),
getDataGroup(),
getNetworkGroup(networkPreferences = networkPreferences),
getLibraryGroup(),
getExtensionsGroup(basePreferences = basePreferences),
// SY -->
// getDownloaderGroup(),
getDataSaverGroup(),
getDeveloperToolsGroup(),
// SY <--
),
)
}
} }
@Composable @Composable
@@ -367,6 +352,34 @@ object SettingsAdvancedScreen : SearchableSettings {
) )
} }
@Composable
private fun getReaderGroup(
basePreferences: BasePreferences,
): Preference.PreferenceGroup {
val context = LocalContext.current
val chooseColorProfile = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
uri?.let {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, flags)
basePreferences.displayProfile().set(uri.toString())
}
}
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_reader),
preferenceItems = persistentListOf(
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_display_profile),
subtitle = basePreferences.displayProfile().get(),
onClick = {
chooseColorProfile.launch(arrayOf("*/*"))
},
),
)
)
}
@Composable @Composable
private fun getExtensionsGroup( private fun getExtensionsGroup(
basePreferences: BasePreferences, basePreferences: BasePreferences,
@@ -2,6 +2,7 @@ package eu.kanade.presentation.more.settings.screen
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -17,6 +18,7 @@ import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryScreen import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryScreen
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.domain.UnsortedPreferences import tachiyomi.domain.UnsortedPreferences
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -39,7 +41,9 @@ object SettingsBrowseScreen : SearchableSettings {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val sourcePreferences = remember { Injekt.get<SourcePreferences>() } val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
val reposCount by sourcePreferences.extensionRepos().collectAsState() val getExtensionRepoCount = remember { Injekt.get<GetExtensionRepoCount>() }
val reposCount by getExtensionRepoCount.subscribe().collectAsState(0)
// SY --> // SY -->
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -104,7 +108,7 @@ object SettingsBrowseScreen : SearchableSettings {
), ),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.label_extension_repos), title = stringResource(MR.strings.label_extension_repos),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size), subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount),
onClick = { onClick = {
navigator.push(ExtensionReposScreen()) navigator.push(ExtensionReposScreen())
}, },
@@ -349,15 +349,15 @@ object SettingsDataScreen : SearchableSettings {
private fun getSyncPreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> { private fun getSyncPreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> {
return listOf( return listOf(
Preference.PreferenceGroup( Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_service_category), title = stringResource(SYMR.strings.pref_sync_service_category),
preferenceItems = persistentListOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = syncPreferences.syncService(), pref = syncPreferences.syncService(),
title = stringResource(MR.strings.pref_sync_service), title = stringResource(SYMR.strings.pref_sync_service),
entries = persistentMapOf( entries = persistentMapOf(
SyncManager.SyncService.NONE.value to stringResource(MR.strings.off), SyncManager.SyncService.NONE.value to stringResource(MR.strings.off),
SyncManager.SyncService.SYNCYOMI.value to stringResource(MR.strings.syncyomi), SyncManager.SyncService.SYNCYOMI.value to stringResource(SYMR.strings.syncyomi),
SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(MR.strings.google_drive), SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(SYMR.strings.google_drive),
), ),
onValueChanged = { true }, onValueChanged = { true },
), ),
@@ -402,7 +402,7 @@ object SettingsDataScreen : SearchableSettings {
val googleDriveSync = Injekt.get<GoogleDriveService>() val googleDriveSync = Injekt.get<GoogleDriveService>()
return listOf( return listOf(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_google_drive_sign_in), title = stringResource(SYMR.strings.pref_google_drive_sign_in),
onClick = { onClick = {
val intent = googleDriveSync.getSignInIntent() val intent = googleDriveSync.getSignInIntent()
context.startActivity(intent) context.startActivity(intent)
@@ -427,19 +427,19 @@ object SettingsDataScreen : SearchableSettings {
val result = googleDriveSync.deleteSyncDataFromGoogleDrive() val result = googleDriveSync.deleteSyncDataFromGoogleDrive()
when (result) { when (result) {
GoogleDriveSyncService.DeleteSyncDataStatus.NOT_INITIALIZED -> context.toast( GoogleDriveSyncService.DeleteSyncDataStatus.NOT_INITIALIZED -> context.toast(
MR.strings.google_drive_not_signed_in, SYMR.strings.google_drive_not_signed_in,
duration = 5000, duration = 5000,
) )
GoogleDriveSyncService.DeleteSyncDataStatus.NO_FILES -> context.toast( GoogleDriveSyncService.DeleteSyncDataStatus.NO_FILES -> context.toast(
MR.strings.google_drive_sync_data_not_found, SYMR.strings.google_drive_sync_data_not_found,
duration = 5000, duration = 5000,
) )
GoogleDriveSyncService.DeleteSyncDataStatus.SUCCESS -> context.toast( GoogleDriveSyncService.DeleteSyncDataStatus.SUCCESS -> context.toast(
MR.strings.google_drive_sync_data_purged, SYMR.strings.google_drive_sync_data_purged,
duration = 5000, duration = 5000,
) )
GoogleDriveSyncService.DeleteSyncDataStatus.ERROR -> context.toast( GoogleDriveSyncService.DeleteSyncDataStatus.ERROR -> context.toast(
MR.strings.google_drive_sync_data_purge_error, SYMR.strings.google_drive_sync_data_purge_error,
duration = 10000, duration = 10000,
) )
} }
@@ -450,7 +450,7 @@ object SettingsDataScreen : SearchableSettings {
} }
return Preference.PreferenceItem.TextPreference( return Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_google_drive_purge_sync_data), title = stringResource(SYMR.strings.pref_google_drive_purge_sync_data),
onClick = { showPurgeDialog = true }, onClick = { showPurgeDialog = true },
) )
} }
@@ -462,8 +462,8 @@ object SettingsDataScreen : SearchableSettings {
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(MR.strings.pref_purge_confirmation_title)) }, title = { Text(text = stringResource(SYMR.strings.pref_purge_confirmation_title)) },
text = { Text(text = stringResource(MR.strings.pref_purge_confirmation_message)) }, text = { Text(text = stringResource(SYMR.strings.pref_purge_confirmation_message)) },
dismissButton = { dismissButton = {
TextButton(onClick = onDismissRequest) { TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel)) Text(text = stringResource(MR.strings.action_cancel))
@@ -482,8 +482,8 @@ object SettingsDataScreen : SearchableSettings {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
return listOf( return listOf(
Preference.PreferenceItem.EditTextPreference( Preference.PreferenceItem.EditTextPreference(
title = stringResource(MR.strings.pref_sync_host), title = stringResource(SYMR.strings.pref_sync_host),
subtitle = stringResource(MR.strings.pref_sync_host_summ), subtitle = stringResource(SYMR.strings.pref_sync_host_summ),
pref = syncPreferences.clientHost(), pref = syncPreferences.clientHost(),
onValueChanged = { newValue -> onValueChanged = { newValue ->
scope.launch { scope.launch {
@@ -496,8 +496,8 @@ object SettingsDataScreen : SearchableSettings {
}, },
), ),
Preference.PreferenceItem.EditTextPreference( Preference.PreferenceItem.EditTextPreference(
title = stringResource(MR.strings.pref_sync_api_key), title = stringResource(SYMR.strings.pref_sync_api_key),
subtitle = stringResource(MR.strings.pref_sync_api_key_summ), subtitle = stringResource(SYMR.strings.pref_sync_api_key_summ),
pref = syncPreferences.clientAPIKey(), pref = syncPreferences.clientAPIKey(),
), ),
) )
@@ -507,12 +507,12 @@ object SettingsDataScreen : SearchableSettings {
private fun getSyncNowPref(): Preference.PreferenceGroup { private fun getSyncNowPref(): Preference.PreferenceGroup {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_now_group_title), title = stringResource(SYMR.strings.pref_sync_now_group_title),
preferenceItems = persistentListOf( preferenceItems = persistentListOf(
getSyncOptionsPref(), getSyncOptionsPref(),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_sync_now), title = stringResource(SYMR.strings.pref_sync_now),
subtitle = stringResource(MR.strings.pref_sync_now_subtitle), subtitle = stringResource(SYMR.strings.pref_sync_now_subtitle),
onClick = { onClick = {
navigator.push(SyncSettingsSelector()) navigator.push(SyncSettingsSelector())
}, },
@@ -525,8 +525,8 @@ object SettingsDataScreen : SearchableSettings {
private fun getSyncOptionsPref(): Preference.PreferenceItem.TextPreference { private fun getSyncOptionsPref(): Preference.PreferenceItem.TextPreference {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
return Preference.PreferenceItem.TextPreference( return Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_sync_options), title = stringResource(SYMR.strings.pref_sync_options),
subtitle = stringResource(MR.strings.pref_sync_options_summ), subtitle = stringResource(SYMR.strings.pref_sync_options_summ),
onClick = { navigator.push(SyncTriggerOptionsScreen()) }, onClick = { navigator.push(SyncTriggerOptionsScreen()) },
) )
} }
@@ -538,16 +538,16 @@ object SettingsDataScreen : SearchableSettings {
val lastSync by syncPreferences.lastSyncTimestamp().collectAsState() val lastSync by syncPreferences.lastSyncTimestamp().collectAsState()
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_automatic_category), title = stringResource(SYMR.strings.pref_sync_automatic_category),
preferenceItems = persistentListOf( preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = syncIntervalPref, pref = syncIntervalPref,
title = stringResource(MR.strings.pref_sync_interval), title = stringResource(SYMR.strings.pref_sync_interval),
entries = persistentMapOf( entries = persistentMapOf(
0 to stringResource(MR.strings.off), 0 to stringResource(MR.strings.off),
30 to stringResource(MR.strings.update_30min), 30 to stringResource(SYMR.strings.update_30min),
60 to stringResource(MR.strings.update_1hour), 60 to stringResource(SYMR.strings.update_1hour),
180 to stringResource(MR.strings.update_3hour), 180 to stringResource(SYMR.strings.update_3hour),
360 to stringResource(MR.strings.update_6hour), 360 to stringResource(MR.strings.update_6hour),
720 to stringResource(MR.strings.update_12hour), 720 to stringResource(MR.strings.update_12hour),
1440 to stringResource(MR.strings.update_24hour), 1440 to stringResource(MR.strings.update_24hour),
@@ -560,7 +560,7 @@ object SettingsDataScreen : SearchableSettings {
}, },
), ),
Preference.PreferenceItem.InfoPreference( Preference.PreferenceItem.InfoPreference(
stringResource(MR.strings.last_synchronization, relativeTimeSpanString(lastSync)), stringResource(SYMR.strings.last_synchronization, relativeTimeSpanString(lastSync)),
), ),
), ),
) )
@@ -35,6 +35,7 @@ object SettingsReaderScreen : SearchableSettings {
// SY --> // SY -->
val forceHorizontalSeekbar by readerPref.forceHorizontalSeekbar().collectAsState() val forceHorizontalSeekbar by readerPref.forceHorizontalSeekbar().collectAsState()
// SY <-- // SY <--
return listOf( return listOf(
Preference.PreferenceItem.ListPreference( Preference.PreferenceItem.ListPreference(
pref = readerPref.defaultReadingMode(), pref = readerPref.defaultReadingMode(),
@@ -81,12 +82,6 @@ object SettingsReaderScreen : SearchableSettings {
enabled = !forceHorizontalSeekbar, enabled = !forceHorizontalSeekbar,
), ),
// SY <-- // SY <--
Preference.PreferenceItem.SwitchPreference(
pref = readerPref.trueColor(),
title = stringResource(MR.strings.pref_true_color),
subtitle = stringResource(MR.strings.pref_true_color_summary),
enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O,
),
/* SY --> /* SY -->
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = readerPref.pageTransitions(), pref = readerPref.pageTransitions(),
@@ -56,10 +56,9 @@ import tachiyomi.presentation.core.icons.Reddit
import tachiyomi.presentation.core.icons.X import tachiyomi.presentation.core.icons.X
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
object AboutScreen : Screen() { object AboutScreen : Screen() {
@@ -293,11 +292,15 @@ object AboutScreen : Screen() {
internal fun getFormattedBuildTime(): String { internal fun getFormattedBuildTime(): String {
return try { return try {
val df = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm'Z'", Locale.US) LocalDateTime.ofInstant(
.withZone(ZoneId.of("UTC")) Instant.parse(BuildConfig.BUILD_TIME),
val buildTime = LocalDateTime.from(df.parse(BuildConfig.BUILD_TIME)) ZoneId.systemDefault(),
)
buildTime!!.toDateTimestampString(UiPreferences.dateFormat(Injekt.get<UiPreferences>().dateFormat().get())) .toDateTimestampString(
UiPreferences.dateFormat(
Injekt.get<UiPreferences>().dateFormat().get(),
),
)
} catch (e: Exception) { } catch (e: Exception) {
BuildConfig.BUILD_TIME BuildConfig.BUILD_TIME
} }
@@ -32,12 +32,13 @@ class OpenSourceLicensesScreen : Screen() {
.fillMaxSize(), .fillMaxSize(),
contentPadding = contentPadding, contentPadding = contentPadding,
onLibraryClick = { onLibraryClick = {
val libraryLicenseScreen = OpenSourceLibraryLicenseScreen( navigator.push(
name = it.library.name, OpenSourceLibraryLicenseScreen(
website = it.library.website, name = it.name,
license = it.library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(), website = it.website,
license = it.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(),
)
) )
navigator.push(libraryLicenseScreen)
}, },
) )
} }
@@ -8,11 +8,14 @@ import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConflictDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
@@ -42,17 +45,19 @@ class ExtensionReposScreen(
ExtensionReposScreen( ExtensionReposScreen(
state = successState, state = successState,
onClickCreate = { screenModel.showDialog(RepoDialog.Create) }, onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
onOpenWebsite = { context.openInBrowser(it.website) },
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) }, onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
onClickRefresh = { screenModel.refreshRepos() },
navigateUp = navigator::pop, navigateUp = navigator::pop,
) )
when (val dialog = successState.dialog) { when (val dialog = successState.dialog) {
null -> {} null -> {}
RepoDialog.Create -> { is RepoDialog.Create -> {
ExtensionRepoCreateDialog( ExtensionRepoCreateDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(it) }, onCreate = { screenModel.createRepo(it) },
repos = successState.repos, repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(),
) )
} }
is RepoDialog.Delete -> { is RepoDialog.Delete -> {
@@ -62,6 +67,15 @@ class ExtensionReposScreen(
repo = dialog.repo, repo = dialog.repo,
) )
} }
is RepoDialog.Conflict -> {
ExtensionRepoConflictDialog(
onDismissRequest = screenModel::dismissDialog,
onMigrate = { screenModel.replaceRepo(dialog.newRepo) },
oldRepo = dialog.oldRepo,
newRepo = dialog.newRepo,
)
}
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -4,24 +4,29 @@ import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.extension.interactor.CreateExtensionRepo
import eu.kanade.domain.extension.interactor.DeleteExtensionRepo
import eu.kanade.domain.extension.interactor.GetExtensionRepos
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class ExtensionReposScreenModel( class ExtensionReposScreenModel(
private val getExtensionRepos: GetExtensionRepos = Injekt.get(), private val getExtensionRepo: GetExtensionRepo = Injekt.get(),
private val createExtensionRepo: CreateExtensionRepo = Injekt.get(), private val createExtensionRepo: CreateExtensionRepo = Injekt.get(),
private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(), private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(),
private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(),
private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(),
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) { ) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE) private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
@@ -29,7 +34,7 @@ class ExtensionReposScreenModel(
init { init {
screenModelScope.launchIO { screenModelScope.launchIO {
getExtensionRepos.subscribe() getExtensionRepo.subscribeAll()
.collectLatest { repos -> .collectLatest { repos ->
mutableState.update { mutableState.update {
RepoScreenState.Success( RepoScreenState.Success(
@@ -43,25 +48,51 @@ class ExtensionReposScreenModel(
/** /**
* Creates and adds a new repo to the database. * Creates and adds a new repo to the database.
* *
* @param name The name of the repo to create. * @param baseUrl The baseUrl of the repo to create.
*/ */
fun createRepo(name: String) { fun createRepo(baseUrl: String) {
screenModelScope.launchIO { screenModelScope.launchIO {
when (createExtensionRepo.await(name)) { when (val result = createExtensionRepo.await(baseUrl)) {
is CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl) CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
CreateExtensionRepo.Result.RepoAlreadyExists -> _events.send(RepoEvent.RepoAlreadyExists)
is CreateExtensionRepo.Result.DuplicateFingerprint -> {
showDialog(RepoDialog.Conflict(result.oldRepo, result.newRepo))
}
else -> {} else -> {}
} }
} }
} }
/** /**
* Deletes the given repo from the database. * Inserts a repo to the database, replace a matching repo with the same signing key fingerprint if found.
* *
* @param repo The repo to delete. * @param newRepo The repo to insert
*/ */
fun deleteRepo(repo: String) { fun replaceRepo(newRepo: ExtensionRepo) {
screenModelScope.launchIO { screenModelScope.launchIO {
deleteExtensionRepo.await(repo) replaceExtensionRepo.await(newRepo)
}
}
/**
* Refreshes information for each repository.
*/
fun refreshRepos() {
val status = state.value
if (status is RepoScreenState.Success) {
screenModelScope.launchIO {
updateExtensionRepo.awaitAll()
}
}
}
/**
* Deletes the given repo from the database
*/
fun deleteRepo(baseUrl: String) {
screenModelScope.launchIO {
deleteExtensionRepo.await(baseUrl)
} }
} }
@@ -87,11 +118,13 @@ class ExtensionReposScreenModel(
sealed class RepoEvent { sealed class RepoEvent {
sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent() sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent()
data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name) data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name)
data object RepoAlreadyExists : LocalizedMessage(MR.strings.error_repo_exists)
} }
sealed class RepoDialog { sealed class RepoDialog {
data object Create : RepoDialog() data object Create : RepoDialog()
data class Delete(val repo: String) : RepoDialog() data class Delete(val repo: String) : RepoDialog()
data class Conflict(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : RepoDialog()
} }
sealed class RepoScreenState { sealed class RepoScreenState {
@@ -101,7 +134,8 @@ sealed class RepoScreenState {
@Immutable @Immutable
data class Success( data class Success(
val repos: ImmutableSet<String>, val repos: ImmutableSet<ExtensionRepo>,
val oldRepos: ImmutableSet<String>? = null,
val dialog: RepoDialog? = null, val dialog: RepoDialog? = null,
) : RepoScreenState() { ) : RepoScreenState() {
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
@@ -22,15 +23,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@Composable @Composable
fun ExtensionReposContent( fun ExtensionReposContent(
repos: ImmutableSet<String>, repos: ImmutableSet<ExtensionRepo>,
lazyListState: LazyListState, lazyListState: LazyListState,
paddingValues: PaddingValues, paddingValues: PaddingValues,
onOpenWebsite: (ExtensionRepo) -> Unit,
onClickDelete: (String) -> Unit, onClickDelete: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -43,9 +46,10 @@ fun ExtensionReposContent(
repos.forEach { repos.forEach {
item { item {
ExtensionRepoListItem( ExtensionRepoListItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
repo = it, repo = it,
onDelete = { onClickDelete(it) }, onOpenWebsite = { onOpenWebsite(it) },
onDelete = { onClickDelete(it.baseUrl) },
) )
} }
} }
@@ -54,7 +58,8 @@ fun ExtensionReposContent(
@Composable @Composable
private fun ExtensionRepoListItem( private fun ExtensionRepoListItem(
repo: String, repo: ExtensionRepo,
onOpenWebsite: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -74,16 +79,27 @@ private fun ExtensionRepoListItem(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null) Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium)) Text(
text = repo.name,
modifier = Modifier.padding(start = MaterialTheme.padding.medium),
style = MaterialTheme.typography.titleMedium,
)
} }
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
) { ) {
IconButton(onClick = onOpenWebsite) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
contentDescription = stringResource(MR.strings.action_open_in_browser),
)
}
IconButton( IconButton(
onClick = { onClick = {
val url = "$repo/index.min.json" val url = "${repo.baseUrl}/index.min.json"
context.copyToClipboard(url, url) context.copyToClipboard(url, url)
}, },
) { ) {
@@ -16,6 +16,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -24,12 +25,12 @@ import kotlin.time.Duration.Companion.seconds
fun ExtensionRepoCreateDialog( fun ExtensionRepoCreateDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onCreate: (String) -> Unit, onCreate: (String) -> Unit,
repos: ImmutableSet<String>, repoUrls: ImmutableSet<String>,
) { ) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { repos.contains(name) } val nameAlreadyExists = remember(name) { repoUrls.contains(name) }
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@@ -115,3 +116,36 @@ fun ExtensionRepoDeleteDialog(
}, },
) )
} }
@Composable
fun ExtensionRepoConflictDialog(
oldRepo: ExtensionRepo,
newRepo: ExtensionRepo,
onDismissRequest: () -> Unit,
onMigrate: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = {
onMigrate()
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_replace_repo))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_replace_repo_title))
},
text = {
Text(text = stringResource(MR.strings.action_replace_repo_message, newRepo.name, oldRepo.name))
},
)
}
@@ -5,12 +5,17 @@ package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import eu.kanade.presentation.category.components.CategoryFloatingActionButton import eu.kanade.presentation.category.components.CategoryFloatingActionButton
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
@@ -23,7 +28,9 @@ import tachiyomi.presentation.core.util.plus
fun ExtensionReposScreen( fun ExtensionReposScreen(
state: RepoScreenState.Success, state: RepoScreenState.Success,
onClickCreate: () -> Unit, onClickCreate: () -> Unit,
onOpenWebsite: (ExtensionRepo) -> Unit,
onClickDelete: (String) -> Unit, onClickDelete: (String) -> Unit,
onClickRefresh: () -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
) { ) {
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
@@ -33,6 +40,14 @@ fun ExtensionReposScreen(
navigateUp = navigateUp, navigateUp = navigateUp,
title = stringResource(MR.strings.label_extension_repos), title = stringResource(MR.strings.label_extension_repos),
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
actions = {
IconButton(onClick = onClickRefresh) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = stringResource(resource = MR.strings.action_webview_refresh),
)
}
},
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -55,6 +70,7 @@ fun ExtensionReposScreen(
lazyListState = lazyListState, lazyListState = lazyListState,
paddingValues = paddingValues + topSmallPaddingValues + paddingValues = paddingValues + topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium), PaddingValues(horizontal = MaterialTheme.padding.medium),
onOpenWebsite = onOpenWebsite,
onClickDelete = onClickDelete, onClickDelete = onClickDelete,
) )
} }
@@ -20,6 +20,7 @@ import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.LabeledCheckbox import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction import tachiyomi.presentation.core.components.LazyColumnWithAction
import tachiyomi.presentation.core.components.SectionCard import tachiyomi.presentation.core.components.SectionCard
@@ -39,7 +40,7 @@ class SyncSettingsSelector : Screen() {
Scaffold( Scaffold(
topBar = { topBar = {
AppBar( AppBar(
title = stringResource(MR.strings.pref_choose_what_to_sync), title = stringResource(SYMR.strings.pref_choose_what_to_sync),
navigateUp = navigator::pop, navigateUp = navigator::pop,
scrollBehavior = it, scrollBehavior = it,
) )
@@ -47,14 +48,14 @@ class SyncSettingsSelector : Screen() {
) { contentPadding -> ) { contentPadding ->
LazyColumnWithAction( LazyColumnWithAction(
contentPadding = contentPadding, contentPadding = contentPadding,
actionLabel = stringResource(MR.strings.label_sync), actionLabel = stringResource(SYMR.strings.label_sync),
actionEnabled = state.options.anyEnabled(), actionEnabled = state.options.anyEnabled(),
onClickAction = { onClickAction = {
if (!SyncDataJob.isAnyJobRunning(context)) { if (!SyncDataJob.isRunning(context)) {
model.syncNow(context) model.syncNow(context)
navigator.pop() navigator.pop()
} else { } else {
context.toast(MR.strings.sync_in_progress) context.toast(SYMR.strings.sync_in_progress)
} }
}, },
) { ) {
@@ -123,6 +124,11 @@ private class SyncSettingsSelectorModel(
appSettings = syncSettings.appSettings, appSettings = syncSettings.appSettings,
sourceSettings = syncSettings.sourceSettings, sourceSettings = syncSettings.sourceSettings,
privateSettings = syncSettings.privateSettings, privateSettings = syncSettings.privateSettings,
// SY -->
customInfo = syncSettings.customInfo,
readEntries = syncSettings.readEntries,
// SY <--
) )
} }
@@ -136,6 +142,11 @@ private class SyncSettingsSelectorModel(
appSettings = backupOptions.appSettings, appSettings = backupOptions.appSettings,
sourceSettings = backupOptions.sourceSettings, sourceSettings = backupOptions.sourceSettings,
privateSettings = backupOptions.privateSettings, privateSettings = backupOptions.privateSettings,
// SY -->
customInfo = backupOptions.customInfo,
readEntries = backupOptions.readEntries,
// SY <--
) )
} }
} }
@@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.data.sync.models.SyncTriggerOptions
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.LabeledCheckbox import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction import tachiyomi.presentation.core.components.LazyColumnWithAction
import tachiyomi.presentation.core.components.SectionCard import tachiyomi.presentation.core.components.SectionCard
@@ -34,7 +35,7 @@ class SyncTriggerOptionsScreen : Screen() {
Scaffold( Scaffold(
topBar = { topBar = {
AppBar( AppBar(
title = stringResource(MR.strings.pref_sync_options), title = stringResource(SYMR.strings.pref_sync_options),
navigateUp = navigator::pop, navigateUp = navigator::pop,
scrollBehavior = it, scrollBehavior = it,
) )
@@ -43,13 +44,13 @@ class SyncTriggerOptionsScreen : Screen() {
LazyColumnWithAction( LazyColumnWithAction(
contentPadding = contentPadding, contentPadding = contentPadding,
actionLabel = stringResource(MR.strings.action_save), actionLabel = stringResource(MR.strings.action_save),
actionEnabled = state.options.anyEnabled(), actionEnabled = true,
onClickAction = { onClickAction = {
navigator.pop() navigator.pop()
}, },
) { ) {
item { item {
SectionCard(MR.strings.label_triggers) { SectionCard(SYMR.strings.label_triggers) {
Options(SyncTriggerOptions.mainOptions, state, model) Options(SyncTriggerOptions.mainOptions, state, model)
} }
} }
@@ -26,8 +26,6 @@ import androidx.compose.ui.unit.dp
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ScrollbarLazyColumn import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrolledToStart
@Composable @Composable
fun <T> ListPreferenceWidget( fun <T> ListPreferenceWidget(
@@ -69,8 +67,8 @@ fun <T> ListPreferenceWidget(
} }
} }
} }
if (!state.isScrolledToStart()) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter)) if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (!state.isScrolledToEnd()) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter)) if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
} }
}, },
confirmButton = { confirmButton = {
@@ -30,8 +30,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrolledToStart
private enum class State { private enum class State {
CHECKED, INVERSED, UNCHECKED CHECKED, INVERSED, UNCHECKED
@@ -115,16 +113,8 @@ fun <T> TriStateListDialog(
} }
} }
if (!listState.isScrolledToStart()) { if (listState.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
HorizontalDivider( if (listState.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
modifier = Modifier.align(Alignment.TopCenter),
)
}
if (!listState.isScrolledToEnd()) {
HorizontalDivider(
modifier = Modifier.align(Alignment.BottomCenter),
)
}
} }
} }
}, },
@@ -43,8 +43,6 @@ import tachiyomi.presentation.core.components.WheelTextPicker
import tachiyomi.presentation.core.components.material.AlertDialogContent import tachiyomi.presentation.core.components.material.AlertDialogContent
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrolledToStart
@Composable @Composable
fun TrackStatusSelector( fun TrackStatusSelector(
@@ -86,8 +84,8 @@ fun TrackStatusSelector(
} }
} }
} }
if (!state.isScrolledToStart()) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter)) if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (!state.isScrolledToEnd()) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter)) if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
}, },
onConfirm = onConfirm, onConfirm = onConfirm,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.FlipToBack import androidx.compose.material.icons.outlined.FlipToBack
import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.SelectAll import androidx.compose.material.icons.outlined.SelectAll
@@ -50,6 +51,7 @@ fun UpdateScreen(
onClickCover: (UpdatesItem) -> Unit, onClickCover: (UpdatesItem) -> Unit,
onSelectAll: (Boolean) -> Unit, onSelectAll: (Boolean) -> Unit,
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Boolean, onUpdateLibrary: () -> Boolean,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit, onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit, onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
@@ -63,6 +65,7 @@ fun UpdateScreen(
Scaffold( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
UpdatesAppBar( UpdatesAppBar(
onCalendarClicked = { onCalendarClicked() },
onUpdateLibrary = { onUpdateLibrary() }, onUpdateLibrary = { onUpdateLibrary() },
actionModeCounter = state.selected.size, actionModeCounter = state.selected.size,
onSelectAll = { onSelectAll(true) }, onSelectAll = { onSelectAll(true) },
@@ -132,6 +135,7 @@ fun UpdateScreen(
@Composable @Composable
private fun UpdatesAppBar( private fun UpdatesAppBar(
onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Unit, onUpdateLibrary: () -> Unit,
// For action mode // For action mode
actionModeCounter: Int, actionModeCounter: Int,
@@ -147,6 +151,11 @@ private fun UpdatesAppBar(
actions = { actions = {
AppBarActions( AppBarActions(
persistentListOf( persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.action_view_upcoming),
icon = Icons.Outlined.CalendarMonth,
onClick = onCalendarClicked,
),
AppBar.Action( AppBar.Action(
title = stringResource(MR.strings.action_update_library), title = stringResource(MR.strings.action_update_library),
icon = Icons.Outlined.Refresh, icon = Icons.Outlined.Refresh,
@@ -54,7 +54,7 @@ internal fun LazyListScope.updatesLastUpdatedItem(
item(key = "updates-lastUpdated") { item(key = "updates-lastUpdated") {
Box( Box(
modifier = Modifier modifier = Modifier
.animateItemPlacement() .animateItem()
.padding(horizontal = MaterialTheme.padding.medium, vertical = MaterialTheme.padding.small), .padding(horizontal = MaterialTheme.padding.medium, vertical = MaterialTheme.padding.small),
) { ) {
Text( Text(
@@ -94,14 +94,14 @@ internal fun LazyListScope.updatesUiItems(
when (item) { when (item) {
is UpdatesUiModel.Header -> { is UpdatesUiModel.Header -> {
ListGroupHeader( ListGroupHeader(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
text = relativeDateText(item.date), text = relativeDateText(item.date),
) )
} }
is UpdatesUiModel.Item -> { is UpdatesUiModel.Item -> {
val updatesItem = item.item val updatesItem = item.item
UpdatesUiItem( UpdatesUiItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItem(),
update = updatesItem.update, update = updatesItem.update,
selected = updatesItem.selected, selected = updatesItem.selected,
readProgress = updatesItem.update.lastPageRead readProgress = updatesItem.update.lastPageRead
@@ -9,9 +9,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
@Composable @Composable
fun rememberRequestPackageInstallsPermissionState(initialValue: Boolean = false): Boolean { fun rememberRequestPackageInstallsPermissionState(initialValue: Boolean = false): Boolean {
@@ -30,12 +30,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import com.google.accompanist.web.AccompanistWebViewClient import com.kevinnzou.web.AccompanistWebViewClient
import com.google.accompanist.web.LoadingState import com.kevinnzou.web.LoadingState
import com.google.accompanist.web.WebContent import com.kevinnzou.web.WebContent
import com.google.accompanist.web.WebView import com.kevinnzou.web.WebView
import com.google.accompanist.web.rememberWebViewNavigator import com.kevinnzou.web.rememberWebViewNavigator
import com.google.accompanist.web.rememberWebViewState import com.kevinnzou.web.rememberWebViewState
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.util.system.setDefaultSettings import eu.kanade.tachiyomi.util.system.setDefaultSettings
@@ -28,11 +28,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.web.AccompanistWebViewClient import com.kevinnzou.web.AccompanistWebViewClient
import com.google.accompanist.web.LoadingState import com.kevinnzou.web.LoadingState
import com.google.accompanist.web.WebView import com.kevinnzou.web.WebView
import com.google.accompanist.web.rememberWebViewNavigator import com.kevinnzou.web.rememberWebViewNavigator
import com.google.accompanist.web.rememberWebViewState import com.kevinnzou.web.rememberWebViewState
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.WarningBanner import eu.kanade.presentation.components.WarningBanner
+28 -28
View File
@@ -17,8 +17,6 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import coil3.ImageLoader import coil3.ImageLoader
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565 import coil3.request.allowRgb565
import coil3.request.crossfade import coil3.request.crossfade
@@ -40,6 +38,7 @@ import eu.kanade.domain.ui.UiPreferences
import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode
import eu.kanade.tachiyomi.crash.CrashActivity import eu.kanade.tachiyomi.crash.CrashActivity
import eu.kanade.tachiyomi.crash.GlobalExceptionHandler import eu.kanade.tachiyomi.crash.GlobalExceptionHandler
import eu.kanade.tachiyomi.data.coil.BufferedSourceFetcher
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer
import eu.kanade.tachiyomi.data.coil.MangaKeyer import eu.kanade.tachiyomi.data.coil.MangaKeyer
@@ -72,8 +71,12 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import logcat.LogPriority import logcat.LogPriority
import logcat.LogcatLogger import logcat.LogcatLogger
import mihon.core.migration.Migrator
import mihon.core.migration.migrations.migrations
import org.conscrypt.Conscrypt import org.conscrypt.Conscrypt
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.storage.service.StorageManager import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -175,25 +178,43 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
) { ) {
SyncDataJob.startNow(this@App) SyncDataJob.startNow(this@App)
} }
initializeMigrator()
}
private fun initializeMigrator() {
val preferenceStore = Injekt.get<PreferenceStore>()
// SY -->
val preference = preferenceStore.getInt(Preference.appStateKey("eh_last_version_code"), 0)
// SY <--
logcat { "Migration from ${preference.get()} to ${BuildConfig.VERSION_CODE}" }
Migrator.initialize(
old = preference.get(),
new = BuildConfig.VERSION_CODE,
migrations = migrations,
onMigrationComplete = {
logcat { "Updating last version to ${BuildConfig.VERSION_CODE}" }
preference.set(BuildConfig.VERSION_CODE)
},
)
} }
override fun newImageLoader(context: Context): ImageLoader { override fun newImageLoader(context: Context): ImageLoader {
return ImageLoader.Builder(this).apply { return ImageLoader.Builder(this).apply {
val callFactoryLazy = lazy { Injekt.get<NetworkHelper>().client } val callFactoryLazy = lazy { Injekt.get<NetworkHelper>().client }
val diskCacheLazy = lazy { CoilDiskCache.get(this@App) }
components { components {
add(OkHttpNetworkFetcherFactory(callFactoryLazy::value)) add(OkHttpNetworkFetcherFactory(callFactoryLazy::value))
add(TachiyomiImageDecoder.Factory()) add(TachiyomiImageDecoder.Factory())
add(MangaCoverFetcher.MangaFactory(callFactoryLazy, diskCacheLazy)) add(MangaCoverFetcher.MangaFactory(callFactoryLazy))
add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy, diskCacheLazy)) add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy))
add(MangaKeyer()) add(MangaKeyer())
add(MangaCoverKeyer()) add(MangaCoverKeyer())
add(BufferedSourceFetcher.Factory())
// SY --> // SY -->
add(PagePreviewKeyer()) add(PagePreviewKeyer())
add(PagePreviewFetcher.Factory(callFactoryLazy, diskCacheLazy)) add(PagePreviewFetcher.Factory(callFactoryLazy))
// SY <-- // SY <--
} }
diskCache(diskCacheLazy::value)
crossfade((300 * this@App.animatorDurationScale).toInt()) crossfade((300 * this@App.animatorDurationScale).toInt())
allowRgb565(DeviceUtil.isLowRamDevice(this@App)) allowRgb565(DeviceUtil.isLowRamDevice(this@App))
if (networkPreferences.verboseLogging().get()) logger(DebugLogger()) if (networkPreferences.verboseLogging().get()) logger(DebugLogger())
@@ -349,24 +370,3 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
} }
private const val ACTION_DISABLE_INCOGNITO_MODE = "tachi.action.DISABLE_INCOGNITO_MODE" private const val ACTION_DISABLE_INCOGNITO_MODE = "tachi.action.DISABLE_INCOGNITO_MODE"
/**
* Direct copy of Coil's internal SingletonDiskCache so that [MangaCoverFetcher] can access it.
*/
private object CoilDiskCache {
private const val FOLDER_NAME = "image_cache"
private var instance: DiskCache? = null
@Synchronized
fun get(context: Context): DiskCache {
return instance ?: run {
val safeCacheDir = context.cacheDir.apply { mkdirs() }
// Create the singleton disk cache instance.
DiskCache.Builder()
.directory(safeCacheDir.resolve(FOLDER_NAME))
.build()
.also { instance = it }
}
}
}
@@ -1,464 +0,0 @@
package eu.kanade.tachiyomi
import android.content.Context
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.workManager
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.preference.minusAssign
import tachiyomi.core.common.preference.plusAssign
import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_COMPLETED
import tachiyomi.i18n.MR
import java.io.File
object Migrations {
// TODO NATIVE TACHIYOMI MIGRATIONS ARE FUCKED UP DUE TO DIFFERING VERSION NUMBERS
/**
* Performs a migration when the application is updated.
*
* @return true if a migration is performed, false otherwise.
*/
fun upgrade(
context: Context,
preferenceStore: PreferenceStore,
basePreferences: BasePreferences,
uiPreferences: UiPreferences,
networkPreferences: NetworkPreferences,
sourcePreferences: SourcePreferences,
securityPreferences: SecurityPreferences,
libraryPreferences: LibraryPreferences,
readerPreferences: ReaderPreferences,
backupPreferences: BackupPreferences,
trackerManager: TrackerManager,
): Boolean {
val lastVersionCode = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0)
val oldVersion = lastVersionCode.get()
if (oldVersion < BuildConfig.VERSION_CODE) {
lastVersionCode.set(BuildConfig.VERSION_CODE)
// Always set up background tasks to ensure they're running
LibraryUpdateJob.setupTask(context)
BackupCreateJob.setupTask(context)
// Fresh install
if (oldVersion == 0) {
return false
}
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
if (oldVersion < 15) {
// Delete internal chapter cache dir.
File(context.cacheDir, "chapter_disk_cache").deleteRecursively()
}
if (oldVersion < 19) {
// Move covers to external files dir.
val oldDir = File(context.externalCacheDir, "cover_disk_cache")
if (oldDir.exists()) {
val destDir = context.getExternalFilesDir("covers")
if (destDir != null) {
oldDir.listFiles()?.forEach {
it.renameTo(File(destDir, it.name))
}
}
}
}
if (oldVersion < 26) {
// Delete external chapter cache dir.
val extCache = context.externalCacheDir
if (extCache != null) {
val chapterCache = File(extCache, "chapter_disk_cache")
if (chapterCache.exists()) {
chapterCache.deleteRecursively()
}
}
}
if (oldVersion < 44) {
// Reset sorting preference if using removed sort by source
val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0)
if (oldSortingMode == 5) { // SOURCE = 5
prefs.edit {
putInt(libraryPreferences.sortingMode().key(), 0) // ALPHABETICAL = 0
}
}
}
if (oldVersion < 52) {
// Migrate library filters to tri-state versions
fun convertBooleanPrefToTriState(key: String): Int {
val oldPrefValue = prefs.getBoolean(key, false)
return if (oldPrefValue) {
1
} else {
0
}
}
prefs.edit {
putInt(
libraryPreferences.filterDownloaded().key(),
convertBooleanPrefToTriState("pref_filter_downloaded_key"),
)
remove("pref_filter_downloaded_key")
putInt(
libraryPreferences.filterUnread().key(),
convertBooleanPrefToTriState("pref_filter_unread_key"),
)
remove("pref_filter_unread_key")
putInt(
libraryPreferences.filterCompleted().key(),
convertBooleanPrefToTriState("pref_filter_completed_key"),
)
remove("pref_filter_completed_key")
}
}
if (oldVersion < 54) {
// Force MAL log out due to login flow change
// v52: switched from scraping to WebView
// v53: switched from WebView to OAuth
if (trackerManager.myAnimeList.isLoggedIn) {
trackerManager.myAnimeList.logout()
context.toast(MR.strings.myanimelist_relogin)
}
}
if (oldVersion < 57) {
// Migrate DNS over HTTPS setting
val wasDohEnabled = prefs.getBoolean("enable_doh", false)
if (wasDohEnabled) {
prefs.edit {
putInt(networkPreferences.dohProvider().key(), PREF_DOH_CLOUDFLARE)
remove("enable_doh")
}
}
}
if (oldVersion < 59) {
// Reset rotation to Free after replacing Lock
if (prefs.contains("pref_rotation_type_key")) {
prefs.edit {
putInt("pref_rotation_type_key", 1)
}
}
}
if (oldVersion < 60) {
// Migrate Rotation and Viewer values to default values for viewer_flags
val newOrientation = when (prefs.getInt("pref_rotation_type_key", 1)) {
1 -> ReaderOrientation.FREE.flagValue
2 -> ReaderOrientation.PORTRAIT.flagValue
3 -> ReaderOrientation.LANDSCAPE.flagValue
4 -> ReaderOrientation.LOCKED_PORTRAIT.flagValue
5 -> ReaderOrientation.LOCKED_LANDSCAPE.flagValue
else -> ReaderOrientation.FREE.flagValue
}
// Reading mode flag and prefValue is the same value
val newReadingMode = prefs.getInt("pref_default_viewer_key", 1)
prefs.edit {
putInt("pref_default_orientation_type_key", newOrientation)
remove("pref_rotation_type_key")
putInt("pref_default_reading_mode_key", newReadingMode)
remove("pref_default_viewer_key")
}
}
if (oldVersion < 61) {
// Handle removed every 1 or 2 hour library updates
val updateInterval = libraryPreferences.autoUpdateInterval().get()
if (updateInterval == 1 || updateInterval == 2) {
libraryPreferences.autoUpdateInterval().set(3)
LibraryUpdateJob.setupTask(context, 3)
}
}
if (oldVersion < 64) {
val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0)
val oldSortingDirection = prefs.getBoolean("library_sorting_ascending", true)
@Suppress("DEPRECATION")
val newSortingMode = when (oldSortingMode) {
0 -> "ALPHABETICAL"
1 -> "LAST_READ"
2 -> "LAST_CHECKED"
3 -> "UNREAD"
4 -> "TOTAL_CHAPTERS"
6 -> "LATEST_CHAPTER"
8 -> "DATE_FETCHED"
7 -> "DATE_ADDED"
else -> "ALPHABETICAL"
}
val newSortingDirection = when (oldSortingDirection) {
true -> "ASCENDING"
else -> "DESCENDING"
}
prefs.edit(commit = true) {
remove(libraryPreferences.sortingMode().key())
remove("library_sorting_ascending")
}
prefs.edit {
putString(libraryPreferences.sortingMode().key(), newSortingMode)
putString("library_sorting_ascending", newSortingDirection)
}
}
if (oldVersion < 70) {
if (sourcePreferences.enabledLanguages().isSet()) {
sourcePreferences.enabledLanguages() += "all"
}
}
if (oldVersion < 71) {
// Handle removed every 3, 4, 6, and 8 hour library updates
val updateInterval = libraryPreferences.autoUpdateInterval().get()
if (updateInterval in listOf(3, 4, 6, 8)) {
libraryPreferences.autoUpdateInterval().set(12)
LibraryUpdateJob.setupTask(context, 12)
}
}
if (oldVersion < 72) {
val oldUpdateOngoingOnly = prefs.getBoolean("pref_update_only_non_completed_key", true)
if (!oldUpdateOngoingOnly) {
libraryPreferences.autoUpdateMangaRestrictions() -= MANGA_NON_COMPLETED
}
}
if (oldVersion < 75) {
val oldSecureScreen = prefs.getBoolean("secure_screen", false)
if (oldSecureScreen) {
securityPreferences.secureScreen().set(SecurityPreferences.SecureScreenMode.ALWAYS)
}
if (
DeviceUtil.isMiui &&
basePreferences.extensionInstaller().get() == BasePreferences.ExtensionInstaller.PACKAGEINSTALLER
) {
basePreferences.extensionInstaller().set(BasePreferences.ExtensionInstaller.LEGACY)
}
}
if (oldVersion < 77) {
val oldReaderTap = prefs.getBoolean("reader_tap", false)
if (!oldReaderTap) {
readerPreferences.navigationModePager().set(5)
readerPreferences.navigationModeWebtoon().set(5)
}
}
if (oldVersion < 81) {
// Handle renamed enum values
prefs.edit {
val newSortingMode = when (
val oldSortingMode = prefs.getString(
libraryPreferences.sortingMode().key(),
"ALPHABETICAL",
)
) {
"LAST_CHECKED" -> "LAST_MANGA_UPDATE"
"UNREAD" -> "UNREAD_COUNT"
"DATE_FETCHED" -> "CHAPTER_FETCH_DATE"
else -> oldSortingMode
}
putString(libraryPreferences.sortingMode().key(), newSortingMode)
}
}
if (oldVersion < 82) {
prefs.edit {
val sort = prefs.getString(libraryPreferences.sortingMode().key(), null) ?: return@edit
val direction = prefs.getString("library_sorting_ascending", "ASCENDING")!!
putString(libraryPreferences.sortingMode().key(), "$sort,$direction")
remove("library_sorting_ascending")
}
}
if (oldVersion < 84) {
if (backupPreferences.backupInterval().get() == 0) {
backupPreferences.backupInterval().set(12)
BackupCreateJob.setupTask(context)
}
}
if (oldVersion < 85) {
val preferences = listOf(
libraryPreferences.filterChapterByRead(),
libraryPreferences.filterChapterByDownloaded(),
libraryPreferences.filterChapterByBookmarked(),
libraryPreferences.sortChapterBySourceOrNumber(),
libraryPreferences.displayChapterByNameOrNumber(),
libraryPreferences.sortChapterByAscendingOrDescending(),
)
prefs.edit {
preferences.forEach { preference ->
val key = preference.key()
val value = prefs.getInt(key, Int.MIN_VALUE)
if (value == Int.MIN_VALUE) return@forEach
remove(key)
putLong(key, value.toLong())
}
}
}
if (oldVersion < 86) {
if (uiPreferences.themeMode().isSet()) {
prefs.edit {
val themeMode = prefs.getString(uiPreferences.themeMode().key(), null) ?: return@edit
putString(uiPreferences.themeMode().key(), themeMode.uppercase())
}
}
}
if (oldVersion < 92) {
val trackingQueuePref = context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE)
trackingQueuePref.all.forEach {
val (_, lastChapterRead) = it.value.toString().split(":")
trackingQueuePref.edit {
remove(it.key)
putFloat(it.key, lastChapterRead.toFloat())
}
}
}
if (oldVersion < 96) {
LibraryUpdateJob.cancelAllWorks(context)
LibraryUpdateJob.setupTask(context)
}
if (oldVersion < 97) {
// Removed background jobs
context.workManager.cancelAllWorkByTag("UpdateChecker")
context.workManager.cancelAllWorkByTag("ExtensionUpdate")
prefs.edit {
remove("automatic_ext_updates")
}
}
if (oldVersion < 99) {
val prefKeys = listOf(
"pref_filter_library_downloaded",
"pref_filter_library_unread",
"pref_filter_library_started",
"pref_filter_library_bookmarked",
"pref_filter_library_completed",
) + trackerManager.trackers.map { "pref_filter_library_tracked_${it.id}" }
prefKeys.forEach { key ->
val pref = preferenceStore.getInt(key, 0)
prefs.edit {
remove(key)
val newValue = when (pref.get()) {
1 -> TriState.ENABLED_IS
2 -> TriState.ENABLED_NOT
else -> TriState.DISABLED
}
preferenceStore.getEnum("${key}_v2", TriState.DISABLED).set(newValue)
}
}
}
if (oldVersion < 105) {
val pref = libraryPreferences.autoUpdateDeviceRestrictions()
if (pref.isSet() && "battery_not_low" in pref.get()) {
pref.getAndSet { it - "battery_not_low" }
}
}
if (oldVersion < 106) {
val pref = preferenceStore.getInt("relative_time", 7)
if (pref.get() == 0) {
uiPreferences.relativeTime().set(false)
}
}
if (oldVersion < 113) {
val prefsToReplace = listOf(
"pref_download_only",
"incognito_mode",
"last_catalogue_source",
"trusted_signatures",
"last_app_closed",
"library_update_last_timestamp",
"library_unseen_updates_count",
"last_used_category",
"last_app_check",
"last_ext_check",
"last_version_code",
"storage_dir",
)
replacePreferences(
preferenceStore = preferenceStore,
filterPredicate = { it.key in prefsToReplace },
newKey = { Preference.appStateKey(it) },
)
// Deleting old download cache index files, but might as well clear it all out
context.cacheDir.deleteRecursively()
}
if (oldVersion < 114) {
sourcePreferences.extensionRepos().getAndSet {
it.map { repo -> "https://raw.githubusercontent.com/$repo/repo" }.toSet()
}
}
if (oldVersion < 116) {
replacePreferences(
preferenceStore = preferenceStore,
filterPredicate = { it.key.startsWith("pref_mangasync_") || it.key.startsWith("track_token_") },
newKey = { Preference.privateKey(it) },
)
}
if (oldVersion < 117) {
prefs.edit {
remove(Preference.appStateKey("trusted_signatures"))
}
}
return true
}
return false
}
}
@Suppress("UNCHECKED_CAST")
private fun replacePreferences(
preferenceStore: PreferenceStore,
filterPredicate: (Map.Entry<String, Any?>) -> Boolean,
newKey: (String) -> String,
) {
preferenceStore.getAll()
.filter(filterPredicate)
.forEach { (key, value) ->
when (value) {
is Int -> {
preferenceStore.getInt(newKey(key)).set(value)
preferenceStore.getInt(key).delete()
}
is Long -> {
preferenceStore.getLong(newKey(key)).set(value)
preferenceStore.getLong(key).delete()
}
is Float -> {
preferenceStore.getFloat(newKey(key)).set(value)
preferenceStore.getFloat(key).delete()
}
is String -> {
preferenceStore.getString(newKey(key)).set(value)
preferenceStore.getString(key).delete()
}
is Boolean -> {
preferenceStore.getBoolean(newKey(key)).set(value)
preferenceStore.getBoolean(key).delete()
}
is Set<*> -> (value as? Set<String>)?.let {
preferenceStore.getStringSet(newKey(key)).set(value)
preferenceStore.getStringSet(key).delete()
}
}
}
}
@@ -17,14 +17,19 @@ class CategoriesRestorer(
if (backupCategories.isNotEmpty()) { if (backupCategories.isNotEmpty()) {
val dbCategories = getCategories.await() val dbCategories = getCategories.await()
val dbCategoriesByName = dbCategories.associateBy { it.name } val dbCategoriesByName = dbCategories.associateBy { it.name }
var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0
val categories = backupCategories.map { val categories = backupCategories
dbCategoriesByName[it.name] .sortedBy { it.order }
?: handler.awaitOneExecutable { .distinctBy { it.name }
categoriesQueries.insert(it.name, it.order, it.flags) .map {
categoriesQueries.selectLastInsertedRowId() val newOrder = nextOrder++
}.let { id -> it.toCategory(id) } dbCategoriesByName[it.name]
} ?: handler.awaitOneExecutable {
categoriesQueries.insert(it.name, newOrder, it.flags)
categoriesQueries.selectLastInsertedRowId()
}.let { id -> it.toCategory(id).copy(order = newOrder) }
}
libraryPreferences.categorizedDisplaySettings().set( libraryPreferences.categorizedDisplaySettings().set(
(dbCategories + categories) (dbCategories + categories)
@@ -313,7 +313,7 @@ class MangaRestorer(
restoreCategories(manga, categories, backupCategories) restoreCategories(manga, categories, backupCategories)
restoreChapters(manga, chapters) restoreChapters(manga, chapters)
restoreTracking(manga, tracks) restoreTracking(manga, tracks)
restoreHistory(history) restoreHistory(manga, history)
restoreExcludedScanlators(manga, excludedScanlators) restoreExcludedScanlators(manga, excludedScanlators)
updateManga.awaitUpdateFetchInterval(manga, now, currentFetchWindow) updateManga.awaitUpdateFetchInterval(manga, now, currentFetchWindow)
// SY --> // SY -->
@@ -359,13 +359,14 @@ class MangaRestorer(
} }
} }
private suspend fun restoreHistory(backupHistory: List<BackupHistory>) { private suspend fun restoreHistory(manga: Manga, backupHistory: List<BackupHistory>) {
val toUpdate = backupHistory.mapNotNull { history -> val toUpdate = backupHistory.mapNotNull { history ->
val dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(history.url) } val dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(manga.id, history.url) }
val item = history.getHistoryImpl() val item = history.getHistoryImpl()
if (dbHistory == null) { if (dbHistory == null) {
val chapter = handler.awaitOneOrNull { chaptersQueries.getChapterByUrl(history.url) } val chapter = handler.awaitList { chaptersQueries.getChapterByUrl(history.url) }
.find { it.manga_id == manga.id }
return@mapNotNull if (chapter == null) { return@mapNotNull if (chapter == null) {
// Chapter doesn't exist; skip // Chapter doesn't exist; skip
null null
@@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.data.coil
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import okio.BufferedSource
class BufferedSourceFetcher(
private val data: BufferedSource,
private val options: Options,
) : Fetcher {
override suspend fun fetch(): FetchResult {
return SourceFetchResult(
source = ImageSource(
source = data,
fileSystem = options.fileSystem,
),
mimeType = null,
dataSource = DataSource.MEMORY,
)
}
class Factory : Fetcher.Factory<BufferedSource> {
override fun create(
data: BufferedSource,
options: Options,
imageLoader: ImageLoader,
): Fetcher {
return BufferedSourceFetcher(data, options)
}
}
}
@@ -46,6 +46,7 @@ import java.io.IOException
* Available request parameter: * Available request parameter:
* - [USE_CUSTOM_COVER_KEY]: Use custom cover if set by user, default is true * - [USE_CUSTOM_COVER_KEY]: Use custom cover if set by user, default is true
*/ */
@Suppress("LongParameterList")
class MangaCoverFetcher( class MangaCoverFetcher(
private val url: String?, private val url: String?,
private val isLibraryManga: Boolean, private val isLibraryManga: Boolean,
@@ -55,7 +56,7 @@ class MangaCoverFetcher(
private val diskCacheKeyLazy: Lazy<String>, private val diskCacheKeyLazy: Lazy<String>,
private val sourceLazy: Lazy<HttpSource?>, private val sourceLazy: Lazy<HttpSource?>,
private val callFactoryLazy: Lazy<Call.Factory>, private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>, private val imageLoader: ImageLoader,
) : Fetcher { ) : Fetcher {
private val diskCacheKey: String private val diskCacheKey: String
@@ -207,7 +208,7 @@ class MangaCoverFetcher(
private fun moveSnapshotToCoverCache(snapshot: DiskCache.Snapshot, cacheFile: File?): File? { private fun moveSnapshotToCoverCache(snapshot: DiskCache.Snapshot, cacheFile: File?): File? {
if (cacheFile == null) return null if (cacheFile == null) return null
return try { return try {
diskCacheLazy.value.run { imageLoader.diskCache?.run {
fileSystem.source(snapshot.data).use { input -> fileSystem.source(snapshot.data).use { input ->
writeSourceToCoverCache(input, cacheFile) writeSourceToCoverCache(input, cacheFile)
} }
@@ -248,7 +249,7 @@ class MangaCoverFetcher(
private fun readFromDiskCache(): DiskCache.Snapshot? { private fun readFromDiskCache(): DiskCache.Snapshot? {
return if (options.diskCachePolicy.readEnabled) { return if (options.diskCachePolicy.readEnabled) {
diskCacheLazy.value.openSnapshot(diskCacheKey) imageLoader.diskCache?.openSnapshot(diskCacheKey)
} else { } else {
null null
} }
@@ -257,9 +258,10 @@ class MangaCoverFetcher(
private fun writeToDiskCache( private fun writeToDiskCache(
response: Response, response: Response,
): DiskCache.Snapshot? { ): DiskCache.Snapshot? {
val editor = diskCacheLazy.value.openEditor(diskCacheKey) ?: return null val diskCache = imageLoader.diskCache
val editor = diskCache?.openEditor(diskCacheKey) ?: return null
try { try {
diskCacheLazy.value.fileSystem.write(editor.data) { diskCache.fileSystem.write(editor.data) {
response.body.source().readAll(this) response.body.source().readAll(this)
} }
return editor.commitAndOpenSnapshot() return editor.commitAndOpenSnapshot()
@@ -299,7 +301,6 @@ class MangaCoverFetcher(
class MangaFactory( class MangaFactory(
private val callFactoryLazy: Lazy<Call.Factory>, private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>,
) : Fetcher.Factory<Manga> { ) : Fetcher.Factory<Manga> {
private val coverCache: CoverCache by injectLazy() private val coverCache: CoverCache by injectLazy()
@@ -312,17 +313,16 @@ class MangaCoverFetcher(
options = options, options = options,
coverFileLazy = lazy { coverCache.getCoverFile(data.thumbnailUrl) }, coverFileLazy = lazy { coverCache.getCoverFile(data.thumbnailUrl) },
customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.id) }, customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.id) },
diskCacheKeyLazy = lazy { MangaKeyer().key(data, options) }, diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! },
sourceLazy = lazy { sourceManager.get(data.source) as? HttpSource }, sourceLazy = lazy { sourceManager.get(data.source) as? HttpSource },
callFactoryLazy = callFactoryLazy, callFactoryLazy = callFactoryLazy,
diskCacheLazy = diskCacheLazy, imageLoader = imageLoader,
) )
} }
} }
class MangaCoverFactory( class MangaCoverFactory(
private val callFactoryLazy: Lazy<Call.Factory>, private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>,
) : Fetcher.Factory<MangaCover> { ) : Fetcher.Factory<MangaCover> {
private val coverCache: CoverCache by injectLazy() private val coverCache: CoverCache by injectLazy()
@@ -335,10 +335,10 @@ class MangaCoverFetcher(
options = options, options = options,
coverFileLazy = lazy { coverCache.getCoverFile(data.url) }, coverFileLazy = lazy { coverCache.getCoverFile(data.url) },
customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.mangaId) }, customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.mangaId) },
diskCacheKeyLazy = lazy { MangaCoverKeyer().key(data, options) }, diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! },
sourceLazy = lazy { sourceManager.get(data.sourceId) as? HttpSource }, sourceLazy = lazy { sourceManager.get(data.sourceId) as? HttpSource },
callFactoryLazy = callFactoryLazy, callFactoryLazy = callFactoryLazy,
diskCacheLazy = diskCacheLazy, imageLoader = imageLoader,
) )
} }
} }
@@ -34,6 +34,7 @@ import java.io.IOException
* Disk caching is handled by [PagePreviewCache], otherwise * Disk caching is handled by [PagePreviewCache], otherwise
* handled by Coil's [DiskCache]. * handled by Coil's [DiskCache].
*/ */
@Suppress("LongParameterList")
class PagePreviewFetcher( class PagePreviewFetcher(
private val page: PagePreview, private val page: PagePreview,
private val options: Options, private val options: Options,
@@ -43,7 +44,7 @@ class PagePreviewFetcher(
private val diskCacheKeyLazy: Lazy<String>, private val diskCacheKeyLazy: Lazy<String>,
private val sourceLazy: Lazy<PagePreviewSource?>, private val sourceLazy: Lazy<PagePreviewSource?>,
private val callFactoryLazy: Lazy<Call.Factory>, private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>, private val imageLoader: ImageLoader,
) : Fetcher { ) : Fetcher {
private val diskCacheKey: String private val diskCacheKey: String
@@ -164,7 +165,7 @@ class PagePreviewFetcher(
private fun moveSnapshotToPagePreviewCache(snapshot: DiskCache.Snapshot): File? { private fun moveSnapshotToPagePreviewCache(snapshot: DiskCache.Snapshot): File? {
return try { return try {
diskCacheLazy.value.run { imageLoader.diskCache?.run {
fileSystem.source(snapshot.data).use { input -> fileSystem.source(snapshot.data).use { input ->
writeSourceToPagePreviewCache(input) writeSourceToPagePreviewCache(input)
} }
@@ -203,15 +204,16 @@ class PagePreviewFetcher(
} }
private fun readFromDiskCache(): DiskCache.Snapshot? { private fun readFromDiskCache(): DiskCache.Snapshot? {
return if (options.diskCachePolicy.readEnabled) diskCacheLazy.value.openSnapshot(diskCacheKey) else null return if (options.diskCachePolicy.readEnabled) imageLoader.diskCache?.openSnapshot(diskCacheKey) else null
} }
private fun writeToDiskCache( private fun writeToDiskCache(
response: Response, response: Response,
): DiskCache.Snapshot? { ): DiskCache.Snapshot? {
val editor = diskCacheLazy.value.openEditor(diskCacheKey) ?: return null val diskCache = imageLoader.diskCache
val editor = diskCache?.openEditor(diskCacheKey) ?: return null
try { try {
diskCacheLazy.value.fileSystem.write(editor.data) { diskCache.fileSystem.write(editor.data) {
response.body.source().readAll(this) response.body.source().readAll(this)
} }
return editor.commitAndOpenSnapshot() return editor.commitAndOpenSnapshot()
@@ -235,7 +237,6 @@ class PagePreviewFetcher(
class Factory( class Factory(
private val callFactoryLazy: Lazy<Call.Factory>, private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>,
) : Fetcher.Factory<PagePreview> { ) : Fetcher.Factory<PagePreview> {
private val pagePreviewCache: PagePreviewCache by injectLazy() private val pagePreviewCache: PagePreviewCache by injectLazy()
@@ -248,10 +249,10 @@ class PagePreviewFetcher(
pagePreviewFile = { pagePreviewCache.getImageFile(data.imageUrl) }, pagePreviewFile = { pagePreviewCache.getImageFile(data.imageUrl) },
isInCache = { pagePreviewCache.isImageInCache(data.imageUrl) }, isInCache = { pagePreviewCache.isImageInCache(data.imageUrl) },
writeToCache = { pagePreviewCache.putImageToCache(data.imageUrl, it) }, writeToCache = { pagePreviewCache.putImageToCache(data.imageUrl, it) },
diskCacheKeyLazy = lazy { PagePreviewKeyer().key(data, options) }, diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! },
sourceLazy = lazy { sourceManager.get(data.source) as? PagePreviewSource }, sourceLazy = lazy { sourceManager.get(data.source) as? PagePreviewSource },
callFactoryLazy = callFactoryLazy, callFactoryLazy = callFactoryLazy,
diskCacheLazy = diskCacheLazy, imageLoader = imageLoader,
) )
} }
} }
@@ -1,16 +1,18 @@
package eu.kanade.tachiyomi.data.coil package eu.kanade.tachiyomi.data.coil
import android.graphics.Bitmap
import android.os.Build import android.os.Build
import androidx.core.graphics.drawable.toDrawable
import coil3.ImageLoader import coil3.ImageLoader
import coil3.asCoilImage import coil3.asCoilImage
import coil3.decode.DecodeResult import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder import coil3.decode.Decoder
import coil3.decode.ImageSource import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult import coil3.fetch.SourceFetchResult
import coil3.request.Options import coil3.request.Options
import coil3.request.allowRgb565 import coil3.request.bitmapConfig
import eu.kanade.tachiyomi.util.storage.CbzCrypto import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.system.GLUtil
import net.lingala.zip4j.ZipFile import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader import net.lingala.zip4j.model.FileHeader
import okio.BufferedSource import okio.BufferedSource
@@ -39,29 +41,58 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
} }
val decoder = resources.sourceOrNull()?.use { val decoder = resources.sourceOrNull()?.use {
zip4j.use { zipFile -> zip4j.use { zipFile ->
ImageDecoder.newInstance(zipFile?.getInputStream(entry) ?: it.inputStream()) ImageDecoder.newInstance(zipFile?.getInputStream(entry) ?: it.inputStream(), options.cropBorders, displayProfile)
} }
} }
// SY <-- // SY <--
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" } check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
val bitmap = decoder.decode(rgb565 = options.allowRgb565) val srcWidth = decoder.width
val srcHeight = decoder.height
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = srcWidth,
srcHeight = srcHeight,
dstWidth = dstWidth,
dstHeight = dstHeight,
scale = options.scale,
)
var bitmap = decoder.decode(sampleSize = sampleSize)
decoder.recycle() decoder.recycle()
check(bitmap != null) { "Failed to decode image" } check(bitmap != null) { "Failed to decode image" }
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
options.bitmapConfig == Bitmap.Config.HARDWARE &&
maxOf(bitmap.width, bitmap.height) <= GLUtil.maxTextureSize
) {
val hwBitmap = bitmap.copy(Bitmap.Config.HARDWARE, false)
if (hwBitmap != null) {
bitmap.recycle()
bitmap = hwBitmap
}
}
return DecodeResult( return DecodeResult(
image = bitmap.asCoilImage(), image = bitmap.asCoilImage(),
isSampled = false, isSampled = sampleSize > 1,
) )
} }
class Factory : Decoder.Factory { class Factory : Decoder.Factory {
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? { override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
if (!isApplicable(result.source.source())) return null return if (options.customDecoder || isApplicable(result.source.source())) {
return TachiyomiImageDecoder(result.source, options) TachiyomiImageDecoder(result.source, options)
} else {
null
}
} }
private fun isApplicable(source: BufferedSource): Boolean { private fun isApplicable(source: BufferedSource): Boolean {
@@ -84,4 +115,8 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
override fun hashCode() = javaClass.hashCode() override fun hashCode() = javaClass.hashCode()
} }
companion object {
var displayProfile: ByteArray? = null
}
} }
@@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.data.coil
import coil3.Extras
import coil3.getExtra
import coil3.request.ImageRequest
import coil3.request.Options
import coil3.size.Dimension
import coil3.size.Scale
import coil3.size.Size
import coil3.size.isOriginal
import coil3.size.pxOrElse
internal inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else width.toPx(scale)
}
internal inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else height.toPx(scale)
}
internal fun Dimension.toPx(scale: Scale): Int = pxOrElse {
when (scale) {
Scale.FILL -> Int.MIN_VALUE
Scale.FIT -> Int.MAX_VALUE
}
}
fun ImageRequest.Builder.cropBorders(enable: Boolean) = apply {
extras[cropBordersKey] = enable
}
val Options.cropBorders: Boolean
get() = getExtra(cropBordersKey)
private val cropBordersKey = Extras.Key(default = false)
fun ImageRequest.Builder.customDecoder(enable: Boolean) = apply {
extras[customDecoderKey] = enable
}
val Options.customDecoder: Boolean
get() = getExtra(customDecoderKey)
private val customDecoderKey = Extras.Key(default = false)
@@ -475,7 +475,7 @@ class DownloadManager(
fun renameMangaDir(oldTitle: String, newTitle: String, source: Long) { fun renameMangaDir(oldTitle: String, newTitle: String, source: Long) {
val sourceDir = provider.findSourceDir(sourceManager.getOrStub(source)) ?: return val sourceDir = provider.findSourceDir(sourceManager.getOrStub(source)) ?: return
val mangaDir = sourceDir.findFile(DiskUtil.buildValidFilename(oldTitle), true) ?: return val mangaDir = sourceDir.findFile(DiskUtil.buildValidFilename(oldTitle)) ?: return
mangaDir.renameTo(DiskUtil.buildValidFilename(newTitle)) mangaDir.renameTo(DiskUtil.buildValidFilename(newTitle))
} }
} }
@@ -57,7 +57,7 @@ class DownloadProvider(
* @param source the source to query. * @param source the source to query.
*/ */
fun findSourceDir(source: Source): UniFile? { fun findSourceDir(source: Source): UniFile? {
return downloadsDir?.findFile(getSourceDirName(source), true) return downloadsDir?.findFile(getSourceDirName(source))
} }
/** /**
@@ -68,7 +68,7 @@ class DownloadProvider(
*/ */
fun findMangaDir(mangaTitle: String, source: Source): UniFile? { fun findMangaDir(mangaTitle: String, source: Source): UniFile? {
val sourceDir = findSourceDir(source) val sourceDir = findSourceDir(source)
return sourceDir?.findFile(getMangaDirName(mangaTitle), true) return sourceDir?.findFile(getMangaDirName(mangaTitle))
} }
/** /**
@@ -82,7 +82,7 @@ class DownloadProvider(
fun findChapterDir(chapterName: String, chapterScanlator: String?, mangaTitle: String, source: Source): UniFile? { fun findChapterDir(chapterName: String, chapterScanlator: String?, mangaTitle: String, source: Source): UniFile? {
val mangaDir = findMangaDir(mangaTitle, source) val mangaDir = findMangaDir(mangaTitle, source)
return getValidChapterDirNames(chapterName, chapterScanlator).asSequence() return getValidChapterDirNames(chapterName, chapterScanlator).asSequence()
.mapNotNull { mangaDir?.findFile(it, true) } .mapNotNull { mangaDir?.findFile(it) }
.firstOrNull() .firstOrNull()
} }
@@ -97,7 +97,7 @@ class DownloadProvider(
val mangaDir = findMangaDir(/* SY --> */ manga.ogTitle /* SY <-- */, source) ?: return null to emptyList() val mangaDir = findMangaDir(/* SY --> */ manga.ogTitle /* SY <-- */, source) ?: return null to emptyList()
return mangaDir to chapters.mapNotNull { chapter -> return mangaDir to chapters.mapNotNull { chapter ->
getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence() getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence()
.mapNotNull { mangaDir.findFile(it, true) } .mapNotNull { mangaDir.findFile(it) }
.firstOrNull() .firstOrNull()
} }
} }
@@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
import eu.kanade.tachiyomi.util.storage.saveTo import eu.kanade.tachiyomi.util.storage.saveTo
import exh.source.isEhBasedSource
import exh.util.DataSaver import exh.util.DataSaver
import exh.util.DataSaver.Companion.getImage import exh.util.DataSaver.Companion.getImage
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -511,6 +512,9 @@ class Downloader(
.retryWhen { _, attempt -> .retryWhen { _, attempt ->
if (attempt < 3) { if (attempt < 3) {
delay((2L shl attempt.toInt()) * 1000) delay((2L shl attempt.toInt()) * 1000)
if (source.isEhBasedSource()) {
page.imageUrl = source.getImageUrl(page)
}
true true
} else { } else {
false false
@@ -709,7 +713,7 @@ class Downloader(
) )
// Remove the old file // Remove the old file
dir.findFile(COMIC_INFO_FILE, true)?.delete() dir.findFile(COMIC_INFO_FILE)?.delete()
dir.createFile(COMIC_INFO_FILE)!!.openOutputStream().use { dir.createFile(COMIC_INFO_FILE)!!.openOutputStream().use {
val comicInfoString = xml.encodeToString(ComicInfo.serializer(), comicInfo) val comicInfoString = xml.encodeToString(ComicInfo.serializer(), comicInfo)
it.write(comicInfoString.toByteArray()) it.write(comicInfoString.toByteArray())
@@ -823,7 +823,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
// Always sync the data before library update if syncing is enabled. // Always sync the data before library update if syncing is enabled.
if (syncPreferences.isSyncEnabled()) { if (syncPreferences.isSyncEnabled()) {
// Check if SyncDataJob is already running // Check if SyncDataJob is already running
if (wm.isRunning(SyncDataJob.TAG_MANUAL)) { if (SyncDataJob.isRunning(context)) {
// SyncDataJob is already running // SyncDataJob is already running
return false return false
} }
@@ -79,7 +79,7 @@ class ImageSaver(
MediaStore.Images.Media.RELATIVE_PATH to relativePath, MediaStore.Images.Media.RELATIVE_PATH to relativePath,
MediaStore.Images.Media.DISPLAY_NAME to image.name, MediaStore.Images.Media.DISPLAY_NAME to image.name,
MediaStore.Images.Media.MIME_TYPE to type.mime, MediaStore.Images.Media.MIME_TYPE to type.mime,
MediaStore.Images.Media.DATE_MODIFIED to Instant.now().toEpochMilli(), MediaStore.Images.Media.DATE_MODIFIED to Instant.now().epochSecond,
) )
val picture = findUriOrDefault(relativePath, filename) { val picture = findUriOrDefault(relativePath, filename) {
@@ -9,11 +9,14 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkQuery
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import eu.kanade.domain.sync.SyncPreferences import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.cancelNotification
import eu.kanade.tachiyomi.util.system.isRunning import eu.kanade.tachiyomi.util.system.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
@@ -27,12 +30,15 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
private val notifier = SyncNotifier(context) private val notifier = SyncNotifier(context)
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
try { if (tags.contains(TAG_AUTO)) {
setForeground(getForegroundInfo()) // Find a running manual worker. If exists, try again later
} catch (e: IllegalStateException) { if (context.workManager.isRunning(TAG_MANUAL)) {
logcat(LogPriority.ERROR, e) { "Not allowed to run on foreground service" } return Result.retry()
}
} }
setForegroundSafely()
return try { return try {
SyncManager(context).syncData() SyncManager(context).syncData()
Result.success() Result.success()
@@ -62,10 +68,8 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
private const val TAG_AUTO = "$TAG_JOB:auto" private const val TAG_AUTO = "$TAG_JOB:auto"
const val TAG_MANUAL = "$TAG_JOB:manual" const val TAG_MANUAL = "$TAG_JOB:manual"
private val jobTagList = listOf(TAG_AUTO, TAG_MANUAL) fun isRunning(context: Context): Boolean {
return context.workManager.isRunning(TAG_JOB)
fun isAnyJobRunning(context: Context): Boolean {
return jobTagList.any { context.workManager.isRunning(it) }
} }
fun setupTask(context: Context, prefInterval: Int? = null) { fun setupTask(context: Context, prefInterval: Int? = null) {
@@ -79,6 +83,7 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
10, 10,
TimeUnit.MINUTES, TimeUnit.MINUTES,
) )
.addTag(TAG_JOB)
.addTag(TAG_AUTO) .addTag(TAG_AUTO)
.build() .build()
@@ -89,14 +94,33 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
} }
fun startNow(context: Context) { fun startNow(context: Context) {
val wm = context.workManager
if (wm.isRunning(TAG_JOB)) {
// Already running either as a scheduled or manual job
return
}
val request = OneTimeWorkRequestBuilder<SyncDataJob>() val request = OneTimeWorkRequestBuilder<SyncDataJob>()
.addTag(TAG_JOB)
.addTag(TAG_MANUAL) .addTag(TAG_MANUAL)
.build() .build()
context.workManager.enqueueUniqueWork(TAG_MANUAL, ExistingWorkPolicy.KEEP, request) context.workManager.enqueueUniqueWork(TAG_MANUAL, ExistingWorkPolicy.KEEP, request)
} }
fun stop(context: Context) { fun stop(context: Context) {
context.workManager.cancelUniqueWork(TAG_MANUAL) val wm = context.workManager
val workQuery = WorkQuery.Builder.fromTags(listOf(TAG_JOB, TAG_AUTO, TAG_MANUAL))
.addStates(listOf(WorkInfo.State.RUNNING))
.build()
wm.getWorkInfos(workQuery).get()
// Should only return one work but just in case
.forEach {
wm.cancelWorkById(it.id)
// Re-enqueue cancelled scheduled work
if (it.tags.contains(TAG_AUTO)) {
setupTask(context)
}
}
} }
} }
} }
@@ -88,7 +88,14 @@ class SyncManager(
appSettings = syncOptions.appSettings, appSettings = syncOptions.appSettings,
sourceSettings = syncOptions.sourceSettings, sourceSettings = syncOptions.sourceSettings,
privateSettings = syncOptions.privateSettings, privateSettings = syncOptions.privateSettings,
// SY -->
customInfo = syncOptions.customInfo,
readEntries = syncOptions.readEntries,
// SY <--
) )
logcat(LogPriority.DEBUG) { "Begin create backup" }
val backup = Backup( val backup = Backup(
backupManga = backupCreator.backupMangas(databaseManga, backupOptions), backupManga = backupCreator.backupMangas(databaseManga, backupOptions),
backupCategories = backupCreator.backupCategories(backupOptions), backupCategories = backupCreator.backupCategories(backupOptions),
@@ -100,9 +107,11 @@ class SyncManager(
backupSavedSearches = backupCreator.backupSavedSearches(), backupSavedSearches = backupCreator.backupSavedSearches(),
// SY <-- // SY <--
) )
logcat(LogPriority.DEBUG) { "End create backup" }
// Create the SyncData object // Create the SyncData object
val syncData = SyncData( val syncData = SyncData(
deviceId = syncPreferences.uniqueDeviceID(),
backup = backup, backup = backup,
) )
@@ -129,8 +138,22 @@ class SyncManager(
val remoteBackup = syncService?.doSync(syncData) val remoteBackup = syncService?.doSync(syncData)
if (remoteBackup == null) {
logcat(LogPriority.DEBUG) { "Skip restore due to network issues" }
// should we call showSyncError?
return
}
if (remoteBackup === syncData.backup){
// nothing changed
logcat(LogPriority.DEBUG) { "Skip restore due to remote was overwrite from local" }
syncPreferences.lastSyncTimestamp().set(Date().time)
notifier.showSyncSuccess("Sync completed successfully")
return
}
// Stop the sync early if the remote backup is null or empty // Stop the sync early if the remote backup is null or empty
if (remoteBackup?.backupManga?.size == 0) { if (remoteBackup.backupManga?.size == 0) {
notifier.showSyncError("No data found on remote server.") notifier.showSyncError("No data found on remote server.")
return return
} }
@@ -143,49 +166,47 @@ class SyncManager(
return return
} }
if (remoteBackup != null) { val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup)
val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup) updateNonFavorites(nonFavorites)
updateNonFavorites(nonFavorites)
val newSyncData = backup.copy( val newSyncData = backup.copy(
backupManga = filteredFavorites, backupManga = filteredFavorites,
backupCategories = remoteBackup.backupCategories, backupCategories = remoteBackup.backupCategories,
backupSources = remoteBackup.backupSources, backupSources = remoteBackup.backupSources,
backupPreferences = remoteBackup.backupPreferences, backupPreferences = remoteBackup.backupPreferences,
backupSourcePreferences = remoteBackup.backupSourcePreferences, backupSourcePreferences = remoteBackup.backupSourcePreferences,
// SY --> // SY -->
backupSavedSearches = remoteBackup.backupSavedSearches, backupSavedSearches = remoteBackup.backupSavedSearches,
// SY <-- // SY <--
)
// It's local sync no need to restore data. (just update remote data)
if (filteredFavorites.isEmpty()) {
// update the sync timestamp
syncPreferences.lastSyncTimestamp().set(Date().time)
notifier.showSyncSuccess("Sync completed successfully")
return
}
val backupUri = writeSyncDataToCache(context, newSyncData)
logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" }
if (backupUri != null) {
BackupRestoreJob.start(
context,
backupUri,
sync = true,
options = RestoreOptions(
appSettings = true,
sourceSettings = true,
library = true,
),
) )
// It's local sync no need to restore data. (just update remote data) // update the sync timestamp
if (filteredFavorites.isEmpty()) { syncPreferences.lastSyncTimestamp().set(Date().time)
// update the sync timestamp } else {
syncPreferences.lastSyncTimestamp().set(Date().time) logcat(LogPriority.ERROR) { "Failed to write sync data to file" }
notifier.showSyncSuccess("Sync completed successfully")
return
}
val backupUri = writeSyncDataToCache(context, newSyncData)
logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" }
if (backupUri != null) {
BackupRestoreJob.start(
context,
backupUri,
sync = true,
options = RestoreOptions(
appSettings = true,
sourceSettings = true,
library = true,
),
)
// update the sync timestamp
syncPreferences.lastSyncTimestamp().set(Date().time)
} else {
logcat(LogPriority.ERROR) { "Failed to write sync data to file" }
}
} }
} }
@@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.data.sync.models
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR import tachiyomi.i18n.sy.SYMR
data class SyncTriggerOptions( data class SyncTriggerOptions(
val syncOnChapterRead: Boolean = false, val syncOnChapterRead: Boolean = false,
@@ -25,22 +25,22 @@ data class SyncTriggerOptions(
companion object { companion object {
val mainOptions = persistentListOf( val mainOptions = persistentListOf(
Entry( Entry(
label = MR.strings.sync_on_chapter_read, label = SYMR.strings.sync_on_chapter_read,
getter = SyncTriggerOptions::syncOnChapterRead, getter = SyncTriggerOptions::syncOnChapterRead,
setter = { options, enabled -> options.copy(syncOnChapterRead = enabled) }, setter = { options, enabled -> options.copy(syncOnChapterRead = enabled) },
), ),
Entry( Entry(
label = MR.strings.sync_on_chapter_open, label = SYMR.strings.sync_on_chapter_open,
getter = SyncTriggerOptions::syncOnChapterOpen, getter = SyncTriggerOptions::syncOnChapterOpen,
setter = { options, enabled -> options.copy(syncOnChapterOpen = enabled) }, setter = { options, enabled -> options.copy(syncOnChapterOpen = enabled) },
), ),
Entry( Entry(
label = MR.strings.sync_on_app_start, label = SYMR.strings.sync_on_app_start,
getter = SyncTriggerOptions::syncOnAppStart, getter = SyncTriggerOptions::syncOnAppStart,
setter = { options, enabled -> options.copy(syncOnAppStart = enabled) }, setter = { options, enabled -> options.copy(syncOnAppStart = enabled) },
), ),
Entry( Entry(
label = MR.strings.sync_on_app_resume, label = SYMR.strings.sync_on_app_resume,
getter = SyncTriggerOptions::syncOnAppResume, getter = SyncTriggerOptions::syncOnAppResume,
setter = { options, enabled -> options.copy(syncOnAppResume = enabled) }, setter = { options, enabled -> options.copy(syncOnAppResume = enabled) },
), ),
@@ -11,6 +11,7 @@ import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets
import com.google.api.client.googleapis.auth.oauth2.GoogleCredential import com.google.api.client.googleapis.auth.oauth2.GoogleCredential
import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse
import com.google.api.client.http.ByteArrayContent import com.google.api.client.http.ByteArrayContent
import com.google.api.client.http.InputStreamContent
import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.JsonFactory import com.google.api.client.json.JsonFactory
import com.google.api.client.json.jackson2.JacksonFactory import com.google.api.client.json.jackson2.JacksonFactory
@@ -18,19 +19,24 @@ import com.google.api.services.drive.Drive
import com.google.api.services.drive.DriveScopes import com.google.api.services.drive.DriveScopes
import com.google.api.services.drive.model.File import com.google.api.services.drive.model.File
import eu.kanade.domain.sync.SyncPreferences import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.tachiyomi.data.backup.models.Backup
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.serialization.encodeToString import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import logcat.LogPriority import logcat.LogPriority
import logcat.logcat import logcat.logcat
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.time.Instant import java.time.Instant
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream import java.util.zip.GZIPOutputStream
@@ -64,11 +70,47 @@ class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: Sync
private val googleDriveService = GoogleDriveService(context) private val googleDriveService = GoogleDriveService(context)
override suspend fun beforeSync() { override suspend fun doSync(syncData: SyncData): Backup? {
beforeSync()
try {
val remoteSData = pullSyncData()
if (remoteSData != null ){
// Get local unique device ID
val localDeviceId = syncPreferences.uniqueDeviceID()
val lastSyncDeviceId = remoteSData.deviceId
// Log the device IDs
logcat(LogPriority.DEBUG, "SyncService") {
"Local device ID: $localDeviceId, Last sync device ID: $lastSyncDeviceId"
}
// check if the last sync was done by the same device if so overwrite the remote data with the local data
return if (lastSyncDeviceId == localDeviceId) {
pushSyncData(syncData)
syncData.backup
}else{
// Merge the local and remote sync data
val mergedSyncData = mergeSyncData(syncData, remoteSData)
pushSyncData(mergedSyncData)
mergedSyncData.backup
}
}
pushSyncData(syncData)
return syncData.backup
} catch (e: Exception) {
logcat(LogPriority.ERROR, "SyncService") { "Error syncing: ${e.message}" }
return null
}
}
private suspend fun beforeSync() {
try { try {
googleDriveService.refreshToken() googleDriveService.refreshToken()
val drive = googleDriveService.driveService val drive = googleDriveService.driveService
?: throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in)) ?: throw Exception(context.stringResource(SYMR.strings.google_drive_not_signed_in))
var backoff = 1000L var backoff = 1000L
var retries = 0 // Retry counter var retries = 0 // Retry counter
@@ -112,21 +154,17 @@ class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: Sync
if (retries >= maxRetries) { if (retries >= maxRetries) {
logcat(LogPriority.ERROR) { "Max retries reached, exiting sync process" } logcat(LogPriority.ERROR) { "Max retries reached, exiting sync process" }
throw Exception(context.stringResource(MR.strings.error_before_sync_gdrive) + ": Max retries reached.") throw Exception(context.stringResource(SYMR.strings.error_before_sync_gdrive) + ": Max retries reached.")
} }
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, throwable = e) { "Error in GoogleDrive beforeSync" } logcat(LogPriority.ERROR, throwable = e) { "Error in GoogleDrive beforeSync" }
throw Exception(context.stringResource(MR.strings.error_before_sync_gdrive) + ": ${e.message}", e) throw Exception(context.stringResource(SYMR.strings.error_before_sync_gdrive) + ": ${e.message}", e)
} }
} }
override suspend fun pullSyncData(): SyncData? { private fun pullSyncData(): SyncData? {
val drive = googleDriveService.driveService val drive = googleDriveService.driveService ?:
throw Exception(context.stringResource(SYMR.strings.google_drive_not_signed_in))
if (drive == null) {
logcat(LogPriority.DEBUG) { "Google Drive service not initialized" }
throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in))
}
val fileList = getAppDataFileList(drive) val fileList = getAppDataFileList(drive)
if (fileList.isEmpty()) { if (fileList.isEmpty()) {
@@ -137,75 +175,53 @@ class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: Sync
val gdriveFileId = fileList[0].id val gdriveFileId = fileList[0].id
logcat(LogPriority.DEBUG) { "Google Drive File ID: $gdriveFileId" } logcat(LogPriority.DEBUG) { "Google Drive File ID: $gdriveFileId" }
val outputStream = ByteArrayOutputStream()
try { try {
drive.files().get(gdriveFileId).executeMediaAndDownloadTo(outputStream) drive.files().get(gdriveFileId).executeMediaAsInputStream().use { inputStream ->
logcat(LogPriority.DEBUG) { "File downloaded successfully" } GZIPInputStream(inputStream).use { gzipInputStream ->
return Json.decodeFromStream(SyncData.serializer(), gzipInputStream)
}
}
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, throwable = e) { "Error downloading file" } logcat(LogPriority.ERROR, throwable = e) { "Error downloading file" }
return null throw Exception("Failed to download sync data: ${e.message}", e)
}
return withIOContext {
try {
val gzipInputStream = GZIPInputStream(outputStream.toByteArray().inputStream())
val jsonString = gzipInputStream.bufferedReader().use { it.readText() }
val syncData = json.decodeFromString(SyncData.serializer(), jsonString)
this@GoogleDriveSyncService.logcat(LogPriority.DEBUG) { "JSON deserialized successfully" }
syncData
} catch (e: Exception) {
this@GoogleDriveSyncService.logcat(
LogPriority.ERROR,
throwable = e,
) { "Failed to convert json to sync data with kotlinx.serialization" }
throw Exception(e.message, e)
}
} }
} }
override suspend fun pushSyncData(syncData: SyncData) { private suspend fun pushSyncData(syncData: SyncData) {
val jsonData = json.encodeToString(syncData)
val drive = googleDriveService.driveService val drive = googleDriveService.driveService
?: throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in)) ?: throw Exception(context.stringResource(SYMR.strings.google_drive_not_signed_in))
val fileList = getAppDataFileList(drive) val fileList = getAppDataFileList(drive)
val byteArrayOutputStream = ByteArrayOutputStream()
withIOContext {
GZIPOutputStream(byteArrayOutputStream).use { gzipOutputStream ->
gzipOutputStream.write(jsonData.toByteArray(Charsets.UTF_8))
}
this@GoogleDriveSyncService.logcat(LogPriority.DEBUG) { "JSON serialized successfully" }
}
val byteArrayContent = ByteArrayContent("application/octet-stream", byteArrayOutputStream.toByteArray()) PipedOutputStream().use { pos ->
PipedInputStream(pos).use { pis ->
withIOContext {
// Start a coroutine or a background thread to write JSON to the PipedOutputStream
launch {
GZIPOutputStream(pos).use { gzipOutputStream ->
Json.encodeToStream(SyncData.serializer(), syncData, gzipOutputStream)
}
}
try { if (fileList.isNotEmpty()) {
if (fileList.isNotEmpty()) { val fileId = fileList[0].id
// File exists, so update it val mediaContent = InputStreamContent("application/gzip", pis)
val fileId = fileList[0].id drive.files().update(fileId, null, mediaContent).execute()
drive.files().update(fileId, null, byteArrayContent).execute() logcat(LogPriority.DEBUG) { "Updated existing sync data file in Google Drive with file ID: $fileId" }
logcat(LogPriority.DEBUG) { "Updated existing sync data file in Google Drive with file ID: $fileId" } } else {
} else { val fileMetadata = File().apply {
// File doesn't exist, so create it name = remoteFileName
val fileMetadata = File().apply { mimeType = "application/gzip"
name = remoteFileName parents = listOf("appDataFolder")
mimeType = "application/gzip" }
parents = listOf("appDataFolder") val mediaContent = InputStreamContent("application/gzip", pis)
val uploadedFile = drive.files().create(fileMetadata, mediaContent)
.setFields("id")
.execute()
logcat(LogPriority.DEBUG) { "Created new sync data file in Google Drive with file ID: ${uploadedFile.id}" }
}
} }
val uploadedFile = drive.files().create(fileMetadata, byteArrayContent)
.setFields("id")
.execute()
logcat(
LogPriority.DEBUG,
) { "Created new sync data file in Google Drive with file ID: ${uploadedFile.id}" }
} }
// Data has been successfully pushed or updated, delete the lock file
deleteLockFile(drive)
} catch (e: Exception) {
logcat(LogPriority.ERROR, throwable = e) { "Failed to push or update sync data" }
throw Exception(context.stringResource(MR.strings.error_uploading_sync_data) + ": ${e.message}", e)
} }
} }
@@ -282,7 +298,7 @@ class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: Sync
} }
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, throwable = e) { "Error deleting lock file" } logcat(LogPriority.ERROR, throwable = e) { "Error deleting lock file" }
throw Exception(context.stringResource(MR.strings.error_deleting_google_drive_lock_file), e) throw Exception(context.stringResource(SYMR.strings.error_deleting_google_drive_lock_file), e)
} }
} }
@@ -392,7 +408,6 @@ class GoogleDriveService(private val context: Context) {
} }
internal suspend fun refreshToken() = withIOContext { internal suspend fun refreshToken() = withIOContext {
val refreshToken = syncPreferences.googleDriveRefreshToken().get() val refreshToken = syncPreferences.googleDriveRefreshToken().get()
val accessToken = syncPreferences.googleDriveAccessToken().get()
val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance() val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance()
val secrets = GoogleClientSecrets.load( val secrets = GoogleClientSecrets.load(
@@ -407,21 +422,17 @@ class GoogleDriveService(private val context: Context) {
.build() .build()
if (refreshToken == "") { if (refreshToken == "") {
throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in)) throw Exception(context.stringResource(SYMR.strings.google_drive_not_signed_in))
} }
credential.refreshToken = refreshToken credential.refreshToken = refreshToken
this@GoogleDriveService.logcat(LogPriority.DEBUG) { "Refreshing access token with: $refreshToken" }
try { try {
credential.refreshToken() credential.refreshToken()
val newAccessToken = credential.accessToken val newAccessToken = credential.accessToken
// Save the new access token // Save the new access token
syncPreferences.googleDriveAccessToken().set(newAccessToken) syncPreferences.googleDriveAccessToken().set(newAccessToken)
setupGoogleDriveService(newAccessToken, credential.refreshToken) setupGoogleDriveService(newAccessToken, credential.refreshToken)
this@GoogleDriveService
.logcat(LogPriority.DEBUG) { "Google Access token refreshed old: $accessToken new: $newAccessToken" }
} catch (e: TokenResponseException) { } catch (e: TokenResponseException) {
if (e.details.error == "invalid_grant") { if (e.details.error == "invalid_grant") {
// The refresh token is invalid, prompt the user to sign in again // The refresh token is invalid, prompt the user to sign in again
@@ -17,6 +17,7 @@ import logcat.logcat
@Serializable @Serializable
data class SyncData( data class SyncData(
val deviceId: String = "",
val backup: Backup? = null, val backup: Backup? = null,
) )
@@ -25,38 +26,7 @@ abstract class SyncService(
val json: Json, val json: Json,
val syncPreferences: SyncPreferences, val syncPreferences: SyncPreferences,
) { ) {
open suspend fun doSync(syncData: SyncData): Backup? { abstract suspend fun doSync(syncData: SyncData): Backup?;
beforeSync()
val remoteSData = pullSyncData()
val finalSyncData =
if (remoteSData == null) {
pushSyncData(syncData)
syncData
} else {
val mergedSyncData = mergeSyncData(syncData, remoteSData)
pushSyncData(mergedSyncData)
mergedSyncData
}
return finalSyncData.backup
}
/**
* For refreshing tokens and other possible operations before connecting to the remote storage
*/
open suspend fun beforeSync() {}
/**
* Download sync data from the remote storage
*/
abstract suspend fun pullSyncData(): SyncData?
/**
* Upload sync data to the remote storage
*/
abstract suspend fun pushSyncData(syncData: SyncData)
/** /**
* Merges the local and remote sync data into a single JSON string. * Merges the local and remote sync data into a single JSON string.
@@ -65,11 +35,17 @@ abstract class SyncService(
* @param remoteSyncData The SData containing the remote sync data. * @param remoteSyncData The SData containing the remote sync data.
* @return The JSON string containing the merged sync data. * @return The JSON string containing the merged sync data.
*/ */
private fun mergeSyncData(localSyncData: SyncData, remoteSyncData: SyncData): SyncData { protected fun mergeSyncData(localSyncData: SyncData, remoteSyncData: SyncData): SyncData {
val mergedMangaList = mergeMangaLists(localSyncData.backup?.backupManga, remoteSyncData.backup?.backupManga)
val mergedCategoriesList = val mergedCategoriesList =
mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories) mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories)
val mergedMangaList = mergeMangaLists(
localSyncData.backup?.backupManga,
remoteSyncData.backup?.backupManga,
localSyncData.backup?.backupCategories ?: emptyList(),
remoteSyncData.backup?.backupCategories ?: emptyList(),
mergedCategoriesList)
val mergedSourcesList = val mergedSourcesList =
mergeSourcesLists(localSyncData.backup?.backupSources, remoteSyncData.backup?.backupSources) mergeSourcesLists(localSyncData.backup?.backupSources, remoteSyncData.backup?.backupSources)
val mergedPreferencesList = val mergedPreferencesList =
@@ -101,6 +77,7 @@ abstract class SyncService(
// Create the merged SData object // Create the merged SData object
return SyncData( return SyncData(
deviceId = syncPreferences.uniqueDeviceID(),
backup = mergedBackup, backup = mergedBackup,
) )
} }
@@ -117,6 +94,9 @@ abstract class SyncService(
private fun mergeMangaLists( private fun mergeMangaLists(
localMangaList: List<BackupManga>?, localMangaList: List<BackupManga>?,
remoteMangaList: List<BackupManga>?, remoteMangaList: List<BackupManga>?,
localCategories: List<BackupCategory>,
remoteCategories: List<BackupCategory>,
mergedCategories: List<BackupCategory>,
): List<BackupManga> { ): List<BackupManga> {
val logTag = "MergeMangaLists" val logTag = "MergeMangaLists"
@@ -135,6 +115,18 @@ abstract class SyncService(
val localMangaMap = localMangaListSafe.associateBy { mangaCompositeKey(it) } val localMangaMap = localMangaListSafe.associateBy { mangaCompositeKey(it) }
val remoteMangaMap = remoteMangaListSafe.associateBy { mangaCompositeKey(it) } val remoteMangaMap = remoteMangaListSafe.associateBy { mangaCompositeKey(it) }
val localCategoriesMapByOrder = localCategories.associateBy { it.order }
val remoteCategoriesMapByOrder = remoteCategories.associateBy { it.order }
val mergedCategoriesMapByName = mergedCategories.associateBy { it.name }
fun updateCategories(theManga: BackupManga, theMap: Map<Long, BackupCategory>): BackupManga {
return theManga.copy(categories = theManga.categories.mapNotNull {
theMap[it]?.let { category ->
mergedCategoriesMapByName[category.name]?.order
}
})
}
logcat(LogPriority.DEBUG, logTag) { logcat(LogPriority.DEBUG, logTag) {
"Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}"
} }
@@ -145,20 +137,26 @@ abstract class SyncService(
// New version comparison logic // New version comparison logic
when { when {
local != null && remote == null -> local local != null && remote == null -> updateCategories(local, localCategoriesMapByOrder)
local == null && remote != null -> remote local == null && remote != null -> updateCategories(remote, remoteCategoriesMapByOrder)
local != null && remote != null -> { local != null && remote != null -> {
// Compare versions to decide which manga to keep // Compare versions to decide which manga to keep
if (local.version >= remote.version) { if (local.version >= remote.version) {
logcat(LogPriority.DEBUG, logTag) { logcat(LogPriority.DEBUG, logTag) {
"Keeping local version of ${local.title} with merged chapters." "Keeping local version of ${local.title} with merged chapters."
} }
local.copy(chapters = mergeChapters(local.chapters, remote.chapters)) updateCategories(
local.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
localCategoriesMapByOrder
)
} else { } else {
logcat(LogPriority.DEBUG, logTag) { logcat(LogPriority.DEBUG, logTag) {
"Keeping remote version of ${remote.title} with merged chapters." "Keeping remote version of ${remote.title} with merged chapters."
} }
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters)) updateCategories(
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
remoteCategoriesMapByOrder
)
} }
} }
else -> null // No manga found for key else -> null // No manga found for key
@@ -2,23 +2,26 @@ package eu.kanade.tachiyomi.data.sync.service
import android.content.Context import android.content.Context
import eu.kanade.domain.sync.SyncPreferences import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupSerializer
import eu.kanade.tachiyomi.data.sync.SyncNotifier import eu.kanade.tachiyomi.data.sync.SyncNotifier
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.PATCH import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import kotlinx.coroutines.delay import kotlinx.serialization.SerializationException
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority import logcat.LogPriority
import logcat.logcat
import okhttp3.Headers import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.gzip
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.system.logcat import org.apache.http.HttpStatus
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class SyncYomiSyncService( class SyncYomiSyncService(
@@ -26,140 +29,117 @@ class SyncYomiSyncService(
json: Json, json: Json,
syncPreferences: SyncPreferences, syncPreferences: SyncPreferences,
private val notifier: SyncNotifier, private val notifier: SyncNotifier,
private val protoBuf: ProtoBuf = Injekt.get(),
) : SyncService(context, json, syncPreferences) { ) : SyncService(context, json, syncPreferences) {
@Serializable private class SyncYomiException(message: String?) : Exception(message)
enum class SyncStatus {
@SerialName("pending")
Pending,
@SerialName("syncing") override suspend fun doSync(syncData: SyncData): Backup? {
Syncing, try {
val (remoteData, etag) = pullSyncData()
@SerialName("success") val finalSyncData = if (remoteData != null){
Success, assert(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" }
} logcat(LogPriority.DEBUG, "SyncService") {
"Try update remote data with ETag($etag)"
@Serializable }
data class LockFile( mergeSyncData(syncData, remoteData)
@SerialName("id") } else {
val id: Int?, // init or overwrite remote data
@SerialName("user_api_key") logcat(LogPriority.DEBUG) {
val userApiKey: String?, "Try overwrite remote data with ETag($etag)"
@SerialName("acquired_by") }
val acquiredBy: String?, syncData
@SerialName("last_synced")
val lastSynced: String?,
@SerialName("status")
val status: SyncStatus,
@SerialName("acquired_at")
val acquiredAt: String?,
@SerialName("expires_at")
val expiresAt: String?,
)
@Serializable
data class LockfileCreateRequest(
@SerialName("acquired_by")
val acquiredBy: String,
)
@Serializable
data class LockfilePatchRequest(
@SerialName("user_api_key")
val userApiKey: String,
@SerialName("acquired_by")
val acquiredBy: String,
)
override suspend fun beforeSync() {
val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get()
val lockFileApi = "$host/api/sync/lock"
val deviceId = syncPreferences.uniqueDeviceID()
val client = OkHttpClient()
val headers = Headers.Builder().add("X-API-Token", apiKey).build()
val json = Json { ignoreUnknownKeys = true }
val createLockfileRequest = LockfileCreateRequest(deviceId)
val createLockfileJson = json.encodeToString(createLockfileRequest)
val patchRequest = LockfilePatchRequest(apiKey, deviceId)
val patchJson = json.encodeToString(patchRequest)
val lockFileRequest = GET(
url = lockFileApi,
headers = headers,
)
val lockFileCreate = POST(
url = lockFileApi,
headers = headers,
body = createLockfileJson.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()),
)
val lockFileUpdate = PATCH(
url = lockFileApi,
headers = headers,
body = patchJson.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()),
)
// create lock file first
client.newCall(lockFileCreate).await()
// update lock file acquired_by
client.newCall(lockFileUpdate).await()
var backoff = 2000L // Start with 2 seconds
val maxBackoff = 32000L // Maximum backoff time e.g., 32 seconds
var lockFile: LockFile
do {
val response = client.newCall(lockFileRequest).await()
val responseBody = response.body.string()
lockFile = json.decodeFromString<LockFile>(responseBody)
logcat(LogPriority.DEBUG) { "SyncYomi lock file status: ${lockFile.status}" }
if (lockFile.status != SyncStatus.Success) {
logcat(LogPriority.DEBUG) { "Lock file not ready, retrying in $backoff ms..." }
delay(backoff)
backoff = (backoff * 2).coerceAtMost(maxBackoff)
} }
} while (lockFile.status != SyncStatus.Success)
// update lock file acquired_by pushSyncData(finalSyncData, etag)
client.newCall(lockFileUpdate).await() return finalSyncData.backup
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" }
notifier.showSyncError(e.message)
return null
}
} }
override suspend fun pullSyncData(): SyncData? { private suspend fun pullSyncData(): Pair<SyncData?, String> {
val host = syncPreferences.clientHost().get() val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get() val apiKey = syncPreferences.clientAPIKey().get()
val downloadUrl = "$host/api/sync/download" val downloadUrl = "$host/api/sync/content"
val client = OkHttpClient() val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
val headers = Headers.Builder().add("X-API-Token", apiKey).build() val lastETag = syncPreferences.lastSyncEtag().get()
if (lastETag != "") {
headersBuilder.add("If-None-Match", lastETag)
}
val headers = headersBuilder.build()
val downloadRequest = GET( val downloadRequest = GET(
url = downloadUrl, url = downloadUrl,
headers = headers, headers = headers,
) )
val client = OkHttpClient()
val response = client.newCall(downloadRequest).await() val response = client.newCall(downloadRequest).await()
val responseBody = response.body.string()
return if (response.isSuccessful) { if (response.code == HttpStatus.SC_NOT_MODIFIED) {
json.decodeFromString<SyncData>(responseBody) // not modified
assert(lastETag.isNotEmpty())
logcat(LogPriority.INFO) {
"Remote server not modified"
}
return Pair(null, lastETag)
} else if (response.code == HttpStatus.SC_NOT_FOUND) {
// maybe got deleted from remote
return Pair(null, "")
}
if (response.isSuccessful) {
val newETag = response.headers["ETag"]
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
val byteArray = response.body.byteStream().use {
return@use it.readBytes()
}
return try {
val backup = protoBuf.decodeFromByteArray(BackupSerializer, byteArray)
return Pair(SyncData(backup = backup), newETag)
} catch (_: SerializationException) {
logcat(LogPriority.INFO) {
"Bad content responsed from server"
}
// the body is invalid
// return default value so we can overwrite it
Pair(null, "")
}
} else { } else {
val responseBody = response.body.string()
notifier.showSyncError("Failed to download sync data: $responseBody") notifier.showSyncError("Failed to download sync data: $responseBody")
responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } } logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
null throw SyncYomiException("Failed to download sync data: $responseBody")
} }
} }
override suspend fun pushSyncData(syncData: SyncData) { /**
* Return true if update success
*/
private suspend fun pushSyncData(syncData: SyncData, eTag: String) {
val backup = syncData.backup ?: return
val host = syncPreferences.clientHost().get() val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get() val apiKey = syncPreferences.clientAPIKey().get()
val uploadUrl = "$host/api/sync/upload" val uploadUrl = "$host/api/sync/content"
val timeout = 30L val timeout = 30L
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
if (eTag.isNotEmpty()) {
headersBuilder.add("If-Match", eTag)
}
val headers = headersBuilder.build()
// Set timeout to 30 seconds // Set timeout to 30 seconds
val client = OkHttpClient.Builder() val client = OkHttpClient.Builder()
.connectTimeout(timeout, TimeUnit.SECONDS) .connectTimeout(timeout, TimeUnit.SECONDS)
@@ -167,32 +147,34 @@ class SyncYomiSyncService(
.writeTimeout(timeout, TimeUnit.SECONDS) .writeTimeout(timeout, TimeUnit.SECONDS)
.build() .build()
val headers = Headers.Builder().add( val byteArray = protoBuf.encodeToByteArray(BackupSerializer, backup)
"Content-Type", if (byteArray.isEmpty()) {
"application/gzip", throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
).add("Content-Encoding", "gzip").add("X-API-Token", apiKey).build() }
val body = byteArray.toRequestBody("application/octet-stream".toMediaType())
val mediaType = "application/gzip".toMediaTypeOrNull() val uploadRequest = PUT(
val jsonData = json.encodeToString(syncData)
val body = jsonData.toRequestBody(mediaType).gzip()
val uploadRequest = POST(
url = uploadUrl, url = uploadUrl,
headers = headers, headers = headers,
body = body, body = body,
) )
client.newCall(uploadRequest).await().use { val response = client.newCall(uploadRequest).await()
if (it.isSuccessful) {
logcat( if (response.isSuccessful) {
LogPriority.DEBUG, val newETag = response.headers["ETag"]
) { "SyncYomi sync completed!" } .takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
} else { syncPreferences.lastSyncEtag().set(newETag)
val responseBody = it.body.string() logcat(LogPriority.DEBUG) { "SyncYomi sync completed" }
notifier.showSyncError("Failed to upload sync data: $responseBody")
responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } } } 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" }
} else {
val responseBody = response.body.string()
notifier.showSyncError("Failed to upload sync data: $responseBody")
logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
} }
} }
} }
@@ -20,9 +20,8 @@ import exh.source.BlacklistedSources
import exh.source.EH_SOURCE_ID import exh.source.EH_SOURCE_ID
import exh.source.EXH_SOURCE_ID import exh.source.EXH_SOURCE_ID
import exh.source.MERGED_SOURCE_ID import exh.source.MERGED_SOURCE_ID
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -32,7 +31,6 @@ import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.lang.launchNow
import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.source.model.StubSource import tachiyomi.domain.source.model.StubSource
@@ -54,6 +52,8 @@ class ExtensionManager(
private val trustExtension: TrustExtension = Injekt.get(), private val trustExtension: TrustExtension = Injekt.get(),
) { ) {
val scope = CoroutineScope(SupervisorJob())
// SY --> // SY -->
private val _isInitialized = MutableStateFlow(false) private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow() val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
@@ -71,13 +71,28 @@ class ExtensionManager(
private val iconMap = mutableMapOf<String, Drawable>() private val iconMap = mutableMapOf<String, Drawable>()
private val _installedExtensionsFlow = MutableStateFlow(emptyList<Extension.Installed>()) private val _installedExtensionsMapFlow = MutableStateFlow(emptyMap<String, Extension.Installed>())
val installedExtensionsFlow = _installedExtensionsFlow.asStateFlow() val installedExtensionsFlow = _installedExtensionsMapFlow.mapExtensions(scope)
private val _availableExtensionsMapFlow = MutableStateFlow(emptyMap<String, Extension.Available>())
val availableExtensionsFlow = _availableExtensionsMapFlow.mapExtensions(scope)
private val _untrustedExtensionsMapFlow = MutableStateFlow(emptyMap<String, Extension.Untrusted>())
val untrustedExtensionsFlow = _untrustedExtensionsMapFlow.mapExtensions(scope)
init {
initExtensions()
ExtensionInstallReceiver(InstallationListener()).register(context)
}
private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages().isSet() private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages().isSet()
fun getAppIconForSource(sourceId: Long): Drawable? { fun getAppIconForSource(sourceId: Long): Drawable? {
val pkgName = _installedExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName val pkgName = _installedExtensionsMapFlow.value.values
.find { ext ->
ext.sources.any { it.id == sourceId }
}
?.pkgName
if (pkgName != null) { if (pkgName != null) {
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) {
ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo
@@ -95,15 +110,6 @@ class ExtensionManager(
// SY <-- // SY <--
} }
private val _availableExtensionsFlow = MutableStateFlow(emptyList<Extension.Available>())
// SY -->
@OptIn(DelicateCoroutinesApi::class)
val availableExtensionsFlow = _availableExtensionsFlow
.map { it.filterNotBlacklisted() }
.stateIn(GlobalScope, SharingStarted.Eagerly, emptyList())
// SY <--
private var availableExtensionsSourcesData: Map<Long, StubSource> = emptyMap() private var availableExtensionsSourcesData: Map<Long, StubSource> = emptyMap()
private fun setupAvailableExtensionsSourcesDataMap(extensions: List<Extension.Available>) { private fun setupAvailableExtensionsSourcesDataMap(extensions: List<Extension.Available>) {
@@ -115,27 +121,19 @@ class ExtensionManager(
fun getSourceData(id: Long) = availableExtensionsSourcesData[id] fun getSourceData(id: Long) = availableExtensionsSourcesData[id]
private val _untrustedExtensionsFlow = MutableStateFlow(emptyList<Extension.Untrusted>())
val untrustedExtensionsFlow = _untrustedExtensionsFlow.asStateFlow()
init {
initExtensions()
ExtensionInstallReceiver(InstallationListener()).register(context)
}
/** /**
* Loads and registers the installed extensions. * Loads and registers the installed extensions.
*/ */
private fun initExtensions() { private fun initExtensions() {
val extensions = ExtensionLoader.loadExtensions(context) val extensions = ExtensionLoader.loadExtensions(context)
_installedExtensionsFlow.value = extensions _installedExtensionsMapFlow.value = extensions
.filterIsInstance<LoadResult.Success>() .filterIsInstance<LoadResult.Success>()
.map { it.extension } .associate { it.extension.pkgName to it.extension }
_untrustedExtensionsFlow.value = extensions _untrustedExtensionsMapFlow.value = extensions
.filterIsInstance<LoadResult.Untrusted>() .filterIsInstance<LoadResult.Untrusted>()
.map { it.extension } .associate { it.extension.pkgName to it.extension }
// SY --> // SY -->
.filterNotBlacklisted() .filterNotBlacklisted()
@@ -144,9 +142,9 @@ class ExtensionManager(
} }
// EXH --> // EXH -->
private fun <T : Extension> Iterable<T>.filterNotBlacklisted(): List<T> { private fun <T : Extension> Map<String, T>.filterNotBlacklisted(): Map<String, T> {
val blacklistEnabled = preferences.enableSourceBlacklist().get() val blacklistEnabled = preferences.enableSourceBlacklist().get()
return filterNot { extension -> return filterNot { (_, extension) ->
extension.isBlacklisted(blacklistEnabled) extension.isBlacklisted(blacklistEnabled)
.also { .also {
if (it) this@ExtensionManager.xLogD("Removing blacklisted extension: (name: %s, pkgName: %s)!", extension.name, extension.pkgName) if (it) this@ExtensionManager.xLogD("Removing blacklisted extension: (name: %s, pkgName: %s)!", extension.name, extension.pkgName)
@@ -160,7 +158,7 @@ class ExtensionManager(
// EXH <-- // EXH <--
/** /**
* Finds the available extensions in the [api] and updates [availableExtensions]. * Finds the available extensions in the [api] and updates [_availableExtensionsMapFlow].
*/ */
suspend fun findAvailableExtensions() { suspend fun findAvailableExtensions() {
val extensions: List<Extension.Available> = try { val extensions: List<Extension.Available> = try {
@@ -173,7 +171,7 @@ class ExtensionManager(
enableAdditionalSubLanguages(extensions) enableAdditionalSubLanguages(extensions)
_availableExtensionsFlow.value = extensions _availableExtensionsMapFlow.value = extensions.associateBy { it.pkgName }
updatedInstalledExtensionsStatuses(extensions) updatedInstalledExtensionsStatuses(extensions)
setupAvailableExtensionsSourcesDataMap(extensions) setupAvailableExtensionsSourcesDataMap(extensions)
} }
@@ -219,42 +217,38 @@ class ExtensionManager(
return return
} }
val mutInstalledExtensions = _installedExtensionsFlow.value.toMutableList() val installedExtensionsMap = _installedExtensionsMapFlow.value.toMutableMap()
var changed = false var changed = false
for ((pkgName, extension) in installedExtensionsMap) {
for ((index, installedExt) in mutInstalledExtensions.withIndex()) {
val pkgName = installedExt.pkgName
// SY --> // SY -->
val availableExt = _availableExtensionsFlow.value.find { it.pkgName == pkgName } val availableExt = availableExtensionsFlow.value.find { it.pkgName == pkgName }
// SY <-- // SY <--
if (availableExt == null && !installedExt.isObsolete) { if (availableExt == null && !extension.isObsolete) {
mutInstalledExtensions[index] = installedExt.copy(isObsolete = true) installedExtensionsMap[pkgName] = extension.copy(isObsolete = true)
changed = true changed = true
// SY --> // SY -->
} else if (installedExt.isBlacklisted() && !installedExt.isRedundant) { } else if (extension.isBlacklisted() && !extension.isRedundant) {
mutInstalledExtensions[index] = installedExt.copy(isRedundant = true) installedExtensionsMap[pkgName] = extension.copy(isRedundant = true)
changed = true changed = true
// SY <-- // SY <--
} else if (availableExt != null) { } else if (availableExt != null) {
val hasUpdate = installedExt.updateExists(availableExt) val hasUpdate = extension.updateExists(availableExt)
if (extension.hasUpdate != hasUpdate) {
if (installedExt.hasUpdate != hasUpdate) { installedExtensionsMap[pkgName] = extension.copy(
mutInstalledExtensions[index] = installedExt.copy(
hasUpdate = hasUpdate, hasUpdate = hasUpdate,
repoUrl = availableExt.repoUrl, repoUrl = availableExt.repoUrl,
) )
changed = true
} else { } else {
mutInstalledExtensions[index] = installedExt.copy( installedExtensionsMap[pkgName] = extension.copy(
repoUrl = availableExt.repoUrl, repoUrl = availableExt.repoUrl,
) )
changed = true
} }
changed = true
} }
} }
if (changed) { if (changed) {
_installedExtensionsFlow.value = mutInstalledExtensions _installedExtensionsMapFlow.value = installedExtensionsMap
} }
updatePendingUpdatesCount() updatePendingUpdatesCount()
} }
@@ -278,8 +272,7 @@ class ExtensionManager(
* @param extension The extension to be updated. * @param extension The extension to be updated.
*/ */
fun updateExtension(extension: Extension.Installed): Flow<InstallStep> { fun updateExtension(extension: Extension.Installed): Flow<InstallStep> {
val availableExt = _availableExtensionsFlow.value.find { it.pkgName == extension.pkgName } val availableExt = _availableExtensionsMapFlow.value[extension.pkgName] ?: return emptyFlow()
?: return emptyFlow()
return installExtension(availableExt) return installExtension(availableExt)
} }
@@ -315,24 +308,16 @@ class ExtensionManager(
* *
* @param extension the extension to trust * @param extension the extension to trust
*/ */
fun trust(extension: Extension.Untrusted) { suspend fun trust(extension: Extension.Untrusted) {
val untrustedPkgNames = _untrustedExtensionsFlow.value.map { it.pkgName }.toSet() _untrustedExtensionsMapFlow.value[extension.pkgName] ?: return
if (extension.pkgName !in untrustedPkgNames) return
trustExtension.trust(extension.pkgName, extension.versionCode, extension.signatureHash) trustExtension.trust(extension.pkgName, extension.versionCode, extension.signatureHash)
val nowTrustedExtensions = _untrustedExtensionsFlow.value _untrustedExtensionsMapFlow.value -= extension.pkgName
.filter { it.pkgName == extension.pkgName && it.versionCode == extension.versionCode }
_untrustedExtensionsFlow.value -= nowTrustedExtensions
launchNow { ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName)
nowTrustedExtensions .let { it as? LoadResult.Success }
.map { extension -> ?.let { registerNewExtension(it.extension) }
async { ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName) }.await()
}
.filterIsInstance<LoadResult.Success>()
.forEach { registerNewExtension(it.extension) }
}
} }
/** /**
@@ -348,7 +333,7 @@ class ExtensionManager(
} }
// SY <-- // SY <--
_installedExtensionsFlow.value += extension _installedExtensionsMapFlow.value += extension
} }
/** /**
@@ -365,13 +350,7 @@ class ExtensionManager(
} }
// SY <-- // SY <--
val mutInstalledExtensions = _installedExtensionsFlow.value.toMutableList() _installedExtensionsMapFlow.value += extension
val oldExtension = mutInstalledExtensions.find { it.pkgName == extension.pkgName }
if (oldExtension != null) {
mutInstalledExtensions -= oldExtension
}
mutInstalledExtensions += extension
_installedExtensionsFlow.value = mutInstalledExtensions
} }
/** /**
@@ -381,14 +360,8 @@ class ExtensionManager(
* @param pkgName The package name of the uninstalled application. * @param pkgName The package name of the uninstalled application.
*/ */
private fun unregisterExtension(pkgName: String) { private fun unregisterExtension(pkgName: String) {
val installedExtension = _installedExtensionsFlow.value.find { it.pkgName == pkgName } _installedExtensionsMapFlow.value -= pkgName
if (installedExtension != null) { _untrustedExtensionsMapFlow.value -= pkgName
_installedExtensionsFlow.value -= installedExtension
}
val untrustedExtension = _untrustedExtensionsFlow.value.find { it.pkgName == pkgName }
if (untrustedExtension != null) {
_untrustedExtensionsFlow.value -= untrustedExtension
}
} }
/** /**
@@ -407,14 +380,9 @@ class ExtensionManager(
} }
override fun onExtensionUntrusted(extension: Extension.Untrusted) { override fun onExtensionUntrusted(extension: Extension.Untrusted) {
val installedExtension = _installedExtensionsFlow.value _installedExtensionsMapFlow.value -= extension.pkgName
.find { it.pkgName == extension.pkgName } _untrustedExtensionsMapFlow.value += extension
updatePendingUpdatesCount()
if (installedExtension != null) {
_installedExtensionsFlow.value -= installedExtension
} else {
_untrustedExtensionsFlow.value += extension
}
} }
override fun onPackageUninstalled(pkgName: String) { override fun onPackageUninstalled(pkgName: String) {
@@ -436,17 +404,24 @@ class ExtensionManager(
} }
private fun Extension.Installed.updateExists(availableExtension: Extension.Available? = null): Boolean { private fun Extension.Installed.updateExists(availableExtension: Extension.Available? = null): Boolean {
val availableExt = availableExtension ?: _availableExtensionsFlow.value.find { it.pkgName == pkgName } val availableExt = availableExtension
?: _availableExtensionsMapFlow.value[pkgName]
?: return false ?: return false
return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion) return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion)
} }
private fun updatePendingUpdatesCount() { private fun updatePendingUpdatesCount() {
val pendingUpdateCount = _installedExtensionsFlow.value.count { it.hasUpdate } val pendingUpdateCount = _installedExtensionsMapFlow.value.values.count { it.hasUpdate }
preferences.extensionUpdatesCount().set(pendingUpdateCount) preferences.extensionUpdatesCount().set(pendingUpdateCount)
if (pendingUpdateCount == 0) { if (pendingUpdateCount == 0) {
ExtensionUpdateNotifier(context).dismiss() ExtensionUpdateNotifier(context).dismiss()
} }
} }
private operator fun <T : Extension> Map<String, T>.plus(extension: T) = plus(extension.pkgName to extension)
private fun <T : Extension> StateFlow<Map<String, T>>.mapExtensions(scope: CoroutineScope): StateFlow<List<T>> {
return map { it.values.toList() }.stateIn(scope, SharingStarted.Lazily, value.values.toList())
}
} }
@@ -11,9 +11,14 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import exh.source.BlacklistedSources import exh.source.BlacklistedSources
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import logcat.LogPriority import logcat.LogPriority
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
@@ -26,8 +31,12 @@ internal class ExtensionApi {
private val networkService: NetworkHelper by injectLazy() private val networkService: NetworkHelper by injectLazy()
private val preferenceStore: PreferenceStore by injectLazy() private val preferenceStore: PreferenceStore by injectLazy()
private val sourcePreferences: SourcePreferences by injectLazy() private val getExtensionRepo: GetExtensionRepo by injectLazy()
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
private val extensionManager: ExtensionManager by injectLazy() private val extensionManager: ExtensionManager by injectLazy()
// SY -->
private val sourcePreferences: SourcePreferences by injectLazy()
// SY <--
private val json: Json by injectLazy() private val json: Json by injectLazy()
private val lastExtCheck: Preference<Long> by lazy { private val lastExtCheck: Preference<Long> by lazy {
@@ -36,11 +45,15 @@ internal class ExtensionApi {
suspend fun findExtensions(): List<Extension.Available> { suspend fun findExtensions(): List<Extension.Available> {
return withIOContext { return withIOContext {
sourcePreferences.extensionRepos().get().flatMap { getExtensions(it) } getExtensionRepo.getAll()
.map { async { getExtensions(it) } }
.awaitAll()
.flatten()
} }
} }
private suspend fun getExtensions(repoBaseUrl: String): List<Extension.Available> { private suspend fun getExtensions(extRepo: ExtensionRepo): List<Extension.Available> {
val repoBaseUrl = extRepo.baseUrl
return try { return try {
val response = networkService.client val response = networkService.client
.newCall(GET("$repoBaseUrl/index.min.json")) .newCall(GET("$repoBaseUrl/index.min.json"))
@@ -68,6 +81,9 @@ internal class ExtensionApi {
return null return null
} }
// Update extension repo details
updateExtensionRepo.awaitAll()
val extensions = if (fromAvailableExtensionList) { val extensions = if (fromAvailableExtensionList) {
extensionManager.availableExtensionsFlow.value extensionManager.availableExtensionsFlow.value
} else { } else {
@@ -9,12 +9,10 @@ import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.lang.launchNow
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
/** /**
@@ -23,29 +21,23 @@ import tachiyomi.core.common.util.system.logcat
* *
* @param listener The listener that should be notified of extension installation events. * @param listener The listener that should be notified of extension installation events.
*/ */
internal class ExtensionInstallReceiver(private val listener: Listener) : internal class ExtensionInstallReceiver(private val listener: Listener) : BroadcastReceiver() {
BroadcastReceiver() {
val scope = CoroutineScope(SupervisorJob())
/**
* Registers this broadcast receiver
*/
fun register(context: Context) { fun register(context: Context) {
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED) ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
} }
/** private val filter = IntentFilter().apply {
* Returns the intent filter this receiver should subscribe to. addAction(Intent.ACTION_PACKAGE_ADDED)
*/ addAction(Intent.ACTION_PACKAGE_REPLACED)
private val filter addAction(Intent.ACTION_PACKAGE_REMOVED)
get() = IntentFilter().apply { addAction(ACTION_EXTENSION_ADDED)
addAction(Intent.ACTION_PACKAGE_ADDED) addAction(ACTION_EXTENSION_REPLACED)
addAction(Intent.ACTION_PACKAGE_REPLACED) addAction(ACTION_EXTENSION_REMOVED)
addAction(Intent.ACTION_PACKAGE_REMOVED) addDataScheme("package")
addAction(ACTION_EXTENSION_ADDED) }
addAction(ACTION_EXTENSION_REPLACED)
addAction(ACTION_EXTENSION_REMOVED)
addDataScheme("package")
}
/** /**
* Called when one of the events of the [filter] is received. When the package is an extension, * Called when one of the events of the [filter] is received. When the package is an extension,
@@ -58,7 +50,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> { Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
if (isReplacing(intent)) return if (isReplacing(intent)) return
launchNow { scope.launch {
when (val result = getExtensionFromIntent(context, intent)) { when (val result = getExtensionFromIntent(context, intent)) {
is LoadResult.Success -> listener.onExtensionInstalled(result.extension) is LoadResult.Success -> listener.onExtensionInstalled(result.extension)
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
@@ -67,7 +59,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
} }
} }
Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> { Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
launchNow { scope.launch {
when (val result = getExtensionFromIntent(context, intent)) { when (val result = getExtensionFromIntent(context, intent)) {
is LoadResult.Success -> listener.onExtensionUpdated(result.extension) is LoadResult.Success -> listener.onExtensionUpdated(result.extension)
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
@@ -107,9 +99,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
logcat(LogPriority.WARN) { "Package name not found" } logcat(LogPriority.WARN) { "Package name not found" }
return LoadResult.Error return LoadResult.Error
} }
return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) { return ExtensionLoader.loadExtensionFromPkgName(context, pkgName)
ExtensionLoader.loadExtensionFromPkgName(context, pkgName)
}.await()
} }
/** /**
@@ -172,7 +172,7 @@ internal object ExtensionLoader {
* Attempts to load an extension from the given package name. It checks if the extension * Attempts to load an extension from the given package name. It checks if the extension
* contains the required feature flag before trying to load it. * contains the required feature flag before trying to load it.
*/ */
fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult { suspend fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
val extensionPackage = getExtensionInfoFromPkgName(context, pkgName) val extensionPackage = getExtensionInfoFromPkgName(context, pkgName)
if (extensionPackage == null) { if (extensionPackage == null) {
logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" } logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" }
@@ -223,7 +223,8 @@ internal object ExtensionLoader {
* @param context The application context. * @param context The application context.
* @param extensionInfo The extension to load. * @param extensionInfo The extension to load.
*/ */
private fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult { @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount")
private suspend fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult {
val pkgManager = context.packageManager val pkgManager = context.packageManager
val pkgInfo = extensionInfo.packageInfo val pkgInfo = extensionInfo.packageInfo
val appInfo = pkgInfo.applicationInfo val appInfo = pkgInfo.applicationInfo
@@ -252,7 +253,7 @@ internal object ExtensionLoader {
if (signatures.isNullOrEmpty()) { if (signatures.isNullOrEmpty()) {
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
return LoadResult.Error return LoadResult.Error
} else if (!trustExtension.isTrusted(pkgInfo, signatures.last())) { } else if (!trustExtension.isTrusted(pkgInfo, signatures)) {
val extension = Extension.Untrusted( val extension = Extension.Untrusted(
extName, extName,
pkgName, pkgName,
@@ -806,6 +806,11 @@ class EHentai(
override fun pageListParse(response: Response) = override fun pageListParse(response: Response) =
throw UnsupportedOperationException("Unused method was called somehow!") throw UnsupportedOperationException("Unused method was called somehow!")
override suspend fun getImageUrl(page: Page): String {
val imageUrlResponse = client.newCall(imageUrlRequest(page)).awaitSuccess()
return realImageUrlParse(imageUrlResponse, page)
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getImageUrl")) @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getImageUrl"))
override fun fetchImageUrl(page: Page): Observable<String> { override fun fetchImageUrl(page: Page): Observable<String> {
return client.newCall(imageUrlRequest(page)) return client.newCall(imageUrlRequest(page))
@@ -109,8 +109,6 @@ class MergedSource : HttpSource() {
suspend fun fetchChaptersForMergedManga( suspend fun fetchChaptersForMergedManga(
manga: Manga, manga: Manga,
downloadChapters: Boolean = true, downloadChapters: Boolean = true,
editScanlators: Boolean = false,
dedupe: Boolean = true,
) { ) {
fetchChaptersAndSync(manga, downloadChapters) fetchChaptersAndSync(manga, downloadChapters)
} }
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@@ -195,7 +196,9 @@ class ExtensionsScreenModel(
} }
fun trustExtension(extension: Extension.Untrusted) { fun trustExtension(extension: Extension.Untrusted) {
extensionManager.trust(extension) screenModelScope.launch {
extensionManager.trust(extension)
}
} }
@Immutable @Immutable
@@ -280,7 +280,7 @@ open class FeedScreenModel(
} }
@Composable @Composable
fun getManga(initialManga: DomainManga, source: CatalogueSource?): State<DomainManga> { fun getManga(initialManga: DomainManga): State<DomainManga> {
return produceState(initialValue = initialManga) { return produceState(initialValue = initialManga) {
getManga.subscribe(initialManga.url, initialManga.source) getManga.subscribe(initialManga.url, initialManga.source)
.collectLatest { manga -> .collectLatest { manga ->
@@ -87,7 +87,7 @@ fun Screen.feedTab(): TabContent {
navigator.push(MangaScreen(manga.id, true)) navigator.push(MangaScreen(manga.id, true))
}, },
onRefresh = screenModel::init, onRefresh = screenModel::init,
getMangaState = { manga, source -> screenModel.getManga(initialManga = manga, source = source) }, getMangaState = { manga -> screenModel.getManga(initialManga = manga) },
) )
state.dialog?.let { dialog -> state.dialog?.let { dialog ->
@@ -46,9 +46,15 @@ import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import java.io.Serializable
import kotlin.math.roundToInt import kotlin.math.roundToInt
class PreMigrationScreen(val mangaIds: List<Long>) : Screen() { sealed class MigrationType : Serializable {
data class MangaList(val mangaIds: List<Long>) : MigrationType()
data class MangaSingle(val fromMangaId: Long, val toManga: Long?) : MigrationType()
}
class PreMigrationScreen(val migration: MigrationType) : Screen() {
@Composable @Composable
override fun Content() { override fun Content() {
val screenModel = rememberScreenModel { PreMigrationScreenModel() } val screenModel = rememberScreenModel { PreMigrationScreenModel() }
@@ -173,7 +179,7 @@ class PreMigrationScreen(val mangaIds: List<Long>) : Screen() {
screenModel.onMigrationSheet(false) screenModel.onMigrationSheet(false)
screenModel.saveEnabledSources() screenModel.saveEnabledSources()
navigator replace MigrationListScreen(MigrationProcedureConfig(mangaIds, extraParam)) navigator replace MigrationListScreen(MigrationProcedureConfig(migration, extraParam))
}, },
) )
} }
@@ -184,10 +190,22 @@ class PreMigrationScreen(val mangaIds: List<Long>) : Screen() {
navigator.push( navigator.push(
if (skipPre) { if (skipPre) {
MigrationListScreen( MigrationListScreen(
MigrationProcedureConfig(mangaIds, null), MigrationProcedureConfig(MigrationType.MangaList(mangaIds), null),
) )
} else { } else {
PreMigrationScreen(mangaIds) PreMigrationScreen(MigrationType.MangaList(mangaIds))
},
)
}
fun navigateToMigration(skipPre: Boolean, navigator: Navigator, fromMangaId: Long, toManga: Long?) {
navigator.push(
if (skipPre) {
MigrationListScreen(
MigrationProcedureConfig(MigrationType.MangaSingle(fromMangaId, toManga), null),
)
} else {
PreMigrationScreen(MigrationType.MangaSingle(fromMangaId, toManga))
}, },
) )
} }
@@ -121,7 +121,7 @@ class MigrationListScreen(private val config: MigrationProcedureConfig) : Screen
) )
val onDismissRequest = { screenModel.dialog.value = null } val onDismissRequest = { screenModel.dialog.value = null }
when (val dialog = dialog) { when (@Suppress("NAME_SHADOWING") val dialog = dialog) {
is MigrationListScreenModel.Dialog.MigrateMangaDialog -> { is MigrationListScreenModel.Dialog.MigrateMangaDialog -> {
MigrationMangaDialog( MigrationMangaDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.getNameForMangaInfo import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.source.online.all.EHentai import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.MigrationType
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigratingManga.SearchResult import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigratingManga.SearchResult
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import exh.eh.EHentaiThrottleManager import exh.eh.EHentaiThrottleManager
@@ -104,8 +105,14 @@ class MigrationListScreenModel(
init { init {
screenModelScope.launchIO { screenModelScope.launchIO {
val mangaIds = when (val migration = config.migration) {
is MigrationType.MangaList -> {
migration.mangaIds
}
is MigrationType.MangaSingle -> listOf(migration.fromMangaId)
}
runMigrations( runMigrations(
config.mangaIds mangaIds
.map { .map {
async { async {
val manga = getManga.await(it) ?: return@async null val manga = getManga.await(it) ?: return@async null
@@ -161,9 +168,13 @@ class MigrationListScreenModel(
break break
} }
// in case it was removed // in case it was removed
if (manga.manga.id !in config.mangaIds) { when (val migration = config.migration) {
continue is MigrationType.MangaList -> if (manga.manga.id !in migration.mangaIds) {
continue
}
else -> Unit
} }
if (manga.searchResult.value == SearchResult.Searching && manga.migrationScope.isActive) { if (manga.searchResult.value == SearchResult.Searching && manga.migrationScope.isActive) {
val mangaObj = manga.manga val mangaObj = manga.manga
val mangaSource = sourceManager.getOrStub(mangaObj.source) val mangaSource = sourceManager.getOrStub(mangaObj.source)
@@ -175,6 +186,28 @@ class MigrationListScreenModel(
} else { } else {
sources.filter { it.id != mangaSource.id } sources.filter { it.id != mangaSource.id }
} }
when (val migration = config.migration) {
is MigrationType.MangaSingle -> if (migration.toManga != null) {
val localManga = getManga.await(migration.toManga)
if (localManga != null) {
val source = sourceManager.get(localManga.source) as? CatalogueSource
if (source != null) {
val chapters = if (source is EHentai) {
source.getChapterList(localManga.toSManga(), throttleManager::throttle)
} else {
source.getChapterList(localManga.toSManga())
}
try {
syncChaptersWithSource.await(chapters, localManga, source)
} catch (_: Exception) {
}
manga.progress.value = validSources.size to validSources.size
return@async localManga
}
}
}
else -> Unit
}
if (useSourceWithMost) { if (useSourceWithMost) {
val sourceSemaphore = Semaphore(3) val sourceSemaphore = Semaphore(3)
val processedSources = AtomicInteger() val processedSources = AtomicInteger()
@@ -523,13 +556,18 @@ class MigrationListScreenModel(
} }
fun removeManga(item: MigratingManga) { fun removeManga(item: MigratingManga) {
val ids = config.mangaIds.toMutableList() when (val migration = config.migration) {
val index = ids.indexOf(item.manga.id) is MigrationType.MangaList -> {
if (index > -1) { val ids = migration.mangaIds.toMutableList()
ids.removeAt(index) val index = ids.indexOf(item.manga.id)
config.mangaIds = ids if (index > -1) {
val index2 = migratingItems.value.orEmpty().indexOf(item) ids.removeAt(index)
if (index2 > -1) migratingItems.value = (migratingItems.value.orEmpty() - item).toImmutableList() config.migration = MigrationType.MangaList(ids)
val index2 = migratingItems.value.orEmpty().indexOf(item)
if (index2 > -1) migratingItems.value = (migratingItems.value.orEmpty() - item).toImmutableList()
}
}
is MigrationType.MangaSingle -> Unit
} }
} }
@@ -1,8 +1,9 @@
package eu.kanade.tachiyomi.ui.browse.migration.advanced.process package eu.kanade.tachiyomi.ui.browse.migration.advanced.process
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.MigrationType
import java.io.Serializable import java.io.Serializable
data class MigrationProcedureConfig( data class MigrationProcedureConfig(
var mangaIds: List<Long>, var migration: MigrationType,
val extraSearchParams: String?, val extraSearchParams: String?,
) : Serializable ) : Serializable
@@ -109,7 +109,7 @@ data class SourceSearchScreen(
} }
val onDismissRequest = { screenModel.setDialog(null) } val onDismissRequest = { screenModel.setDialog(null) }
when (val dialog = state.dialog) { when (state.dialog) {
is BrowseSourceScreenModel.Dialog.Filter -> { is BrowseSourceScreenModel.Dialog.Filter -> {
SourceFilterDialog( SourceFilterDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@@ -16,6 +16,7 @@ import eu.kanade.presentation.components.TabContent
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen
import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaScreen import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaScreen
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.DelicateCoroutinesApi
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.domain.UnsortedPreferences import tachiyomi.domain.UnsortedPreferences
@@ -55,6 +56,7 @@ fun Screen.migrateSourceTab(): TabContent {
// SY --> // SY -->
onClickAll = { source -> onClickAll = { source ->
// TODO: Jay wtf, need to clean this up sometime // TODO: Jay wtf, need to clean this up sometime
@OptIn(DelicateCoroutinesApi::class)
launchIO { launchIO {
val manga = Injekt.get<GetFavorites>().await() val manga = Injekt.get<GetFavorites>().await()
val sourceMangas = val sourceMangas =
@@ -17,6 +17,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip import androidx.compose.material3.InputChip
import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -154,7 +155,7 @@ fun AutoCompleteTextField(
null null
}, },
modifier = Modifier modifier = Modifier
.menuAnchor() .menuAnchor(MenuAnchorType.PrimaryEditable)
.fillMaxWidth() .fillMaxWidth()
.runOnEnterKeyPressed { submit() }, .runOnEnterKeyPressed { submit() },
singleLine = true, singleLine = true,
@@ -49,6 +49,7 @@ import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing
import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen
@@ -62,6 +63,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import tachiyomi.core.common.Constants import tachiyomi.core.common.Constants
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.UnsortedPreferences
import tachiyomi.domain.source.model.StubSource import tachiyomi.domain.source.model.StubSource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
@@ -69,6 +71,8 @@ import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.source.local.LocalSource import tachiyomi.source.local.LocalSource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
data class BrowseSourceScreen( data class BrowseSourceScreen(
private val sourceId: Long, private val sourceId: Long,
@@ -319,6 +323,16 @@ data class BrowseSourceScreen(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onConfirm = { screenModel.addFavorite(dialog.manga) }, onConfirm = { screenModel.addFavorite(dialog.manga) },
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) }, onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
onMigrate = {
// SY -->
PreMigrationScreen.navigateToMigration(
Injekt.get<UnsortedPreferences>().skipPreMigration().get(),
navigator,
dialog.duplicate.id,
dialog.manga.id,
)
// SY <--
},
) )
} }
is BrowseSourceScreenModel.Dialog.RemoveManga -> { is BrowseSourceScreenModel.Dialog.RemoveManga -> {
@@ -455,7 +455,7 @@ open class BrowseSourceScreenModel(
val manga: Manga, val manga: Manga,
val initialSelection: ImmutableList<CheckboxState.State<Category>>, val initialSelection: ImmutableList<CheckboxState.State<Category>>,
) : Dialog ) : Dialog
data class Migrate(val newManga: Manga) : Dialog data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
// SY --> // SY -->
data class DeleteSavedSearch(val idToDelete: Long, val name: String) : Dialog data class DeleteSavedSearch(val idToDelete: Long, val name: String) : Dialog
@@ -59,6 +59,7 @@ import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@@ -233,6 +234,9 @@ class LibraryScreenModel(
prefs.filterBookmarked, prefs.filterBookmarked,
prefs.filterCompleted, prefs.filterCompleted,
prefs.filterIntervalCustom, prefs.filterIntervalCustom,
// SY -->
prefs.filterLewd,
// SY <--
) + trackFilter.values ) + trackFilter.values
).any { it != TriState.DISABLED } ).any { it != TriState.DISABLED }
} }
@@ -740,6 +744,7 @@ class LibraryScreenModel(
clearSelection() clearSelection()
} }
@OptIn(DelicateCoroutinesApi::class)
fun syncMangaToDex() { fun syncMangaToDex() {
launchIO { launchIO {
MdUtil.getEnabledMangaDex(unsortedPreferences, sourcePreferences, sourceManager)?.let { mdex -> MdUtil.getEnabledMangaDex(unsortedPreferences, sourcePreferences, sourceManager)?.let { mdex ->
@@ -164,10 +164,10 @@ object LibraryTab : Tab {
} }
}, },
onClickSyncNow = { onClickSyncNow = {
if (!SyncDataJob.isAnyJobRunning(context)) { if (!SyncDataJob.isRunning(context)) {
SyncDataJob.startNow(context) SyncDataJob.startNow(context)
} else { } else {
context.toast(MR.strings.sync_in_progress) context.toast(SYMR.strings.sync_in_progress)
} }
}, },
// SY --> // SY -->

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