Compare commits

..

65 Commits

Author SHA1 Message Date
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
277 changed files with 5715 additions and 3301 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ When creating a fork, remember to:
8. Click publish
9. Go to API & Services -> Credentials
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
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/`
+11 -29
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
plugins {
id("com.android.application")
id("mihon.android.application")
id("mihon.android.application.compose")
id("com.mikepenz.aboutlibraries.plugin")
kotlin("android")
kotlin("plugin.parcelize")
kotlin("plugin.serialization")
// id("com.github.zellius.shortcut-helper")
@@ -26,7 +29,7 @@ android {
defaultConfig {
applicationId = "eu.kanade.tachiyomi.sy"
versionCode = 66
versionCode = 67
versionName = "1.10.5"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
@@ -120,7 +123,6 @@ android {
buildFeatures {
viewBinding = true
compose = true
buildConfig = true
// Disable some unused things
@@ -133,10 +135,6 @@ android {
abortOnError = false
checkReleaseBuilds = false
}
composeOptions {
kotlinCompilerExtensionVersion = compose.versions.compiler.get()
}
}
dependencies {
@@ -154,7 +152,6 @@ dependencies {
implementation(projects.presentationWidget)
// Compose
implementation(platform(compose.bom))
implementation(compose.activity)
implementation(compose.foundation)
implementation(compose.material3.core)
@@ -165,7 +162,6 @@ dependencies {
debugImplementation(compose.ui.tooling)
implementation(compose.ui.tooling.preview)
implementation(compose.ui.util)
implementation(compose.accompanist.webview)
implementation(compose.accompanist.systemuicontroller)
implementation(androidx.paging.runtime)
@@ -247,6 +243,9 @@ dependencies {
implementation(libs.bundles.voyager)
implementation(libs.compose.materialmotion)
implementation(libs.swipe)
implementation(libs.compose.webview)
implementation(libs.compose.grid)
implementation(libs.google.api.services.drive)
implementation(libs.google.api.client.oauth)
@@ -268,6 +267,8 @@ dependencies {
// debugImplementation(libs.leakcanary.android)
implementation(libs.leakcanary.plumber)
testImplementation(kotlinx.coroutines.test)
// SY -->
// Text distance (EH)
implementation(sylibs.simularity)
@@ -320,25 +321,6 @@ tasks {
"-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-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 tachiyomi.**
-keep,allowoptimization class mihon.**
# Keep common dependencies used in extensions
-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.SyncChaptersWithSource
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.GetExtensionRepos
import eu.kanade.domain.extension.interactor.GetExtensionSources
import eu.kanade.domain.extension.interactor.GetExtensionsByType
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.SyncChapterProgressWithTrack
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.chapter.ChapterRepositoryImpl
import tachiyomi.data.history.HistoryRepositoryImpl
@@ -111,6 +118,7 @@ class DomainModule : InjektModule {
addFactory { GetMangaByUrlAndSourceId(get()) }
addFactory { GetManga(get()) }
addFactory { GetNextChapters(get(), get(), get(), get()) }
addFactory { GetUpcomingManga(get()) }
addFactory { ResetViewerFlags(get()) }
addFactory { SetMangaChapterFlags(get()) }
addFactory { FetchInterval(get()) }
@@ -173,8 +181,13 @@ class DomainModule : InjektModule {
addFactory { ToggleSourcePin(get()) }
addFactory { TrustExtension(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 { GetExtensionRepos(get()) }
addFactory { ReplaceExtensionRepo(get()) }
addFactory { UpdateExtensionRepo(get(), get()) }
}
}
@@ -32,4 +32,6 @@ class BasePreferences(
SHIZUKU(MR.strings.ext_installer_shizuku, 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()
}
}
@@ -13,6 +13,8 @@ class SyncPreferences(
fun clientAPIKey() = preferenceStore.getString("sync_client_api_key", "")
fun lastSyncTimestamp() = preferenceStore.getLong(Preference.appStateKey("last_sync_timestamp"), 0L)
fun lastSyncEtag() = preferenceStore.getString("sync_etag", "")
fun syncInterval() = preferenceStore.getInt("sync_interval", 0)
fun syncService() = preferenceStore.getInt("sync_service", 0)
@@ -53,6 +55,11 @@ class SyncPreferences(
appSettings = preferenceStore.getBoolean("appSettings", true).get(),
sourceSettings = preferenceStore.getBoolean("sourceSettings", 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("sourceSettings", true).set(syncSettings.sourceSettings)
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 {
@@ -9,4 +9,9 @@ data class SyncSettings(
val appSettings: Boolean = true,
val sourceSettings: Boolean = true,
val privateSettings: Boolean = false,
// SY -->
val customInfo: Boolean = true,
val readEntries: Boolean = true
// SY <--
)
@@ -70,7 +70,7 @@ fun FeedScreen(
onClickDelete: (FeedSavedSearch) -> Unit,
onClickManga: (Manga) -> Unit,
onRefresh: () -> Unit,
getMangaState: @Composable (Manga, CatalogueSource?) -> State<Manga>,
getMangaState: @Composable (Manga) -> State<Manga>,
) {
when {
state.isLoading -> LoadingScreen()
@@ -119,7 +119,7 @@ fun FeedScreen(
) {
FeedItem(
item = item,
getMangaState = { getMangaState(it, item.source) },
getMangaState = { getMangaState(it) },
onClickManga = onClickManga,
)
}
@@ -129,7 +129,7 @@ private fun LibraryRegularToolbar(
onClick = onClickOpenRandomManga,
),
AppBar.OverflowAction(
title = stringResource(MR.strings.sync_library),
title = stringResource(SYMR.strings.sync_library),
onClick = onClickSyncNow,
),
).builder().apply {
@@ -1,16 +1,33 @@
package eu.kanade.presentation.manga
import androidx.compose.foundation.clickable
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.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.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
@Composable
@@ -18,42 +35,92 @@ fun DuplicateMangaDialog(
onDismissRequest: () -> Unit,
onConfirm: () -> Unit,
onOpenManga: () -> Unit,
onMigrate: () -> Unit,
modifier: Modifier = Modifier,
) {
AlertDialog(
val minHeight = LocalPreferenceMinHeight.current
AdaptiveSheet(
modifier = modifier,
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.are_you_sure))
},
text = {
Text(text = stringResource(MR.strings.confirm_add_duplicate_manga))
},
confirmButton = {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
) {
Column(
modifier = Modifier
.padding(
vertical = TabbedDialogPaddings.Vertical,
horizontal = TabbedDialogPaddings.Horizontal,
)
.fillMaxWidth(),
) {
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(
onClick = {
onDismissRequest()
onOpenManga()
},
) {
Text(text = stringResource(MR.strings.action_show_manga))
}
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))
OutlinedButton(onClick = onDismissRequest, modifier = Modifier.fillMaxWidth()) {
Text(
modifier = Modifier
.padding(vertical = 8.dp),
text = stringResource(MR.strings.action_cancel),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleLarge,
fontSize = 16.sp,
)
}
}
},
)
}
}
}
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)
@@ -8,6 +8,8 @@ import android.provider.Settings
import android.webkit.WebStorage
import android.webkit.WebView
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
@@ -112,71 +114,54 @@ object SettingsAdvancedScreen : SearchableSettings {
val basePreferences = remember { Injekt.get<BasePreferences>() }
val networkPreferences = remember { Injekt.get<NetworkPreferences>() }
return buildList {
addAll(
listOf(
/* SY --> Preference.PreferenceItem.SwitchPreference(
pref = basePreferences.acraEnabled(),
title = stringResource(MR.strings.pref_enable_acra),
subtitle = stringResource(MR.strings.pref_acra_summary),
enabled = isPreviewBuildType || isReleaseBuildType,
), SY <-- */
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_dump_crash_logs),
subtitle = stringResource(MR.strings.pref_dump_crash_logs_summary),
onClick = {
scope.launch {
CrashLogUtil(context).dumpLogs()
}
},
),
/* SY --> Preference.PreferenceItem.SwitchPreference(
pref = networkPreferences.verboseLogging(),
title = stringResource(MR.strings.pref_verbose_logging),
subtitle = stringResource(MR.strings.pref_verbose_logging_summary),
onValueChanged = {
context.toast(MR.strings.requires_app_restart)
true
},
), SY <-- */
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_debug_info),
onClick = { navigator.push(DebugInfoScreen()) },
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_onboarding_guide),
onClick = { navigator.push(OnboardingScreen()) },
),
),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
add(
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_manage_notifications),
onClick = {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
}
context.startActivity(intent)
},
),
)
}
addAll(
listOf(
getBackgroundActivityGroup(),
getDataGroup(),
getNetworkGroup(networkPreferences = networkPreferences),
getLibraryGroup(),
getExtensionsGroup(basePreferences = basePreferences),
// SY -->
// getDownloaderGroup(),
getDataSaverGroup(),
getDeveloperToolsGroup(),
// SY <--
),
)
}
return listOf(
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_dump_crash_logs),
subtitle = stringResource(MR.strings.pref_dump_crash_logs_summary),
onClick = {
scope.launch {
CrashLogUtil(context).dumpLogs()
}
},
),
/* SY --> Preference.PreferenceItem.SwitchPreference(
pref = networkPreferences.verboseLogging(),
title = stringResource(MR.strings.pref_verbose_logging),
subtitle = stringResource(MR.strings.pref_verbose_logging_summary),
onValueChanged = {
context.toast(MR.strings.requires_app_restart)
true
},
), SY <-- */
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_debug_info),
onClick = { navigator.push(DebugInfoScreen()) },
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_onboarding_guide),
onClick = { navigator.push(OnboardingScreen()) },
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_manage_notifications),
onClick = {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
}
context.startActivity(intent)
},
),
getBackgroundActivityGroup(),
getDataGroup(),
getNetworkGroup(networkPreferences = networkPreferences),
getLibraryGroup(),
getReaderGroup(basePreferences = basePreferences),
getExtensionsGroup(basePreferences = basePreferences),
// SY -->
// getDownloaderGroup(),
getDataSaverGroup(),
getDeveloperToolsGroup(),
// SY <--
)
}
@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
private fun getExtensionsGroup(
basePreferences: BasePreferences,
@@ -2,6 +2,7 @@ package eu.kanade.presentation.more.settings.screen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.util.system.AuthenticatorUtil.authenticate
import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.domain.UnsortedPreferences
import tachiyomi.i18n.MR
@@ -39,7 +41,9 @@ object SettingsBrowseScreen : SearchableSettings {
val navigator = LocalNavigator.currentOrThrow
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 -->
val scope = rememberCoroutineScope()
@@ -104,7 +108,7 @@ object SettingsBrowseScreen : SearchableSettings {
),
Preference.PreferenceItem.TextPreference(
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 = {
navigator.push(ExtensionReposScreen())
},
@@ -349,15 +349,15 @@ object SettingsDataScreen : SearchableSettings {
private fun getSyncPreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> {
return listOf(
Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_service_category),
title = stringResource(SYMR.strings.pref_sync_service_category),
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = syncPreferences.syncService(),
title = stringResource(MR.strings.pref_sync_service),
title = stringResource(SYMR.strings.pref_sync_service),
entries = persistentMapOf(
SyncManager.SyncService.NONE.value to stringResource(MR.strings.off),
SyncManager.SyncService.SYNCYOMI.value to stringResource(MR.strings.syncyomi),
SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(MR.strings.google_drive),
SyncManager.SyncService.SYNCYOMI.value to stringResource(SYMR.strings.syncyomi),
SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(SYMR.strings.google_drive),
),
onValueChanged = { true },
),
@@ -402,7 +402,7 @@ object SettingsDataScreen : SearchableSettings {
val googleDriveSync = Injekt.get<GoogleDriveService>()
return listOf(
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_google_drive_sign_in),
title = stringResource(SYMR.strings.pref_google_drive_sign_in),
onClick = {
val intent = googleDriveSync.getSignInIntent()
context.startActivity(intent)
@@ -427,19 +427,19 @@ object SettingsDataScreen : SearchableSettings {
val result = googleDriveSync.deleteSyncDataFromGoogleDrive()
when (result) {
GoogleDriveSyncService.DeleteSyncDataStatus.NOT_INITIALIZED -> context.toast(
MR.strings.google_drive_not_signed_in,
SYMR.strings.google_drive_not_signed_in,
duration = 5000,
)
GoogleDriveSyncService.DeleteSyncDataStatus.NO_FILES -> context.toast(
MR.strings.google_drive_sync_data_not_found,
SYMR.strings.google_drive_sync_data_not_found,
duration = 5000,
)
GoogleDriveSyncService.DeleteSyncDataStatus.SUCCESS -> context.toast(
MR.strings.google_drive_sync_data_purged,
SYMR.strings.google_drive_sync_data_purged,
duration = 5000,
)
GoogleDriveSyncService.DeleteSyncDataStatus.ERROR -> context.toast(
MR.strings.google_drive_sync_data_purge_error,
SYMR.strings.google_drive_sync_data_purge_error,
duration = 10000,
)
}
@@ -450,7 +450,7 @@ object SettingsDataScreen : SearchableSettings {
}
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 },
)
}
@@ -462,8 +462,8 @@ object SettingsDataScreen : SearchableSettings {
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(MR.strings.pref_purge_confirmation_title)) },
text = { Text(text = stringResource(MR.strings.pref_purge_confirmation_message)) },
title = { Text(text = stringResource(SYMR.strings.pref_purge_confirmation_title)) },
text = { Text(text = stringResource(SYMR.strings.pref_purge_confirmation_message)) },
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
@@ -482,8 +482,8 @@ object SettingsDataScreen : SearchableSettings {
val scope = rememberCoroutineScope()
return listOf(
Preference.PreferenceItem.EditTextPreference(
title = stringResource(MR.strings.pref_sync_host),
subtitle = stringResource(MR.strings.pref_sync_host_summ),
title = stringResource(SYMR.strings.pref_sync_host),
subtitle = stringResource(SYMR.strings.pref_sync_host_summ),
pref = syncPreferences.clientHost(),
onValueChanged = { newValue ->
scope.launch {
@@ -496,8 +496,8 @@ object SettingsDataScreen : SearchableSettings {
},
),
Preference.PreferenceItem.EditTextPreference(
title = stringResource(MR.strings.pref_sync_api_key),
subtitle = stringResource(MR.strings.pref_sync_api_key_summ),
title = stringResource(SYMR.strings.pref_sync_api_key),
subtitle = stringResource(SYMR.strings.pref_sync_api_key_summ),
pref = syncPreferences.clientAPIKey(),
),
)
@@ -507,12 +507,12 @@ object SettingsDataScreen : SearchableSettings {
private fun getSyncNowPref(): Preference.PreferenceGroup {
val navigator = LocalNavigator.currentOrThrow
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_now_group_title),
title = stringResource(SYMR.strings.pref_sync_now_group_title),
preferenceItems = persistentListOf(
getSyncOptionsPref(),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_sync_now),
subtitle = stringResource(MR.strings.pref_sync_now_subtitle),
title = stringResource(SYMR.strings.pref_sync_now),
subtitle = stringResource(SYMR.strings.pref_sync_now_subtitle),
onClick = {
navigator.push(SyncSettingsSelector())
},
@@ -525,8 +525,8 @@ object SettingsDataScreen : SearchableSettings {
private fun getSyncOptionsPref(): Preference.PreferenceItem.TextPreference {
val navigator = LocalNavigator.currentOrThrow
return Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_sync_options),
subtitle = stringResource(MR.strings.pref_sync_options_summ),
title = stringResource(SYMR.strings.pref_sync_options),
subtitle = stringResource(SYMR.strings.pref_sync_options_summ),
onClick = { navigator.push(SyncTriggerOptionsScreen()) },
)
}
@@ -538,16 +538,16 @@ object SettingsDataScreen : SearchableSettings {
val lastSync by syncPreferences.lastSyncTimestamp().collectAsState()
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_automatic_category),
title = stringResource(SYMR.strings.pref_sync_automatic_category),
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = syncIntervalPref,
title = stringResource(MR.strings.pref_sync_interval),
title = stringResource(SYMR.strings.pref_sync_interval),
entries = persistentMapOf(
0 to stringResource(MR.strings.off),
30 to stringResource(MR.strings.update_30min),
60 to stringResource(MR.strings.update_1hour),
180 to stringResource(MR.strings.update_3hour),
30 to stringResource(SYMR.strings.update_30min),
60 to stringResource(SYMR.strings.update_1hour),
180 to stringResource(SYMR.strings.update_3hour),
360 to stringResource(MR.strings.update_6hour),
720 to stringResource(MR.strings.update_12hour),
1440 to stringResource(MR.strings.update_24hour),
@@ -560,7 +560,7 @@ object SettingsDataScreen : SearchableSettings {
},
),
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 -->
val forceHorizontalSeekbar by readerPref.forceHorizontalSeekbar().collectAsState()
// SY <--
return listOf(
Preference.PreferenceItem.ListPreference(
pref = readerPref.defaultReadingMode(),
@@ -81,12 +82,6 @@ object SettingsReaderScreen : SearchableSettings {
enabled = !forceHorizontalSeekbar,
),
// 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 -->
Preference.PreferenceItem.SwitchPreference(
pref = readerPref.pageTransitions(),
@@ -56,10 +56,9 @@ import tachiyomi.presentation.core.icons.Reddit
import tachiyomi.presentation.core.icons.X
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
object AboutScreen : Screen() {
@@ -293,11 +292,15 @@ object AboutScreen : Screen() {
internal fun getFormattedBuildTime(): String {
return try {
val df = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm'Z'", Locale.US)
.withZone(ZoneId.of("UTC"))
val buildTime = LocalDateTime.from(df.parse(BuildConfig.BUILD_TIME))
buildTime!!.toDateTimestampString(UiPreferences.dateFormat(Injekt.get<UiPreferences>().dateFormat().get()))
LocalDateTime.ofInstant(
Instant.parse(BuildConfig.BUILD_TIME),
ZoneId.systemDefault(),
)
.toDateTimestampString(
UiPreferences.dateFormat(
Injekt.get<UiPreferences>().dateFormat().get(),
),
)
} catch (e: Exception) {
BuildConfig.BUILD_TIME
}
@@ -32,12 +32,13 @@ class OpenSourceLicensesScreen : Screen() {
.fillMaxSize(),
contentPadding = contentPadding,
onLibraryClick = {
val libraryLicenseScreen = OpenSourceLibraryLicenseScreen(
name = it.library.name,
website = it.library.website,
license = it.library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(),
navigator.push(
OpenSourceLibraryLicenseScreen(
name = it.name,
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.navigator.LocalNavigator
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.ExtensionRepoDeleteDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.flow.collectLatest
import tachiyomi.presentation.core.screens.LoadingScreen
@@ -42,17 +45,19 @@ class ExtensionReposScreen(
ExtensionReposScreen(
state = successState,
onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
onOpenWebsite = { context.openInBrowser(it.website) },
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
onClickRefresh = { screenModel.refreshRepos() },
navigateUp = navigator::pop,
)
when (val dialog = successState.dialog) {
null -> {}
RepoDialog.Create -> {
is RepoDialog.Create -> {
ExtensionRepoCreateDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(it) },
repos = successState.repos,
repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(),
)
}
is RepoDialog.Delete -> {
@@ -62,6 +67,15 @@ class ExtensionReposScreen(
repo = dialog.repo,
)
}
is RepoDialog.Conflict -> {
ExtensionRepoConflictDialog(
onDismissRequest = screenModel::dismissDialog,
onMigrate = { screenModel.replaceRepo(dialog.newRepo) },
oldRepo = dialog.oldRepo,
newRepo = dialog.newRepo,
)
}
}
LaunchedEffect(Unit) {
@@ -4,24 +4,29 @@ import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource
import eu.kanade.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.toImmutableSet
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
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.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionReposScreenModel(
private val getExtensionRepos: GetExtensionRepos = Injekt.get(),
private val getExtensionRepo: GetExtensionRepo = Injekt.get(),
private val createExtensionRepo: CreateExtensionRepo = Injekt.get(),
private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(),
private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(),
private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(),
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
@@ -29,7 +34,7 @@ class ExtensionReposScreenModel(
init {
screenModelScope.launchIO {
getExtensionRepos.subscribe()
getExtensionRepo.subscribeAll()
.collectLatest { repos ->
mutableState.update {
RepoScreenState.Success(
@@ -43,25 +48,51 @@ class ExtensionReposScreenModel(
/**
* 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 {
when (createExtensionRepo.await(name)) {
is CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
when (val result = createExtensionRepo.await(baseUrl)) {
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 -> {}
}
}
}
/**
* 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 {
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 LocalizedMessage(val stringRes: StringResource) : RepoEvent()
data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name)
data object RepoAlreadyExists : LocalizedMessage(MR.strings.error_repo_exists)
}
sealed class RepoDialog {
data object Create : RepoDialog()
data class Delete(val repo: String) : RepoDialog()
data class Conflict(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : RepoDialog()
}
sealed class RepoScreenState {
@@ -101,7 +134,8 @@ sealed class RepoScreenState {
@Immutable
data class Success(
val repos: ImmutableSet<String>,
val repos: ImmutableSet<ExtensionRepo>,
val oldRepos: ImmutableSet<String>? = null,
val dialog: RepoDialog? = null,
) : RepoScreenState() {
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
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.Delete
import androidx.compose.material3.ElevatedCard
@@ -22,15 +23,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.collections.immutable.ImmutableSet
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun ExtensionReposContent(
repos: ImmutableSet<String>,
repos: ImmutableSet<ExtensionRepo>,
lazyListState: LazyListState,
paddingValues: PaddingValues,
onOpenWebsite: (ExtensionRepo) -> Unit,
onClickDelete: (String) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -45,7 +48,8 @@ fun ExtensionReposContent(
ExtensionRepoListItem(
modifier = Modifier.animateItemPlacement(),
repo = it,
onDelete = { onClickDelete(it) },
onOpenWebsite = { onOpenWebsite(it) },
onDelete = { onClickDelete(it.baseUrl) },
)
}
}
@@ -54,7 +58,8 @@ fun ExtensionReposContent(
@Composable
private fun ExtensionRepoListItem(
repo: String,
repo: ExtensionRepo,
onOpenWebsite: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -74,16 +79,27 @@ private fun ExtensionRepoListItem(
verticalAlignment = Alignment.CenterVertically,
) {
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(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = onOpenWebsite) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
contentDescription = stringResource(MR.strings.action_open_in_browser),
)
}
IconButton(
onClick = {
val url = "$repo/index.min.json"
val url = "${repo.baseUrl}/index.min.json"
context.copyToClipboard(url, url)
},
) {
@@ -16,6 +16,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.coroutines.delay
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.Duration.Companion.seconds
@@ -24,12 +25,12 @@ import kotlin.time.Duration.Companion.seconds
fun ExtensionRepoCreateDialog(
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
repos: ImmutableSet<String>,
repoUrls: ImmutableSet<String>,
) {
var name by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { repos.contains(name) }
val nameAlreadyExists = remember(name) { repoUrls.contains(name) }
AlertDialog(
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.padding
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.runtime.Composable
import androidx.compose.ui.Modifier
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
@@ -23,7 +28,9 @@ import tachiyomi.presentation.core.util.plus
fun ExtensionReposScreen(
state: RepoScreenState.Success,
onClickCreate: () -> Unit,
onOpenWebsite: (ExtensionRepo) -> Unit,
onClickDelete: (String) -> Unit,
onClickRefresh: () -> Unit,
navigateUp: () -> Unit,
) {
val lazyListState = rememberLazyListState()
@@ -33,6 +40,14 @@ fun ExtensionReposScreen(
navigateUp = navigateUp,
title = stringResource(MR.strings.label_extension_repos),
scrollBehavior = scrollBehavior,
actions = {
IconButton(onClick = onClickRefresh) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = stringResource(resource = MR.strings.action_webview_refresh),
)
}
},
)
},
floatingActionButton = {
@@ -55,6 +70,7 @@ fun ExtensionReposScreen(
lazyListState = lazyListState,
paddingValues = paddingValues + topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium),
onOpenWebsite = onOpenWebsite,
onClickDelete = onClickDelete,
)
}
@@ -20,6 +20,7 @@ import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.update
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction
import tachiyomi.presentation.core.components.SectionCard
@@ -39,7 +40,7 @@ class SyncSettingsSelector : Screen() {
Scaffold(
topBar = {
AppBar(
title = stringResource(MR.strings.pref_choose_what_to_sync),
title = stringResource(SYMR.strings.pref_choose_what_to_sync),
navigateUp = navigator::pop,
scrollBehavior = it,
)
@@ -47,14 +48,14 @@ class SyncSettingsSelector : Screen() {
) { contentPadding ->
LazyColumnWithAction(
contentPadding = contentPadding,
actionLabel = stringResource(MR.strings.label_sync),
actionLabel = stringResource(SYMR.strings.label_sync),
actionEnabled = state.options.anyEnabled(),
onClickAction = {
if (!SyncDataJob.isAnyJobRunning(context)) {
if (!SyncDataJob.isRunning(context)) {
model.syncNow(context)
navigator.pop()
} 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,
sourceSettings = syncSettings.sourceSettings,
privateSettings = syncSettings.privateSettings,
// SY -->
customInfo = syncSettings.customInfo,
readEntries = syncSettings.readEntries,
// SY <--
)
}
@@ -136,6 +142,11 @@ private class SyncSettingsSelectorModel(
appSettings = backupOptions.appSettings,
sourceSettings = backupOptions.sourceSettings,
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.coroutines.flow.update
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction
import tachiyomi.presentation.core.components.SectionCard
@@ -34,7 +35,7 @@ class SyncTriggerOptionsScreen : Screen() {
Scaffold(
topBar = {
AppBar(
title = stringResource(MR.strings.pref_sync_options),
title = stringResource(SYMR.strings.pref_sync_options),
navigateUp = navigator::pop,
scrollBehavior = it,
)
@@ -43,13 +44,13 @@ class SyncTriggerOptionsScreen : Screen() {
LazyColumnWithAction(
contentPadding = contentPadding,
actionLabel = stringResource(MR.strings.action_save),
actionEnabled = state.options.anyEnabled(),
actionEnabled = true,
onClickAction = {
navigator.pop()
},
) {
item {
SectionCard(MR.strings.label_triggers) {
SectionCard(SYMR.strings.label_triggers) {
Options(SyncTriggerOptions.mainOptions, state, model)
}
}
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.FlipToBack
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.SelectAll
@@ -50,6 +51,7 @@ fun UpdateScreen(
onClickCover: (UpdatesItem) -> Unit,
onSelectAll: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Boolean,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
@@ -63,6 +65,7 @@ fun UpdateScreen(
Scaffold(
topBar = { scrollBehavior ->
UpdatesAppBar(
onCalendarClicked = { onCalendarClicked() },
onUpdateLibrary = { onUpdateLibrary() },
actionModeCounter = state.selected.size,
onSelectAll = { onSelectAll(true) },
@@ -132,6 +135,7 @@ fun UpdateScreen(
@Composable
private fun UpdatesAppBar(
onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Unit,
// For action mode
actionModeCounter: Int,
@@ -147,6 +151,11 @@ private fun UpdatesAppBar(
actions = {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.action_view_upcoming),
icon = Icons.Outlined.CalendarMonth,
onClick = onCalendarClicked,
),
AppBar.Action(
title = stringResource(MR.strings.action_update_library),
icon = Icons.Outlined.Refresh,
@@ -30,12 +30,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.google.accompanist.web.AccompanistWebViewClient
import com.google.accompanist.web.LoadingState
import com.google.accompanist.web.WebContent
import com.google.accompanist.web.WebView
import com.google.accompanist.web.rememberWebViewNavigator
import com.google.accompanist.web.rememberWebViewState
import com.kevinnzou.web.AccompanistWebViewClient
import com.kevinnzou.web.LoadingState
import com.kevinnzou.web.WebContent
import com.kevinnzou.web.WebView
import com.kevinnzou.web.rememberWebViewNavigator
import com.kevinnzou.web.rememberWebViewState
import eu.kanade.presentation.components.AppBar
import eu.kanade.tachiyomi.BuildConfig
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.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import com.google.accompanist.web.AccompanistWebViewClient
import com.google.accompanist.web.LoadingState
import com.google.accompanist.web.WebView
import com.google.accompanist.web.rememberWebViewNavigator
import com.google.accompanist.web.rememberWebViewState
import com.kevinnzou.web.AccompanistWebViewClient
import com.kevinnzou.web.LoadingState
import com.kevinnzou.web.WebView
import com.kevinnzou.web.rememberWebViewNavigator
import com.kevinnzou.web.rememberWebViewState
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.WarningBanner
+26 -28
View File
@@ -17,8 +17,6 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565
import coil3.request.crossfade
@@ -72,8 +70,12 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import logcat.LogPriority
import logcat.LogcatLogger
import mihon.core.migration.Migrator
import mihon.core.migration.migrations.migrations
import org.conscrypt.Conscrypt
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.domain.storage.service.StorageManager
import tachiyomi.i18n.MR
@@ -175,25 +177,42 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
) {
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 {
return ImageLoader.Builder(this).apply {
val callFactoryLazy = lazy { Injekt.get<NetworkHelper>().client }
val diskCacheLazy = lazy { CoilDiskCache.get(this@App) }
components {
add(OkHttpNetworkFetcherFactory(callFactoryLazy::value))
add(TachiyomiImageDecoder.Factory())
add(MangaCoverFetcher.MangaFactory(callFactoryLazy, diskCacheLazy))
add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy, diskCacheLazy))
add(MangaCoverFetcher.MangaFactory(callFactoryLazy))
add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy))
add(MangaKeyer())
add(MangaCoverKeyer())
// SY -->
add(PagePreviewKeyer())
add(PagePreviewFetcher.Factory(callFactoryLazy, diskCacheLazy))
add(PagePreviewFetcher.Factory(callFactoryLazy))
// SY <--
}
diskCache(diskCacheLazy::value)
crossfade((300 * this@App.animatorDurationScale).toInt())
allowRgb565(DeviceUtil.isLowRamDevice(this@App))
if (networkPreferences.verboseLogging().get()) logger(DebugLogger())
@@ -349,24 +368,3 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
}
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()) {
val dbCategories = getCategories.await()
val dbCategoriesByName = dbCategories.associateBy { it.name }
var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0
val categories = backupCategories.map {
dbCategoriesByName[it.name]
?: handler.awaitOneExecutable {
categoriesQueries.insert(it.name, it.order, it.flags)
categoriesQueries.selectLastInsertedRowId()
}.let { id -> it.toCategory(id) }
}
val categories = backupCategories
.sortedBy { it.order }
.distinctBy { it.name }
.map {
val newOrder = nextOrder++
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(
(dbCategories + categories)
@@ -313,7 +313,7 @@ class MangaRestorer(
restoreCategories(manga, categories, backupCategories)
restoreChapters(manga, chapters)
restoreTracking(manga, tracks)
restoreHistory(history)
restoreHistory(manga, history)
restoreExcludedScanlators(manga, excludedScanlators)
updateManga.awaitUpdateFetchInterval(manga, now, currentFetchWindow)
// 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 dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(history.url) }
val dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(manga.id, history.url) }
val item = history.getHistoryImpl()
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) {
// Chapter doesn't exist; skip
null
@@ -46,6 +46,7 @@ import java.io.IOException
* Available request parameter:
* - [USE_CUSTOM_COVER_KEY]: Use custom cover if set by user, default is true
*/
@Suppress("LongParameterList")
class MangaCoverFetcher(
private val url: String?,
private val isLibraryManga: Boolean,
@@ -55,7 +56,7 @@ class MangaCoverFetcher(
private val diskCacheKeyLazy: Lazy<String>,
private val sourceLazy: Lazy<HttpSource?>,
private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>,
private val imageLoader: ImageLoader,
) : Fetcher {
private val diskCacheKey: String
@@ -207,7 +208,7 @@ class MangaCoverFetcher(
private fun moveSnapshotToCoverCache(snapshot: DiskCache.Snapshot, cacheFile: File?): File? {
if (cacheFile == null) return null
return try {
diskCacheLazy.value.run {
imageLoader.diskCache?.run {
fileSystem.source(snapshot.data).use { input ->
writeSourceToCoverCache(input, cacheFile)
}
@@ -248,7 +249,7 @@ class MangaCoverFetcher(
private fun readFromDiskCache(): DiskCache.Snapshot? {
return if (options.diskCachePolicy.readEnabled) {
diskCacheLazy.value.openSnapshot(diskCacheKey)
imageLoader.diskCache?.openSnapshot(diskCacheKey)
} else {
null
}
@@ -257,9 +258,10 @@ class MangaCoverFetcher(
private fun writeToDiskCache(
response: Response,
): DiskCache.Snapshot? {
val editor = diskCacheLazy.value.openEditor(diskCacheKey) ?: return null
val diskCache = imageLoader.diskCache
val editor = diskCache?.openEditor(diskCacheKey) ?: return null
try {
diskCacheLazy.value.fileSystem.write(editor.data) {
diskCache.fileSystem.write(editor.data) {
response.body.source().readAll(this)
}
return editor.commitAndOpenSnapshot()
@@ -299,7 +301,6 @@ class MangaCoverFetcher(
class MangaFactory(
private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>,
) : Fetcher.Factory<Manga> {
private val coverCache: CoverCache by injectLazy()
@@ -312,17 +313,16 @@ class MangaCoverFetcher(
options = options,
coverFileLazy = lazy { coverCache.getCoverFile(data.thumbnailUrl) },
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 },
callFactoryLazy = callFactoryLazy,
diskCacheLazy = diskCacheLazy,
imageLoader = imageLoader,
)
}
}
class MangaCoverFactory(
private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>,
) : Fetcher.Factory<MangaCover> {
private val coverCache: CoverCache by injectLazy()
@@ -335,10 +335,10 @@ class MangaCoverFetcher(
options = options,
coverFileLazy = lazy { coverCache.getCoverFile(data.url) },
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 },
callFactoryLazy = callFactoryLazy,
diskCacheLazy = diskCacheLazy,
imageLoader = imageLoader,
)
}
}
@@ -34,6 +34,7 @@ import java.io.IOException
* Disk caching is handled by [PagePreviewCache], otherwise
* handled by Coil's [DiskCache].
*/
@Suppress("LongParameterList")
class PagePreviewFetcher(
private val page: PagePreview,
private val options: Options,
@@ -43,7 +44,7 @@ class PagePreviewFetcher(
private val diskCacheKeyLazy: Lazy<String>,
private val sourceLazy: Lazy<PagePreviewSource?>,
private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>,
private val imageLoader: ImageLoader,
) : Fetcher {
private val diskCacheKey: String
@@ -164,7 +165,7 @@ class PagePreviewFetcher(
private fun moveSnapshotToPagePreviewCache(snapshot: DiskCache.Snapshot): File? {
return try {
diskCacheLazy.value.run {
imageLoader.diskCache?.run {
fileSystem.source(snapshot.data).use { input ->
writeSourceToPagePreviewCache(input)
}
@@ -203,15 +204,16 @@ class PagePreviewFetcher(
}
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(
response: Response,
): DiskCache.Snapshot? {
val editor = diskCacheLazy.value.openEditor(diskCacheKey) ?: return null
val diskCache = imageLoader.diskCache
val editor = diskCache?.openEditor(diskCacheKey) ?: return null
try {
diskCacheLazy.value.fileSystem.write(editor.data) {
diskCache.fileSystem.write(editor.data) {
response.body.source().readAll(this)
}
return editor.commitAndOpenSnapshot()
@@ -235,7 +237,6 @@ class PagePreviewFetcher(
class Factory(
private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>,
) : Fetcher.Factory<PagePreview> {
private val pagePreviewCache: PagePreviewCache by injectLazy()
@@ -248,10 +249,10 @@ class PagePreviewFetcher(
pagePreviewFile = { pagePreviewCache.getImageFile(data.imageUrl) },
isInCache = { pagePreviewCache.isImageInCache(data.imageUrl) },
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 },
callFactoryLazy = callFactoryLazy,
diskCacheLazy = diskCacheLazy,
imageLoader = imageLoader,
)
}
}
@@ -9,7 +9,6 @@ import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.request.allowRgb565
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
@@ -46,7 +45,7 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
val bitmap = decoder.decode(rgb565 = options.allowRgb565)
val bitmap = decoder.decode()
decoder.recycle()
check(bitmap != null) { "Failed to decode image" }
@@ -823,7 +823,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
// Always sync the data before library update if syncing is enabled.
if (syncPreferences.isSyncEnabled()) {
// Check if SyncDataJob is already running
if (wm.isRunning(SyncDataJob.TAG_MANUAL)) {
if (SyncDataJob.isRunning(context)) {
// SyncDataJob is already running
return false
}
@@ -79,7 +79,7 @@ class ImageSaver(
MediaStore.Images.Media.RELATIVE_PATH to relativePath,
MediaStore.Images.Media.DISPLAY_NAME to image.name,
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) {
@@ -9,11 +9,14 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkQuery
import androidx.work.WorkerParameters
import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.cancelNotification
import eu.kanade.tachiyomi.util.system.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely
import eu.kanade.tachiyomi.util.system.workManager
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
@@ -27,12 +30,15 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
private val notifier = SyncNotifier(context)
override suspend fun doWork(): Result {
try {
setForeground(getForegroundInfo())
} catch (e: IllegalStateException) {
logcat(LogPriority.ERROR, e) { "Not allowed to run on foreground service" }
if (tags.contains(TAG_AUTO)) {
// Find a running manual worker. If exists, try again later
if (context.workManager.isRunning(TAG_MANUAL)) {
return Result.retry()
}
}
setForegroundSafely()
return try {
SyncManager(context).syncData()
Result.success()
@@ -62,10 +68,8 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
private const val TAG_AUTO = "$TAG_JOB:auto"
const val TAG_MANUAL = "$TAG_JOB:manual"
private val jobTagList = listOf(TAG_AUTO, TAG_MANUAL)
fun isAnyJobRunning(context: Context): Boolean {
return jobTagList.any { context.workManager.isRunning(it) }
fun isRunning(context: Context): Boolean {
return context.workManager.isRunning(TAG_JOB)
}
fun setupTask(context: Context, prefInterval: Int? = null) {
@@ -79,6 +83,7 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
10,
TimeUnit.MINUTES,
)
.addTag(TAG_JOB)
.addTag(TAG_AUTO)
.build()
@@ -89,14 +94,33 @@ class SyncDataJob(private val context: Context, workerParams: WorkerParameters)
}
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>()
.addTag(TAG_JOB)
.addTag(TAG_MANUAL)
.build()
context.workManager.enqueueUniqueWork(TAG_MANUAL, ExistingWorkPolicy.KEEP, request)
}
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,
sourceSettings = syncOptions.sourceSettings,
privateSettings = syncOptions.privateSettings,
// SY -->
customInfo = syncOptions.customInfo,
readEntries = syncOptions.readEntries,
// SY <--
)
logcat(LogPriority.DEBUG) { "Begin create backup" }
val backup = Backup(
backupManga = backupCreator.backupMangas(databaseManga, backupOptions),
backupCategories = backupCreator.backupCategories(backupOptions),
@@ -100,9 +107,11 @@ class SyncManager(
backupSavedSearches = backupCreator.backupSavedSearches(),
// SY <--
)
logcat(LogPriority.DEBUG) { "End create backup" }
// Create the SyncData object
val syncData = SyncData(
deviceId = syncPreferences.uniqueDeviceID(),
backup = backup,
)
@@ -129,8 +138,22 @@ class SyncManager(
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
if (remoteBackup?.backupManga?.size == 0) {
if (remoteBackup.backupManga?.size == 0) {
notifier.showSyncError("No data found on remote server.")
return
}
@@ -143,49 +166,47 @@ class SyncManager(
return
}
if (remoteBackup != null) {
val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup)
updateNonFavorites(nonFavorites)
val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup)
updateNonFavorites(nonFavorites)
val newSyncData = backup.copy(
backupManga = filteredFavorites,
backupCategories = remoteBackup.backupCategories,
backupSources = remoteBackup.backupSources,
backupPreferences = remoteBackup.backupPreferences,
backupSourcePreferences = remoteBackup.backupSourcePreferences,
val newSyncData = backup.copy(
backupManga = filteredFavorites,
backupCategories = remoteBackup.backupCategories,
backupSources = remoteBackup.backupSources,
backupPreferences = remoteBackup.backupPreferences,
backupSourcePreferences = remoteBackup.backupSourcePreferences,
// SY -->
backupSavedSearches = remoteBackup.backupSavedSearches,
// SY <--
// SY -->
backupSavedSearches = remoteBackup.backupSavedSearches,
// 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)
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,
),
)
// update the sync timestamp
syncPreferences.lastSyncTimestamp().set(Date().time)
} else {
logcat(LogPriority.ERROR) { "Failed to write sync data to file" }
}
// 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 kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
data class SyncTriggerOptions(
val syncOnChapterRead: Boolean = false,
@@ -25,22 +25,22 @@ data class SyncTriggerOptions(
companion object {
val mainOptions = persistentListOf(
Entry(
label = MR.strings.sync_on_chapter_read,
label = SYMR.strings.sync_on_chapter_read,
getter = SyncTriggerOptions::syncOnChapterRead,
setter = { options, enabled -> options.copy(syncOnChapterRead = enabled) },
),
Entry(
label = MR.strings.sync_on_chapter_open,
label = SYMR.strings.sync_on_chapter_open,
getter = SyncTriggerOptions::syncOnChapterOpen,
setter = { options, enabled -> options.copy(syncOnChapterOpen = enabled) },
),
Entry(
label = MR.strings.sync_on_app_start,
label = SYMR.strings.sync_on_app_start,
getter = SyncTriggerOptions::syncOnAppStart,
setter = { options, enabled -> options.copy(syncOnAppStart = enabled) },
),
Entry(
label = MR.strings.sync_on_app_resume,
label = SYMR.strings.sync_on_app_resume,
getter = SyncTriggerOptions::syncOnAppResume,
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.GoogleTokenResponse
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.json.JsonFactory
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.model.File
import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.tachiyomi.data.backup.models.Backup
import kotlinx.coroutines.delay
import kotlinx.serialization.encodeToString
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import logcat.LogPriority
import logcat.logcat
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.time.Instant
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
@@ -64,11 +70,47 @@ class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: Sync
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 {
googleDriveService.refreshToken()
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 retries = 0 // Retry counter
@@ -112,21 +154,17 @@ class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: Sync
if (retries >= maxRetries) {
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) {
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? {
val drive = googleDriveService.driveService
if (drive == null) {
logcat(LogPriority.DEBUG) { "Google Drive service not initialized" }
throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in))
}
private fun pullSyncData(): SyncData? {
val drive = googleDriveService.driveService ?:
throw Exception(context.stringResource(SYMR.strings.google_drive_not_signed_in))
val fileList = getAppDataFileList(drive)
if (fileList.isEmpty()) {
@@ -137,75 +175,53 @@ class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: Sync
val gdriveFileId = fileList[0].id
logcat(LogPriority.DEBUG) { "Google Drive File ID: $gdriveFileId" }
val outputStream = ByteArrayOutputStream()
try {
drive.files().get(gdriveFileId).executeMediaAndDownloadTo(outputStream)
logcat(LogPriority.DEBUG) { "File downloaded successfully" }
drive.files().get(gdriveFileId).executeMediaAsInputStream().use { inputStream ->
GZIPInputStream(inputStream).use { gzipInputStream ->
return Json.decodeFromStream(SyncData.serializer(), gzipInputStream)
}
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, throwable = e) { "Error downloading file" }
return null
}
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)
}
throw Exception("Failed to download sync data: ${e.message}", e)
}
}
override suspend fun pushSyncData(syncData: SyncData) {
val jsonData = json.encodeToString(syncData)
private suspend fun pushSyncData(syncData: SyncData) {
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 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()) {
// File exists, so update it
val fileId = fileList[0].id
drive.files().update(fileId, null, byteArrayContent).execute()
logcat(LogPriority.DEBUG) { "Updated existing sync data file in Google Drive with file ID: $fileId" }
} else {
// File doesn't exist, so create it
val fileMetadata = File().apply {
name = remoteFileName
mimeType = "application/gzip"
parents = listOf("appDataFolder")
if (fileList.isNotEmpty()) {
val fileId = fileList[0].id
val mediaContent = InputStreamContent("application/gzip", pis)
drive.files().update(fileId, null, mediaContent).execute()
logcat(LogPriority.DEBUG) { "Updated existing sync data file in Google Drive with file ID: $fileId" }
} else {
val fileMetadata = File().apply {
name = remoteFileName
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) {
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 {
val refreshToken = syncPreferences.googleDriveRefreshToken().get()
val accessToken = syncPreferences.googleDriveAccessToken().get()
val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance()
val secrets = GoogleClientSecrets.load(
@@ -407,21 +422,17 @@ class GoogleDriveService(private val context: Context) {
.build()
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
this@GoogleDriveService.logcat(LogPriority.DEBUG) { "Refreshing access token with: $refreshToken" }
try {
credential.refreshToken()
val newAccessToken = credential.accessToken
// Save the new access token
syncPreferences.googleDriveAccessToken().set(newAccessToken)
setupGoogleDriveService(newAccessToken, credential.refreshToken)
this@GoogleDriveService
.logcat(LogPriority.DEBUG) { "Google Access token refreshed old: $accessToken new: $newAccessToken" }
} catch (e: TokenResponseException) {
if (e.details.error == "invalid_grant") {
// The refresh token is invalid, prompt the user to sign in again
@@ -17,6 +17,7 @@ import logcat.logcat
@Serializable
data class SyncData(
val deviceId: String = "",
val backup: Backup? = null,
)
@@ -25,38 +26,7 @@ abstract class SyncService(
val json: Json,
val syncPreferences: SyncPreferences,
) {
open 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)
abstract suspend fun doSync(syncData: SyncData): Backup?;
/**
* 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.
* @return The JSON string containing the merged sync data.
*/
private fun mergeSyncData(localSyncData: SyncData, remoteSyncData: SyncData): SyncData {
val mergedMangaList = mergeMangaLists(localSyncData.backup?.backupManga, remoteSyncData.backup?.backupManga)
protected fun mergeSyncData(localSyncData: SyncData, remoteSyncData: SyncData): SyncData {
val mergedCategoriesList =
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 =
mergeSourcesLists(localSyncData.backup?.backupSources, remoteSyncData.backup?.backupSources)
val mergedPreferencesList =
@@ -101,6 +77,7 @@ abstract class SyncService(
// Create the merged SData object
return SyncData(
deviceId = syncPreferences.uniqueDeviceID(),
backup = mergedBackup,
)
}
@@ -117,6 +94,9 @@ abstract class SyncService(
private fun mergeMangaLists(
localMangaList: List<BackupManga>?,
remoteMangaList: List<BackupManga>?,
localCategories: List<BackupCategory>,
remoteCategories: List<BackupCategory>,
mergedCategories: List<BackupCategory>,
): List<BackupManga> {
val logTag = "MergeMangaLists"
@@ -135,6 +115,18 @@ abstract class SyncService(
val localMangaMap = localMangaListSafe.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) {
"Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}"
}
@@ -145,20 +137,26 @@ abstract class SyncService(
// New version comparison logic
when {
local != null && remote == null -> local
local == null && remote != null -> remote
local != null && remote == null -> updateCategories(local, localCategoriesMapByOrder)
local == null && remote != null -> updateCategories(remote, remoteCategoriesMapByOrder)
local != null && remote != null -> {
// Compare versions to decide which manga to keep
if (local.version >= remote.version) {
logcat(LogPriority.DEBUG, logTag) {
"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 {
logcat(LogPriority.DEBUG, logTag) {
"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
@@ -2,23 +2,26 @@ package eu.kanade.tachiyomi.data.sync.service
import android.content.Context
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.network.GET
import eu.kanade.tachiyomi.network.PATCH
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.await
import kotlinx.coroutines.delay
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority
import logcat.logcat
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.gzip
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
class SyncYomiSyncService(
@@ -26,140 +29,117 @@ class SyncYomiSyncService(
json: Json,
syncPreferences: SyncPreferences,
private val notifier: SyncNotifier,
private val protoBuf: ProtoBuf = Injekt.get(),
) : SyncService(context, json, syncPreferences) {
@Serializable
enum class SyncStatus {
@SerialName("pending")
Pending,
private class SyncYomiException(message: String?) : Exception(message)
@SerialName("syncing")
Syncing,
override suspend fun doSync(syncData: SyncData): Backup? {
try {
val (remoteData, etag) = pullSyncData()
@SerialName("success")
Success,
}
@Serializable
data class LockFile(
@SerialName("id")
val id: Int?,
@SerialName("user_api_key")
val userApiKey: String?,
@SerialName("acquired_by")
val acquiredBy: String?,
@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)
val finalSyncData = if (remoteData != null){
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)"
}
mergeSyncData(syncData, remoteData)
} else {
// init or overwrite remote data
logcat(LogPriority.DEBUG) {
"Try overwrite remote data with ETag($etag)"
}
syncData
}
} while (lockFile.status != SyncStatus.Success)
// update lock file acquired_by
client.newCall(lockFileUpdate).await()
pushSyncData(finalSyncData, etag)
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 apiKey = syncPreferences.clientAPIKey().get()
val downloadUrl = "$host/api/sync/download"
val downloadUrl = "$host/api/sync/content"
val client = OkHttpClient()
val headers = Headers.Builder().add("X-API-Token", apiKey).build()
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
val lastETag = syncPreferences.lastSyncEtag().get()
if (lastETag != "") {
headersBuilder.add("If-None-Match", lastETag)
}
val headers = headersBuilder.build()
val downloadRequest = GET(
url = downloadUrl,
headers = headers,
)
val client = OkHttpClient()
val response = client.newCall(downloadRequest).await()
val responseBody = response.body.string()
return if (response.isSuccessful) {
json.decodeFromString<SyncData>(responseBody)
if (response.code == HttpStatus.SC_NOT_MODIFIED) {
// 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 {
val responseBody = response.body.string()
notifier.showSyncError("Failed to download sync data: $responseBody")
responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } }
null
logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
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 apiKey = syncPreferences.clientAPIKey().get()
val uploadUrl = "$host/api/sync/upload"
val uploadUrl = "$host/api/sync/content"
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
val client = OkHttpClient.Builder()
.connectTimeout(timeout, TimeUnit.SECONDS)
@@ -167,32 +147,34 @@ class SyncYomiSyncService(
.writeTimeout(timeout, TimeUnit.SECONDS)
.build()
val headers = Headers.Builder().add(
"Content-Type",
"application/gzip",
).add("Content-Encoding", "gzip").add("X-API-Token", apiKey).build()
val byteArray = protoBuf.encodeToByteArray(BackupSerializer, backup)
if (byteArray.isEmpty()) {
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
}
val body = byteArray.toRequestBody("application/octet-stream".toMediaType())
val mediaType = "application/gzip".toMediaTypeOrNull()
val jsonData = json.encodeToString(syncData)
val body = jsonData.toRequestBody(mediaType).gzip()
val uploadRequest = POST(
val uploadRequest = PUT(
url = uploadUrl,
headers = headers,
body = body,
)
client.newCall(uploadRequest).await().use {
if (it.isSuccessful) {
logcat(
LogPriority.DEBUG,
) { "SyncYomi sync completed!" }
} else {
val responseBody = it.body.string()
notifier.showSyncError("Failed to upload sync data: $responseBody")
responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } }
}
val response = client.newCall(uploadRequest).await()
if (response.isSuccessful) {
val newETag = response.headers["ETag"]
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
syncPreferences.lastSyncEtag().set(newETag)
logcat(LogPriority.DEBUG) { "SyncYomi sync completed" }
} 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" }
}
}
}
@@ -11,9 +11,14 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import exh.source.BlacklistedSources
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
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.PreferenceStore
import tachiyomi.core.common.util.lang.withIOContext
@@ -26,8 +31,12 @@ internal class ExtensionApi {
private val networkService: NetworkHelper 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()
// SY -->
private val sourcePreferences: SourcePreferences by injectLazy()
// SY <--
private val json: Json by injectLazy()
private val lastExtCheck: Preference<Long> by lazy {
@@ -36,11 +45,15 @@ internal class ExtensionApi {
suspend fun findExtensions(): List<Extension.Available> {
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 {
val response = networkService.client
.newCall(GET("$repoBaseUrl/index.min.json"))
@@ -68,6 +81,9 @@ internal class ExtensionApi {
return null
}
// Update extension repo details
updateExtensionRepo.awaitAll()
val extensions = if (fromAvailableExtensionList) {
extensionManager.availableExtensionsFlow.value
} else {
@@ -109,8 +109,6 @@ class MergedSource : HttpSource() {
suspend fun fetchChaptersForMergedManga(
manga: Manga,
downloadChapters: Boolean = true,
editScanlators: Boolean = false,
dedupe: Boolean = true,
) {
fetchChaptersAndSync(manga, downloadChapters)
}
@@ -280,7 +280,7 @@ open class FeedScreenModel(
}
@Composable
fun getManga(initialManga: DomainManga, source: CatalogueSource?): State<DomainManga> {
fun getManga(initialManga: DomainManga): State<DomainManga> {
return produceState(initialValue = initialManga) {
getManga.subscribe(initialManga.url, initialManga.source)
.collectLatest { manga ->
@@ -87,7 +87,7 @@ fun Screen.feedTab(): TabContent {
navigator.push(MangaScreen(manga.id, true))
},
onRefresh = screenModel::init,
getMangaState = { manga, source -> screenModel.getManga(initialManga = manga, source = source) },
getMangaState = { manga -> screenModel.getManga(initialManga = manga) },
)
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.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import java.io.Serializable
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
override fun Content() {
val screenModel = rememberScreenModel { PreMigrationScreenModel() }
@@ -173,7 +179,7 @@ class PreMigrationScreen(val mangaIds: List<Long>) : Screen() {
screenModel.onMigrationSheet(false)
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(
if (skipPre) {
MigrationListScreen(
MigrationProcedureConfig(mangaIds, null),
MigrationProcedureConfig(MigrationType.MangaList(mangaIds), null),
)
} 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 }
when (val dialog = dialog) {
when (@Suppress("NAME_SHADOWING") val dialog = dialog) {
is MigrationListScreenModel.Dialog.MigrateMangaDialog -> {
MigrationMangaDialog(
onDismissRequest = onDismissRequest,
@@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.source.online.all.EHentai
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.util.system.toast
import exh.eh.EHentaiThrottleManager
@@ -104,8 +105,14 @@ class MigrationListScreenModel(
init {
screenModelScope.launchIO {
val mangaIds = when (val migration = config.migration) {
is MigrationType.MangaList -> {
migration.mangaIds
}
is MigrationType.MangaSingle -> listOf(migration.fromMangaId)
}
runMigrations(
config.mangaIds
mangaIds
.map {
async {
val manga = getManga.await(it) ?: return@async null
@@ -161,9 +168,13 @@ class MigrationListScreenModel(
break
}
// in case it was removed
if (manga.manga.id !in config.mangaIds) {
continue
when (val migration = config.migration) {
is MigrationType.MangaList -> if (manga.manga.id !in migration.mangaIds) {
continue
}
else -> Unit
}
if (manga.searchResult.value == SearchResult.Searching && manga.migrationScope.isActive) {
val mangaObj = manga.manga
val mangaSource = sourceManager.getOrStub(mangaObj.source)
@@ -175,6 +186,28 @@ class MigrationListScreenModel(
} else {
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) {
val sourceSemaphore = Semaphore(3)
val processedSources = AtomicInteger()
@@ -523,13 +556,18 @@ class MigrationListScreenModel(
}
fun removeManga(item: MigratingManga) {
val ids = config.mangaIds.toMutableList()
val index = ids.indexOf(item.manga.id)
if (index > -1) {
ids.removeAt(index)
config.mangaIds = ids
val index2 = migratingItems.value.orEmpty().indexOf(item)
if (index2 > -1) migratingItems.value = (migratingItems.value.orEmpty() - item).toImmutableList()
when (val migration = config.migration) {
is MigrationType.MangaList -> {
val ids = migration.mangaIds.toMutableList()
val index = ids.indexOf(item.manga.id)
if (index > -1) {
ids.removeAt(index)
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
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.MigrationType
import java.io.Serializable
data class MigrationProcedureConfig(
var mangaIds: List<Long>,
var migration: MigrationType,
val extraSearchParams: String?,
) : Serializable
@@ -109,7 +109,7 @@ data class SourceSearchScreen(
}
val onDismissRequest = { screenModel.setDialog(null) }
when (val dialog = state.dialog) {
when (state.dialog) {
is BrowseSourceScreenModel.Dialog.Filter -> {
SourceFilterDialog(
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.manga.MigrateMangaScreen
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.DelicateCoroutinesApi
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.domain.UnsortedPreferences
@@ -55,6 +56,7 @@ fun Screen.migrateSourceTab(): TabContent {
// SY -->
onClickAll = { source ->
// TODO: Jay wtf, need to clean this up sometime
@OptIn(DelicateCoroutinesApi::class)
launchIO {
val manga = Injekt.get<GetFavorites>().await()
val sourceMangas =
@@ -49,6 +49,7 @@ import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.online.HttpSource
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.browse.BrowseSourceScreenModel.Listing
import eu.kanade.tachiyomi.ui.category.CategoryScreen
@@ -62,6 +63,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import tachiyomi.core.common.Constants
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.UnsortedPreferences
import tachiyomi.domain.source.model.StubSource
import tachiyomi.i18n.MR
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.screens.LoadingScreen
import tachiyomi.source.local.LocalSource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
data class BrowseSourceScreen(
private val sourceId: Long,
@@ -319,6 +323,16 @@ data class BrowseSourceScreen(
onDismissRequest = onDismissRequest,
onConfirm = { screenModel.addFavorite(dialog.manga) },
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 -> {
@@ -455,7 +455,7 @@ open class BrowseSourceScreenModel(
val manga: Manga,
val initialSelection: ImmutableList<CheckboxState.State<Category>>,
) : Dialog
data class Migrate(val newManga: Manga) : Dialog
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
// SY -->
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.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.collectLatest
@@ -740,6 +741,7 @@ class LibraryScreenModel(
clearSelection()
}
@OptIn(DelicateCoroutinesApi::class)
fun syncMangaToDex() {
launchIO {
MdUtil.getEnabledMangaDex(unsortedPreferences, sourcePreferences, sourceManager)?.let { mdex ->
@@ -164,10 +164,10 @@ object LibraryTab : Tab {
}
},
onClickSyncNow = {
if (!SyncDataJob.isAnyJobRunning(context)) {
if (!SyncDataJob.isRunning(context)) {
SyncDataJob.startNow(context)
} else {
context.toast(MR.strings.sync_in_progress)
context.toast(SYMR.strings.sync_in_progress)
}
},
// SY -->
@@ -50,8 +50,6 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.ktx.Firebase
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.components.AppStateBanners
import eu.kanade.presentation.components.DownloadedOnlyBannerBackgroundColor
import eu.kanade.presentation.components.IncognitoModeBannerBackgroundColor
@@ -80,7 +78,6 @@ import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.util.system.isNavigationBarNeedsScrim
import eu.kanade.tachiyomi.util.system.isPreviewBuildType
import eu.kanade.tachiyomi.util.view.setComposeContent
import exh.EXHMigrations
import exh.debug.DebugToggles
import exh.eh.EHentaiUpdateWorker
import exh.log.DebugModeOverlay
@@ -97,6 +94,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.core.migration.Migrator
import tachiyomi.core.common.Constants
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.system.logcat
@@ -105,17 +103,13 @@ import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.release.interactor.GetApplicationRelease
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.LinkedList
import androidx.compose.ui.graphics.Color.Companion as ComposeColor
class MainActivity : BaseActivity() {
private val sourcePreferences: SourcePreferences by injectLazy()
private val libraryPreferences: LibraryPreferences by injectLazy()
private val uiPreferences: UiPreferences by injectLazy()
private val preferences: BasePreferences by injectLazy()
// SY -->
@@ -165,20 +159,7 @@ class MainActivity : BaseActivity() {
val didMigration = if (isLaunch) {
addAnalytics()
EXHMigrations.upgrade(
context = applicationContext,
basePreferences = preferences,
uiPreferences = uiPreferences,
preferenceStore = Injekt.get(),
networkPreferences = Injekt.get(),
sourcePreferences = sourcePreferences,
securityPreferences = Injekt.get(),
libraryPreferences = libraryPreferences,
readerPreferences = Injekt.get(),
backupPreferences = Injekt.get(),
trackerManager = Injekt.get(),
pagePreviewCache = Injekt.get(),
)
Migrator.awaitAndRelease()
} else {
false
}
@@ -66,6 +66,7 @@ fun EditMangaDialog(
confirmButton = {
TextButton(
onClick = {
@Suppress("NAME_SHADOWING")
val binding = binding ?: return@TextButton
onPositiveClick(
binding.title.text.toString(),
@@ -120,7 +121,7 @@ fun EditMangaDialog(
}
private fun onViewCreated(manga: Manga, context: Context, binding: EditMangaDialogBinding, scope: CoroutineScope) {
loadCover(manga, context, binding)
loadCover(manga, binding)
val statusAdapter: ArrayAdapter<String> = ArrayAdapter(
context,
@@ -188,7 +189,7 @@ private fun onViewCreated(manga: Manga, context: Context, binding: EditMangaDial
binding.mangaDescription.hint =
context.stringResource(
SYMR.strings.description_hint,
manga.ogDescription?.takeIf { it.isNotBlank() }?.let { it.replace("\n", " ").chop(20) } ?: ""
manga.ogDescription?.takeIf { it.isNotBlank() }?.replace("\n", " ")?.chop(20) ?: ""
)
binding.thumbnailUrl.hint =
context.stringResource(
@@ -212,7 +213,7 @@ private fun resetTags(manga: Manga, binding: EditMangaDialogBinding, scope: Coro
}
}
private fun loadCover(manga: Manga, context: Context, binding: EditMangaDialogBinding) {
private fun loadCover(manga: Manga, binding: EditMangaDialogBinding) {
binding.mangaCover.load(manga) {
transformations(RoundedCornersTransformation(4.dpToPx.toFloat()))
}
@@ -67,6 +67,7 @@ import exh.source.isMdBasedSource
import exh.ui.ifSourcesLoaded
import exh.ui.metadata.MetadataViewScreen
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
@@ -251,11 +252,19 @@ class MangaScreen(
},
)
}
is MangaScreenModel.Dialog.DuplicateManga -> DuplicateMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = { screenModel.toggleFavorite(onRemoved = {}, checkDuplicate = false) },
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
)
is MangaScreenModel.Dialog.DuplicateManga -> {
DuplicateMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = { screenModel.toggleFavorite(onRemoved = {}, checkDuplicate = false) },
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
onMigrate = {
// SY -->
migrateManga(navigator, dialog.duplicate, screenModel.manga!!.id)
// SY <--
},
)
}
MangaScreenModel.Dialog.SettingsSheet -> ChapterSettingsDialog(
onDismissRequest = onDismissRequest,
manga = successState.manga,
@@ -457,12 +466,13 @@ class MangaScreen(
/**
* Initiates source migration for the specific manga.
*/
private fun migrateManga(navigator: Navigator, manga: Manga) {
private fun migrateManga(navigator: Navigator, manga: Manga, toMangaId: Long? = null) {
// SY -->
PreMigrationScreen.navigateToMigration(
Injekt.get<UnsortedPreferences>().skipPreMigration().get(),
navigator,
listOf(manga.id),
manga.id,
toMangaId,
)
// SY <--
}
@@ -505,6 +515,7 @@ class MangaScreen(
navigator.push(SourcesScreen(smartSearchConfig))
}
@OptIn(DelicateCoroutinesApi::class)
private fun mergeWithAnother(
navigator: Navigator,
context: Context,
@@ -976,6 +976,7 @@ class MangaScreenModel(
downloadManager.getQueuedDownloadOrNull(chapter.id)
}
// SY -->
@Suppress("NAME_SHADOWING")
val manga = mergedData?.manga?.get(chapter.mangaId) ?: manga
val source = mergedData?.sources?.find { manga.source == it.id }?.takeIf { mergedData.sources.size > 2 }
// SY <--
@@ -1049,7 +1050,7 @@ class MangaScreenModel(
downloadNewChapters(newChapters)
}
} else {
state.source.fetchChaptersForMergedManga(state.manga, manualFetch, true, dedupe)
state.source.fetchChaptersForMergedManga(state.manga, manualFetch)
}
}
} catch (e: Throwable) {
@@ -1548,6 +1549,9 @@ class MangaScreenModel(
) : Dialog
data class DeleteChapters(val chapters: List<Chapter>) : Dialog
data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
/* SY -->
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
SY <-- */
data class SetFetchInterval(val manga: Manga) : Dialog
// SY -->
@@ -1580,6 +1584,12 @@ class MangaScreenModel(
updateSuccessState { it.copy(dialog = Dialog.FullCover) }
}
/* SY -->
fun showMigrateDialog(duplicate: Manga) {
val manga = successState?.manga ?: return
updateSuccessState { it.copy(dialog = Dialog.Migrate(newManga = manga, oldManga = duplicate)) }
} SY <-- */
fun setExcludedScanlators(excludedScanlators: Set<String>) {
screenModelScope.launchIO {
setExcludedScanlators.await(mangaId, excludedScanlators)
@@ -47,6 +47,7 @@ import androidx.lifecycle.repeatOnLifecycle
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.google.android.material.elevation.SurfaceColors
import com.google.android.material.transition.platform.MaterialContainerTransform
import com.hippo.unifile.UniFile
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.manga.model.readingMode
@@ -123,6 +124,7 @@ import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.ByteArrayOutputStream
import kotlin.time.Duration.Companion.seconds
class ReaderActivity : BaseActivity() {
@@ -1197,8 +1199,8 @@ class ReaderActivity : BaseActivity() {
}
.launchIn(lifecycleScope)
readerPreferences.trueColor().changes()
.onEach(::setTrueColor)
preferences.displayProfile().changes()
.onEach { setDisplayProfile(it) }
.launchIn(lifecycleScope)
readerPreferences.cutoutShort().changes()
@@ -1266,13 +1268,19 @@ class ReaderActivity : BaseActivity() {
}
/**
* Sets the 32-bit color mode according to [enabled].
* Sets the display profile to [path].
*/
private fun setTrueColor(enabled: Boolean) {
if (enabled) {
SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.ARGB_8888)
} else {
SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.RGB_565)
private fun setDisplayProfile(path: String) {
val file = UniFile.fromUri(baseContext, path.toUri())
if (file != null && file.exists()) {
val inputStream = file.openInputStream()
val outputStream = ByteArrayOutputStream()
inputStream.use { input ->
outputStream.use { output ->
input.copyTo(output)
}
}
SubsamplingScaleImageView.setDisplayProfile(outputStream.toByteArray())
}
}
@@ -29,9 +29,6 @@ class ReaderPreferences(
fun showReadingMode() = preferenceStore.getBoolean("pref_show_reading_mode", true)
// TODO: default this to true if reader long strip ever goes stable
fun trueColor() = preferenceStore.getBoolean("pref_true_color_key", false)
fun fullscreen() = preferenceStore.getBoolean("fullscreen", true)
fun cutoutShort() = preferenceStore.getBoolean("cutout_short", true)
@@ -343,8 +343,9 @@ open class ReaderPageImageView @JvmOverloads constructor(
.diskCachePolicy(CachePolicy.DISABLED)
.target(
onSuccess = { result ->
setImageDrawable(result.asDrawable(context.resources))
(result as? Animatable)?.start()
val drawable = result.asDrawable(context.resources)
setImageDrawable(drawable)
(drawable as? Animatable)?.start()
isVisible = true
this@ReaderPageImageView.onImageLoaded()
},
@@ -21,7 +21,6 @@ abstract class ViewerConfig(readerPreferences: ReaderPreferences, private val sc
var doubleTapAnimDuration = 500
var volumeKeysEnabled = false
var volumeKeysInverted = false
var trueColor = false
var alwaysShowChapterTransition = true
var navigationMode = 0
protected set
@@ -58,9 +57,6 @@ abstract class ViewerConfig(readerPreferences: ReaderPreferences, private val sc
readerPreferences.readWithVolumeKeysInverted()
.register({ volumeKeysInverted = it })
readerPreferences.trueColor()
.register({ trueColor = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.alwaysShowChapterTransition()
.register({ alwaysShowChapterTransition = it })
@@ -164,7 +164,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
return when (item) {
is ReaderPage -> PagerPageHolder(readerThemedContext, viewer, item, item2 as? ReaderPage)
is ChapterTransition -> PagerTransitionHolder(readerThemedContext, viewer, item)
else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented")
// SY --> else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented") SY <--
}
}
@@ -36,20 +36,21 @@ class WebtoonLayoutManager(context: Context) : LinearLayoutManager(context) {
*/
fun findLastEndVisibleItemPosition(): Int {
ensureLayoutState()
@ViewBoundsCheck.ViewBounds val preferredBoundsFlag =
(ViewBoundsCheck.FLAG_CVE_LT_PVE or ViewBoundsCheck.FLAG_CVE_EQ_PVE)
val fromIndex = childCount - 1
val toIndex = -1
val child = if (mOrientation == HORIZONTAL) {
val callback = if (mOrientation == HORIZONTAL) {
mHorizontalBoundCheck
.findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0)
} else {
mVerticalBoundCheck
.findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0)
}.mCallback
val start = callback.parentStart
val end = callback.parentEnd
for (i in childCount - 1 downTo 0) {
val child = getChildAt(i)!!
val childStart = callback.getChildStart(child)
val childEnd = callback.getChildEnd(child)
if (childEnd <= end || childStart < start) {
return getPosition(child)
}
}
return if (child == null) NO_POSITION else getPosition(child)
return NO_POSITION
}
}
@@ -118,6 +118,7 @@ class WebtoonPageHolder(
removeErrorLayout()
frame.recycle()
progressIndicator.setProgress(0)
progressContainer.isVisible = true
}
/**
@@ -6,7 +6,7 @@ import androidx.lifecycle.lifecycleScope
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@@ -23,7 +23,7 @@ class GoogleDriveLoginActivity : BaseOAuthLoginActivity() {
onSuccess = {
Toast.makeText(
this@GoogleDriveLoginActivity,
stringResource(MR.strings.google_drive_login_success),
stringResource(SYMR.strings.google_drive_login_success),
Toast.LENGTH_LONG,
).show()
@@ -32,7 +32,7 @@ class GoogleDriveLoginActivity : BaseOAuthLoginActivity() {
onFailure = { error ->
Toast.makeText(
this@GoogleDriveLoginActivity,
stringResource(MR.strings.google_drive_login_failed, error),
stringResource(SYMR.strings.google_drive_login_failed, error),
Toast.LENGTH_LONG,
).show()
returnToSettings()
@@ -42,7 +42,7 @@ class GoogleDriveLoginActivity : BaseOAuthLoginActivity() {
} else if (error != null) {
Toast.makeText(
this@GoogleDriveLoginActivity,
stringResource(MR.strings.google_drive_login_failed, error),
stringResource(SYMR.strings.google_drive_login_failed, error),
Toast.LENGTH_LONG,
).show()
@@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.updates.UpdatesScreenModel.Event
import kotlinx.coroutines.flow.collectLatest
import mihon.feature.upcoming.UpcomingScreen
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@@ -91,6 +92,7 @@ object UpdatesTab : Tab {
val intent = ReaderActivity.newIntent(context, it.update.mangaId, it.update.chapterId)
context.startActivity(intent)
},
onCalendarClicked = { navigator.push(UpcomingScreen()) },
)
val onDismissDialog = { screenModel.setDialog(null) }
@@ -39,6 +39,10 @@ fun Long.toLocalDate(): LocalDate {
return LocalDate.ofInstant(Instant.ofEpochMilli(this), ZoneId.systemDefault())
}
fun Instant.toLocalDate(zoneId: ZoneId = ZoneId.systemDefault()): LocalDate {
return LocalDate.ofInstant(this, zoneId)
}
fun LocalDate.toRelativeString(
context: Context,
relative: Boolean = true,
@@ -56,14 +60,12 @@ fun LocalDate.toRelativeString(
difference.toInt().absoluteValue,
difference.toInt().absoluteValue,
)
difference < 1 -> context.stringResource(MR.strings.relative_time_today)
difference < 7 -> context.pluralStringResource(
MR.plurals.relative_time,
difference.toInt(),
difference.toInt(),
)
else -> dateFormat.format(this)
}
}
-782
View File
@@ -1,699 +1,15 @@
package exh
import android.content.Context
import android.widget.Toast
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
import eu.kanade.tachiyomi.data.cache.PagePreviewCache
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.source.Source
import eu.kanade.tachiyomi.source.online.all.NHentai
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 exh.eh.EHentaiUpdateWorker
import exh.log.xLogE
import exh.source.BlacklistedSources
import exh.source.EH_SOURCE_ID
import exh.source.HBROWSE_SOURCE_ID
import exh.source.MERGED_SOURCE_ID
import exh.source.TSUMINO_SOURCE_ID
import exh.util.nullIfBlank
import exh.util.under
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import logcat.LogPriority
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.util.system.logcat
import tachiyomi.data.DatabaseHandler
import tachiyomi.data.category.CategoryMapper
import tachiyomi.data.chapter.ChapterMapper
import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.chapter.interactor.DeleteChapters
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.ChapterUpdate
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_COMPLETED
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.GetMangaBySource
import tachiyomi.domain.manga.interactor.InsertMergedReference
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.manga.model.MergedMangaReference
import tachiyomi.domain.source.interactor.InsertFeedSavedSearch
import tachiyomi.domain.source.interactor.InsertSavedSearch
import tachiyomi.domain.source.model.FeedSavedSearch
import tachiyomi.domain.source.model.SavedSearch
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.net.URI
import java.net.URISyntaxException
object EXHMigrations {
private val handler: DatabaseHandler by injectLazy()
private val sourceManager: SourceManager by injectLazy()
private val getManga: GetManga by injectLazy()
private val getMangaBySource: GetMangaBySource by injectLazy()
private val updateManga: UpdateManga by injectLazy()
private val updateChapter: UpdateChapter by injectLazy()
private val deleteChapters: DeleteChapters by injectLazy()
private val insertMergedReference: InsertMergedReference by injectLazy()
private val insertSavedSearch: InsertSavedSearch by injectLazy()
private val insertFeedSavedSearch: InsertFeedSavedSearch by injectLazy()
/**
* 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,
pagePreviewCache: PagePreviewCache,
): Boolean {
val lastVersionCode = preferenceStore.getInt("eh_last_version_code", 0)
val oldVersion = lastVersionCode.get()
try {
if (oldVersion < BuildConfig.VERSION_CODE) {
lastVersionCode.set(BuildConfig.VERSION_CODE)
LibraryUpdateJob.setupTask(context)
BackupCreateJob.setupTask(context)
EHentaiUpdateWorker.scheduleBackground(context)
// Fresh install
if (oldVersion == 0) {
return false
}
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
if (oldVersion under 4) {
updateSourceId(HBROWSE_SOURCE_ID, 6912)
// Migrate BHrowse URLs
val hBrowseManga = runBlocking { getMangaBySource.await(HBROWSE_SOURCE_ID) }
val mangaUpdates = hBrowseManga.map {
MangaUpdate(it.id, url = it.url + "/c00001/")
}
runBlocking {
updateManga.awaitAll(mangaUpdates)
}
}
if (oldVersion under 6) {
updateSourceId(NHentai.otherId, 6907)
}
if (oldVersion under 7) {
val mergedMangas = runBlocking { getMangaBySource.await(MERGED_SOURCE_ID) }
if (mergedMangas.isNotEmpty()) {
val mangaConfigs = mergedMangas.mapNotNull { mergedManga ->
readMangaConfig(mergedManga)?.let { mergedManga to it }
}
if (mangaConfigs.isNotEmpty()) {
val mangaToUpdate = mutableListOf<MangaUpdate>()
val mergedMangaReferences = mutableListOf<MergedMangaReference>()
mangaConfigs.onEach { mergedManga ->
val newFirst = mergedManga.second.children.firstOrNull()?.url?.let {
if (runBlocking { getManga.await(it, MERGED_SOURCE_ID) } != null) return@onEach
mangaToUpdate += MangaUpdate(id = mergedManga.first.id, url = it)
mergedManga.first.copy(url = it)
} ?: mergedManga.first
mergedMangaReferences += MergedMangaReference(
id = -1,
isInfoManga = false,
getChapterUpdates = false,
chapterSortMode = 0,
chapterPriority = 0,
downloadChapters = false,
mergeId = newFirst.id,
mergeUrl = newFirst.url,
mangaId = newFirst.id,
mangaUrl = newFirst.url,
mangaSourceId = MERGED_SOURCE_ID,
)
mergedManga.second.children.distinct().forEachIndexed { index, mangaSource ->
val load = mangaSource.load() ?: return@forEachIndexed
mergedMangaReferences += MergedMangaReference(
id = -1,
isInfoManga = index == 0,
getChapterUpdates = true,
chapterSortMode = 0,
chapterPriority = 0,
downloadChapters = true,
mergeId = newFirst.id,
mergeUrl = newFirst.url,
mangaId = load.manga.id,
mangaUrl = load.manga.url,
mangaSourceId = load.source.id,
)
}
}
runBlocking {
updateManga.awaitAll(mangaToUpdate)
insertMergedReference.awaitAll(mergedMangaReferences)
}
val loadedMangaList = mangaConfigs
.map { it.second.children }
.flatten()
.mapNotNull { it.load() }
.distinct()
val chapters =
runBlocking {
handler.awaitList {
ehQueries.getChaptersByMangaIds(
mergedMangas.map { it.id },
ChapterMapper::mapChapter,
)
}
}
val mergedMangaChapters =
runBlocking {
handler.awaitList {
ehQueries.getChaptersByMangaIds(
loadedMangaList.map { it.manga.id },
ChapterMapper::mapChapter,
)
}
}
val mergedMangaChaptersMatched = mergedMangaChapters.mapNotNull { chapter ->
loadedMangaList.firstOrNull {
it.manga.id == chapter.id
}?.let { it to chapter }
}
val parsedChapters = chapters.filter {
it.read || it.lastPageRead != 0L
}.mapNotNull { chapter -> readUrlConfig(chapter.url)?.let { chapter to it } }
val chaptersToUpdate = mutableListOf<ChapterUpdate>()
parsedChapters.forEach { parsedChapter ->
mergedMangaChaptersMatched.firstOrNull {
it.second.url == parsedChapter.second.url &&
it.first.source.id == parsedChapter.second.source &&
it.first.manga.url == parsedChapter.second.mangaUrl
}?.let {
chaptersToUpdate += ChapterUpdate(
it.second.id,
read = parsedChapter.first.read,
lastPageRead = parsedChapter.first.lastPageRead,
)
}
}
runBlocking {
deleteChapters.await(mergedMangaChapters.map { it.id })
updateChapter.awaitAll(chaptersToUpdate)
}
}
}
}
if (oldVersion under 12) {
// Force MAL log out due to login flow change
trackerManager.myAnimeList.logout()
}
if (oldVersion under 14) {
// 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 under 16) {
// Reset rotation to Free after replacing Lock
if (prefs.contains("pref_rotation_type_key")) {
prefs.edit {
putInt("pref_rotation_type_key", 1)
}
}
// Disable update check for Android 5.x users
// if (BuildConfig.INCLUDE_UPDATER && Build.VERSION.SDK_INT under Build.VERSION_CODES.M) {
// UpdaterJob.cancelTask(context)
// }
}
if (oldVersion under 17) {
// 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")
}
// Delete old mangadex trackers
runBlocking {
handler.await { ehQueries.deleteBySyncId(6) }
}
}
if (oldVersion under 18) {
val readerTheme = readerPreferences.readerTheme().get()
if (readerTheme == 4) {
readerPreferences.readerTheme().set(3)
}
val updateInterval = libraryPreferences.autoUpdateInterval().get()
if (updateInterval == 1 || updateInterval == 2) {
libraryPreferences.autoUpdateInterval().set(3)
}
}
if (oldVersion under 20) {
try {
val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0 /* ALPHABETICAL */)
val oldSortingDirection = prefs.getBoolean("library_sorting_ascending", true)
val newSortingMode = when (oldSortingMode) {
0 -> "ALPHABETICAL"
1 -> "LAST_READ"
2 -> "LAST_MANGA_UPDATE"
3 -> "UNREAD_COUNT"
4 -> "TOTAL_CHAPTERS"
6 -> "LATEST_CHAPTER"
7 -> "DRAG_AND_DROP"
8 -> "DATE_ADDED"
9 -> "TAG_LIST"
10 -> "CHAPTER_FETCH_DATE"
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)
}
} catch (e: Exception) {
logcat(throwable = e) { "Already done migration" }
}
}
if (oldVersion under 22) {
// 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)
}
}
if (oldVersion under 23) {
val oldUpdateOngoingOnly = prefs.getBoolean("pref_update_only_non_completed_key", true)
if (!oldUpdateOngoingOnly) {
libraryPreferences.autoUpdateMangaRestrictions() -= MANGA_NON_COMPLETED
}
}
if (oldVersion under 24) {
try {
sequenceOf(
"fav-sync",
"fav-sync.management",
"fav-sync.lock",
"fav-sync.note",
).map {
File(context.filesDir, it)
}.filter(File::exists).forEach {
if (it.isDirectory) {
it.deleteRecursively()
} else {
it.delete()
}
}
} catch (e: Exception) {
xLogE("Failed to delete old favorites database", e)
}
}
if (oldVersion under 27) {
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 under 28) {
if (prefs.getString("pref_display_mode_library", null) == "NO_TITLE_GRID") {
prefs.edit(commit = true) {
putString("pref_display_mode_library", "COVER_ONLY_GRID")
}
}
}
if (oldVersion under 29) {
if (prefs.getString("pref_display_mode_catalogue", null) == "NO_TITLE_GRID") {
prefs.edit(commit = true) {
putString("pref_display_mode_catalogue", "COMPACT_GRID")
}
}
}
if (oldVersion under 31) {
runBlocking {
val savedSearch = prefs.getStringSet("eh_saved_searches", emptySet())?.mapNotNull {
runCatching {
val content = Json.decodeFromString<JsonObject>(it.substringAfter(':'))
SavedSearch(
id = -1,
source = it.substringBefore(':').toLongOrNull()
?: return@runCatching null,
name = content["name"]!!.jsonPrimitive.content,
query = content["query"]!!.jsonPrimitive.contentOrNull?.nullIfBlank(),
filtersJson = Json.encodeToString(content["filters"]!!.jsonArray),
)
}.getOrNull()
}
if (!savedSearch.isNullOrEmpty()) {
insertSavedSearch.awaitAll(savedSearch)
}
val feedSavedSearch = prefs.getStringSet("latest_tab_sources", emptySet())?.map {
FeedSavedSearch(
id = -1,
source = it.toLong(),
savedSearch = null,
global = true,
)
}
if (!feedSavedSearch.isNullOrEmpty()) {
insertFeedSavedSearch.awaitAll(feedSavedSearch)
}
}
prefs.edit(commit = true) {
remove("eh_saved_searches")
remove("latest_tab_sources")
}
}
if (oldVersion under 32) {
val oldReaderTap = prefs.getBoolean("reader_tap", false)
if (!oldReaderTap) {
readerPreferences.navigationModePager().set(5)
readerPreferences.navigationModeWebtoon().set(5)
}
}
if (oldVersion under 38) {
// Handle renamed enum values
val newSortingMode = when (
val oldSortingMode = prefs.getString(libraryPreferences.sortingMode().key(), "ALPHABETICAL")
) {
"LAST_CHECKED" -> "LAST_MANGA_UPDATE"
"UNREAD" -> "UNREAD_COUNT"
"DATE_FETCHED" -> "CHAPTER_FETCH_DATE"
"DRAG_AND_DROP" -> "ALPHABETICAL"
else -> oldSortingMode
}
prefs.edit {
putString(libraryPreferences.sortingMode().key(), newSortingMode)
}
runBlocking {
handler.await(true) {
categoriesQueries.getCategories(CategoryMapper::mapCategory).executeAsList()
.filter { (it.flags and 0b00111100L) == 0b00100000L }
.forEach {
categoriesQueries.update(
categoryId = it.id,
flags = it.flags and 0b00111100L.inv(),
name = null,
order = null,
)
}
}
}
}
if (oldVersion under 39) {
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 under 40) {
if (backupPreferences.backupInterval().get() == 0) {
backupPreferences.backupInterval().set(12)
}
}
if (oldVersion under 41) {
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 under 42) {
if (uiPreferences.themeMode().isSet()) {
prefs.edit {
val themeMode = prefs.getString(uiPreferences.themeMode().key(), null) ?: return@edit
putString(uiPreferences.themeMode().key(), themeMode.uppercase())
}
}
}
if (oldVersion under 43) {
if (preferenceStore.getBoolean("start_reading_button").get()) {
libraryPreferences.showContinueReadingButton().set(true)
}
}
if (oldVersion under 44) {
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 under 45) {
// Force MangaDex log out due to login flow change
trackerManager.mdList.logout()
}
if (oldVersion under 52) {
// Removed background jobs
context.workManager.cancelAllWorkByTag("UpdateChecker")
context.workManager.cancelAllWorkByTag("ExtensionUpdate")
prefs.edit {
remove("automatic_ext_updates")
}
val prefKeys = listOf(
"pref_filter_library_downloaded",
"pref_filter_library_unread",
"pref_filter_library_started",
"pref_filter_library_bookmarked",
"pref_filter_library_completed",
"pref_filter_library_lewd",
) + 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 under 53) {
// // This was accidentally visible from the reader settings sheet, but should always
// // be disabled in release builds.
// if (isReleaseBuildType) {
// readerPreferences.longStripSplitWebtoon().set(false)
// }
// }
if (oldVersion under 56) {
val pref = libraryPreferences.autoUpdateDeviceRestrictions()
if (pref.isSet() && "battery_not_low" in pref.get()) {
pref.getAndSet { it - "battery_not_low" }
}
}
if (oldVersion under 57) {
val pref = preferenceStore.getInt("relative_time", 7)
if (pref.get() == 0) {
uiPreferences.relativeTime().set(false)
}
}
if (oldVersion under 58) {
pagePreviewCache.clear()
File(context.cacheDir, PagePreviewCache.PARAMETER_CACHE_DIRECTORY).listFiles()?.forEach {
if (it.name == "journal" || it.name.startsWith("journal.")) {
return@forEach
}
try {
it.delete()
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Failed to remove file from cache" }
}
}
}
if (oldVersion under 59) {
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",
"skip_pre_migration",
"eh_auto_update_stats",
"storage_dir",
)
replacePreferences(
preferenceStore = preferenceStore,
filterPredicate = { it.key in prefsToReplace },
newKey = { Preference.appStateKey(it) },
)
val privatePrefsToReplace = listOf(
"sql_password",
"encrypt_database",
"cbz_password",
"password_protect_downloads",
"eh_ipb_member_id",
"enable_exhentai",
"eh_ipb_member_id",
"eh_ipb_pass_hash",
"eh_igneous",
"eh_ehSettingsProfile",
"eh_exhSettingsProfile",
"eh_settingsKey",
"eh_sessionCookie",
"eh_hathPerksCookie",
)
replacePreferences(
preferenceStore = preferenceStore,
filterPredicate = { it.key in privatePrefsToReplace },
newKey = { Preference.privateKey(it) },
)
// Deleting old download cache index files, but might as well clear it all out
context.cacheDir.deleteRecursively()
}
if (oldVersion under 60) {
sourcePreferences.extensionRepos().getAndSet {
it.map { "https://raw.githubusercontent.com/$it/repo" }.toSet()
}
replacePreferences(
preferenceStore = preferenceStore,
filterPredicate = { it.key.startsWith("pref_mangasync_") || it.key.startsWith("track_token_") },
newKey = { Preference.privateKey(it) },
)
prefs.edit {
remove(Preference.appStateKey("trusted_signatures"))
}
}
if (oldVersion under 66) {
val cacheImagesToDisk = prefs.getBoolean("cache_archive_manga_on_disk", false)
if (cacheImagesToDisk) {
readerPreferences.archiveReaderMode().set(ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK)
}
}
if (oldVersion under 66) {
if (prefs.getBoolean(Preference.privateKey("encrypt_database"), false)) {
context.toast(
"Restart the app to load your encrypted library",
Toast.LENGTH_LONG
)
}
val appStatePrefsToReplace = listOf(
"__PRIVATE_sql_password",
"__PRIVATE_encrypt_database",
"__PRIVATE_cbz_password",
)
replacePreferences(
preferenceStore = preferenceStore,
filterPredicate = { it.key in appStatePrefsToReplace },
newKey = { Preference.appStateKey(it.replace("__PRIVATE_", "").trim()) },
)
}
// if (oldVersion under 1) { } (1 is current release version)
// do stuff here when releasing changed crap
return true
}
} catch (e: Exception) {
xLogE("Failed to migrate app from $oldVersion -> ${BuildConfig.VERSION_CODE}!", e)
}
return false
}
fun migrateBackupEntry(manga: Manga): Manga {
var newManga = manga
@@ -745,102 +61,4 @@ object EXHMigrations {
orig
}
}
@Serializable
private data class UrlConfig(
@SerialName("s")
val source: Long,
@SerialName("u")
val url: String,
@SerialName("m")
val mangaUrl: String,
)
@Serializable
private data class MangaConfig(
@SerialName("c")
val children: List<MangaSource>,
) {
companion object {
fun readFromUrl(url: String): MangaConfig? {
return try {
Json.decodeFromString(url)
} catch (e: Exception) {
null
}
}
}
}
private fun readMangaConfig(manga: Manga): MangaConfig? {
return MangaConfig.readFromUrl(manga.url)
}
@Serializable
private data class MangaSource(
@SerialName("s")
val source: Long,
@SerialName("u")
val url: String,
) {
fun load(): LoadedMangaSource? {
val manga = runBlocking { getManga.await(url, source) } ?: return null
val source = sourceManager.getOrStub(source)
return LoadedMangaSource(source, manga)
}
}
private fun readUrlConfig(url: String): UrlConfig? {
return try {
Json.decodeFromString(url)
} catch (e: Exception) {
null
}
}
private data class LoadedMangaSource(val source: Source, val manga: Manga)
private fun updateSourceId(newId: Long, oldId: Long) {
runBlocking {
handler.await { ehQueries.migrateSource(newId, oldId) }
}
}
@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()
}
}
}
}
}
+32 -57
View File
@@ -1,21 +1,15 @@
package exh.debug
import android.app.Application
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.model.toSManga
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.cache.PagePreviewCache
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.source.AndroidSourceManager
import eu.kanade.tachiyomi.source.online.all.NHentai
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.system.workManager
import exh.EXHMigrations
import exh.eh.EHentaiThrottleManager
import exh.eh.EHentaiUpdateWorker
import exh.metadata.metadata.EHentaiSearchMetadata
@@ -25,10 +19,12 @@ import exh.source.nHentaiSourceIds
import exh.util.jobScheduler
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.protobuf.schema.ProtoBufSchemaGenerator
import tachiyomi.core.common.preference.PreferenceStore
import mihon.core.migration.MigrationContext
import mihon.core.migration.MigrationJobFactory
import mihon.core.migration.MigrationStrategyFactory
import mihon.core.migration.Migrator
import mihon.core.migration.migrations.migrations
import tachiyomi.data.DatabaseHandler
import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.GetAllManga
import tachiyomi.domain.manga.interactor.GetExhFavoriteMangaWithMetadata
import tachiyomi.domain.manga.interactor.GetFavorites
@@ -36,6 +32,8 @@ import tachiyomi.domain.manga.interactor.GetFlatMetadataById
import tachiyomi.domain.manga.interactor.GetSearchMetadata
import tachiyomi.domain.manga.interactor.InsertFlatMetadata
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.UUID
@@ -43,16 +41,6 @@ import java.util.UUID
object DebugFunctions {
private val app: Application by injectLazy()
private val handler: DatabaseHandler by injectLazy()
private val prefsStore: PreferenceStore by injectLazy()
private val basePrefs: BasePreferences by injectLazy()
private val uiPrefs: UiPreferences by injectLazy()
private val networkPrefs: NetworkPreferences by injectLazy()
private val sourcePrefs: SourcePreferences by injectLazy()
private val securityPrefs: SecurityPreferences by injectLazy()
private val libraryPrefs: LibraryPreferences by injectLazy()
private val readerPrefs: ReaderPreferences by injectLazy()
private val backupPrefs: BackupPreferences by injectLazy()
private val trackerManager: TrackerManager by injectLazy()
private val sourceManager: SourceManager by injectLazy()
private val updateManga: UpdateManga by injectLazy()
private val getFavorites: GetFavorites by injectLazy()
@@ -61,44 +49,21 @@ object DebugFunctions {
private val getExhFavoriteMangaWithMetadata: GetExhFavoriteMangaWithMetadata by injectLazy()
private val getSearchMetadata: GetSearchMetadata by injectLazy()
private val getAllManga: GetAllManga by injectLazy()
private val pagePreviewCache: PagePreviewCache by injectLazy()
fun forceUpgradeMigration() {
val lastVersionCode = prefsStore.getInt("eh_last_version_code", 0)
lastVersionCode.set(1)
EXHMigrations.upgrade(
context = app,
preferenceStore = prefsStore,
basePreferences = basePrefs,
uiPreferences = uiPrefs,
networkPreferences = networkPrefs,
sourcePreferences = sourcePrefs,
securityPreferences = securityPrefs,
libraryPreferences = libraryPrefs,
readerPreferences = readerPrefs,
backupPreferences = backupPrefs,
trackerManager = trackerManager,
pagePreviewCache = pagePreviewCache,
)
fun forceUpgradeMigration(): Boolean {
val migrationContext = MigrationContext(dryrun = false)
val migrationJobFactory = MigrationJobFactory(migrationContext, Migrator.scope)
val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, {})
val strategy = migrationStrategyFactory.create(1, BuildConfig.VERSION_CODE)
return runBlocking { strategy(migrations).await() }
}
fun forceSetupJobs() {
val lastVersionCode = prefsStore.getInt("eh_last_version_code", 0)
lastVersionCode.set(0)
EXHMigrations.upgrade(
context = app,
preferenceStore = prefsStore,
basePreferences = basePrefs,
uiPreferences = uiPrefs,
networkPreferences = networkPrefs,
sourcePreferences = sourcePrefs,
securityPreferences = securityPrefs,
libraryPreferences = libraryPrefs,
readerPreferences = readerPrefs,
backupPreferences = backupPrefs,
trackerManager = trackerManager,
pagePreviewCache = pagePreviewCache,
)
fun forceSetupJobs(): Boolean {
val migrationContext = MigrationContext(dryrun = false)
val migrationJobFactory = MigrationJobFactory(migrationContext, Migrator.scope)
val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, {})
val strategy = migrationStrategyFactory.create(0, BuildConfig.VERSION_CODE)
return runBlocking { strategy(migrations).await() }
}
fun resetAgedFlagInEXHManga() {
@@ -340,4 +305,14 @@ object DebugFunctions {
}
fun exportProtobufScheme() = ProtoBufSchemaGenerator.generateSchemaText(Backup.serializer().descriptor)
fun killSyncJobs() {
val context = Injekt.get<Application>()
SyncDataJob.stop(context)
}
fun killLibraryJobs() {
val context = Injekt.get<Application>()
LibraryUpdateJob.stop(context)
}
}
@@ -2,6 +2,7 @@ package exh.favorites
import android.content.Context
import android.net.wifi.WifiManager
import android.os.Build
import android.os.PowerManager
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.tachiyomi.network.POST
@@ -136,10 +137,18 @@ class FavoritesSyncHelper(val context: Context) {
}
ignore { wifiLock?.release() }
wifiLock = ignore {
context.wifiManager.createWifiLock(
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
"teh:ExhFavoritesSyncWifi",
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.wifiManager.createWifiLock(
WifiManager.WIFI_MODE_FULL_LOW_LATENCY,
"teh:ExhFavoritesSyncWifi",
)
} else {
@Suppress("DEPRECATION")
context.wifiManager.createWifiLock(
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
"teh:ExhFavoritesSyncWifi",
)
}
}
// Do not update galleries while syncing favorites
@@ -20,15 +20,19 @@ import eu.kanade.presentation.browse.components.RemoveMangaDialog
import eu.kanade.presentation.category.components.ChangeCategoryDialog
import eu.kanade.presentation.manga.DuplicateMangaDialog
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel
import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import exh.ui.ifSourcesLoaded
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.UnsortedPreferences
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MangaDexFollowsScreen(private val sourceId: Long) : Screen() {
@@ -104,6 +108,14 @@ class MangaDexFollowsScreen(private val sourceId: Long) : Screen() {
onDismissRequest = onDismissRequest,
onConfirm = { screenModel.addFavorite(dialog.manga) },
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
onMigrate = {
PreMigrationScreen.navigateToMigration(
Injekt.get<UnsortedPreferences>().skipPreMigration().get(),
navigator,
dialog.duplicate.id,
dialog.manga.id,
)
}
)
}
is BrowseSourceScreenModel.Dialog.RemoveManga -> {
@@ -32,7 +32,6 @@ class MangaDexSimilarScreen(val mangaId: Long, val sourceId: Long) : Screen() {
}
val screenModel = rememberScreenModel { MangaDexSimilarScreenModel(mangaId, sourceId) }
val state by screenModel.state.collectAsState()
val navigator = LocalNavigator.currentOrThrow
val onMangaClick: (Manga) -> Unit = {
@@ -31,7 +31,6 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() {
}
val screenModel = rememberScreenModel { RecommendsScreenModel(mangaId, sourceId) }
val state by screenModel.state.collectAsState()
val navigator = LocalNavigator.currentOrThrow
val onMangaClick: (Manga) -> Unit = { manga ->
@@ -50,7 +50,7 @@ class MetadataViewScreen(private val mangaId: Long, private val sourceId: Long)
)
},
) { paddingValues ->
when (val state = state) {
when (@Suppress("NAME_SHADOWING") val state = state) {
MetadataViewState.Loading -> LoadingScreen()
MetadataViewState.MetadataNotFound -> EmptyScreen(MR.strings.no_results_found)
MetadataViewState.SourceNotFound -> EmptyScreen(MR.strings.source_empty_screen)
@@ -0,0 +1,23 @@
package mihon.core.designsystem.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
@Composable
@ReadOnlyComposable
fun isMediumWidthWindow(): Boolean {
val configuration = LocalConfiguration.current
return configuration.screenWidthDp > MediumWidthWindowSize.value
}
@Composable
@ReadOnlyComposable
fun isExpandedWidthWindow(): Boolean {
val configuration = LocalConfiguration.current
return configuration.screenWidthDp > ExpandedWidthWindowSize.value
}
val MediumWidthWindowSize = 600.dp
val ExpandedWidthWindowSize = 840.dp
@@ -0,0 +1,53 @@
package mihon.core.migration
import kotlinx.coroutines.runBlocking
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.data.DatabaseHandler
object MigrateUtils {
fun updateSourceId(migrationContext: MigrationContext, newId: Long, oldId: Long) {
val handler = migrationContext.get<DatabaseHandler>() ?: return
runBlocking {
handler.await { ehQueries.migrateSource(newId, oldId) }
}
}
@Suppress("UNCHECKED_CAST")
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()
}
}
}
}
}
@@ -0,0 +1,22 @@
package mihon.core.migration
interface Migration {
val version: Float
suspend operator fun invoke(migrationContext: MigrationContext): Boolean
val isAlways: Boolean
get() = version == ALWAYS
companion object {
const val ALWAYS = -1f
fun of(version: Float, action: suspend (MigrationContext) -> Boolean): Migration = object : Migration {
override val version: Float = version
override suspend operator fun invoke(migrationContext: MigrationContext): Boolean {
return action(migrationContext)
}
}
}
}
@@ -0,0 +1,3 @@
package mihon.core.migration
typealias MigrationCompletedListener = () -> Unit
@@ -0,0 +1,10 @@
package mihon.core.migration
import uy.kohesive.injekt.Injekt
class MigrationContext(val dryrun: Boolean) {
inline fun <reified T> get(): T? {
return Injekt.getInstanceOrNull(T::class.java)
}
}
@@ -0,0 +1,31 @@
package mihon.core.migration
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import tachiyomi.core.common.util.system.logcat
class MigrationJobFactory(
private val migrationContext: MigrationContext,
private val scope: CoroutineScope
) {
@SuppressWarnings("MaxLineLength")
fun create(migrations: List<Migration>): Deferred<Boolean> = with(scope) {
return migrations.sortedBy { it.version }
.fold(CompletableDeferred(true)) { acc: Deferred<Boolean>, migration: Migration ->
if (!migrationContext.dryrun) {
logcat { "Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" }
async(start = CoroutineStart.UNDISPATCHED) {
val prev = acc.await()
migration(migrationContext) || prev
}
} else {
logcat { "(Dry-run) Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" }
CompletableDeferred(true)
}
}
}
}
@@ -0,0 +1,55 @@
package mihon.core.migration
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.launch
interface MigrationStrategy {
operator fun invoke(migrations: List<Migration>): Deferred<Boolean>
}
class DefaultMigrationStrategy(
private val migrationJobFactory: MigrationJobFactory,
private val migrationCompletedListener: MigrationCompletedListener,
private val scope: CoroutineScope
) : MigrationStrategy {
override operator fun invoke(migrations: List<Migration>): Deferred<Boolean> = with(scope) {
if (migrations.isEmpty()) {
return@with CompletableDeferred(false)
}
val chain = migrationJobFactory.create(migrations)
launch {
if (chain.await()) migrationCompletedListener()
}.start()
chain
}
}
class InitialMigrationStrategy(private val strategy: DefaultMigrationStrategy) : MigrationStrategy {
override operator fun invoke(migrations: List<Migration>): Deferred<Boolean> {
return strategy(migrations.filter { it.isAlways })
}
}
class NoopMigrationStrategy(val state: Boolean) : MigrationStrategy {
override fun invoke(migrations: List<Migration>): Deferred<Boolean> {
return CompletableDeferred(state)
}
}
class VersionRangeMigrationStrategy(
private val versions: IntRange,
private val strategy: DefaultMigrationStrategy
) : MigrationStrategy {
override operator fun invoke(migrations: List<Migration>): Deferred<Boolean> {
return strategy(migrations.filter { it.isAlways || it.version.toInt() in versions })
}
}
@@ -0,0 +1,23 @@
package mihon.core.migration
class MigrationStrategyFactory(
private val factory: MigrationJobFactory,
private val migrationCompletedListener: MigrationCompletedListener,
) {
fun create(old: Int, new: Int): MigrationStrategy {
val versions = (old + 1)..new
val strategy = when {
old == 0 -> InitialMigrationStrategy(
strategy = DefaultMigrationStrategy(factory, migrationCompletedListener, Migrator.scope),
)
old >= new -> NoopMigrationStrategy(false)
else -> VersionRangeMigrationStrategy(
versions = versions,
strategy = DefaultMigrationStrategy(factory, migrationCompletedListener, Migrator.scope),
)
}
return strategy
}
}
@@ -0,0 +1,41 @@
package mihon.core.migration
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.runBlocking
object Migrator {
private var result: Deferred<Boolean>? = null
val scope = CoroutineScope(Dispatchers.Main + Job())
fun initialize(
old: Int,
new: Int,
migrations: List<Migration>,
dryrun: Boolean = false,
onMigrationComplete: () -> Unit
) {
val migrationContext = MigrationContext(dryrun)
val migrationJobFactory = MigrationJobFactory(migrationContext, scope)
val migrationStrategyFactory = MigrationStrategyFactory(migrationJobFactory, onMigrationComplete)
val strategy = migrationStrategyFactory.create(old, new)
result = strategy(migrations)
}
suspend fun await(): Boolean {
val result = result ?: CompletableDeferred(false)
return result.await()
}
fun release() {
result = null
}
fun awaitAndRelease(): Boolean = runBlocking {
await().also { release() }
}
}
@@ -0,0 +1,19 @@
package mihon.core.migration.migrations
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.backup.service.BackupPreferences
class AlwaysBackupMigration : Migration {
override val version: Float = 40f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val backupPreferences = migrationContext.get<BackupPreferences>() ?: return@withIOContext false
if (backupPreferences.backupInterval().get() == 0) {
backupPreferences.backupInterval().set(12)
}
return@withIOContext true
}
}
@@ -0,0 +1,24 @@
package mihon.core.migration.migrations
import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.util.system.DeviceUtil
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
class ChangeMiuiExtensionInstallerMigration : Migration {
override val version: Float = 27f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val basePreferences = migrationContext.get<BasePreferences>() ?: return@withIOContext false
if (
DeviceUtil.isMiui &&
basePreferences.extensionInstaller().get() == BasePreferences.ExtensionInstaller
.PACKAGEINSTALLER
) {
basePreferences.extensionInstaller().set(BasePreferences.ExtensionInstaller.LEGACY)
}
return@withIOContext true
}
}
@@ -0,0 +1,27 @@
package mihon.core.migration.migrations
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.App
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
class ChangeThemeModeToUppercaseMigration : Migration {
override val version: Float = 42f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val context = migrationContext.get<App>() ?: return@withIOContext false
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val uiPreferences = migrationContext.get<UiPreferences>() ?: return@withIOContext false
if (uiPreferences.themeMode().isSet()) {
prefs.edit {
val themeMode = prefs.getString(uiPreferences.themeMode().key(), null) ?: return@edit
putString(uiPreferences.themeMode().key(), themeMode.uppercase())
}
}
return@withIOContext true
}
}
@@ -0,0 +1,26 @@
package mihon.core.migration.migrations
import android.content.Context
import androidx.core.content.edit
import eu.kanade.tachiyomi.App
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
class ChangeTrackingQueueTypeMigration : Migration {
override val version: Float = 44f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val context = migrationContext.get<App>() ?: return@withIOContext false
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())
}
}
return@withIOContext true
}
}
@@ -0,0 +1,33 @@
package mihon.core.migration.migrations
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.data.cache.PagePreviewCache
import logcat.LogPriority
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import java.io.File
class ClearBrokenPagePreviewCacheMigration : Migration {
override val version: Float = 58f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val context = migrationContext.get<App>() ?: return@withIOContext false
val pagePreviewCache = migrationContext.get<PagePreviewCache>() ?: return@withIOContext false
pagePreviewCache.clear()
File(context.cacheDir, PagePreviewCache.PARAMETER_CACHE_DIRECTORY).listFiles()?.forEach {
if (it.name == "journal" || it.name.startsWith("journal.")) {
return@forEach
}
try {
it.delete()
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Failed to remove file from cache" }
}
}
return@withIOContext true
}
}
@@ -0,0 +1,28 @@
package mihon.core.migration.migrations
import eu.kanade.domain.manga.interactor.UpdateManga
import exh.source.HBROWSE_SOURCE_ID
import mihon.core.migration.MigrateUtils
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.manga.interactor.GetMangaBySource
import tachiyomi.domain.manga.model.MangaUpdate
class DelegateHBrowseMigration : Migration {
override val version: Float = 4f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val getMangaBySource = migrationContext.get<GetMangaBySource>() ?: return@withIOContext false
val updateManga = migrationContext.get<UpdateManga>() ?: return@withIOContext false
MigrateUtils.updateSourceId(migrationContext, HBROWSE_SOURCE_ID, 6912)
// Migrate BHrowse URLs
val hBrowseManga = getMangaBySource.await(HBROWSE_SOURCE_ID)
val mangaUpdates = hBrowseManga.map {
MangaUpdate(it.id, url = it.url + "/c00001/")
}
updateManga.awaitAll(mangaUpdates)
return@withIOContext true
}
}
@@ -0,0 +1,17 @@
package mihon.core.migration.migrations
import eu.kanade.tachiyomi.source.online.all.NHentai
import mihon.core.migration.MigrateUtils
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
class DelegateNHentaiMigration : Migration {
override val version: Float = 6f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
MigrateUtils.updateSourceId(migrationContext, NHentai.otherId, 6907)
return@withIOContext true
}
}
@@ -0,0 +1,36 @@
package mihon.core.migration.migrations
import eu.kanade.tachiyomi.App
import exh.log.xLogE
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
import java.io.File
class DeleteOldEhFavoritesDatabaseMigration : Migration {
override val version: Float = 24f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val context = migrationContext.get<App>() ?: return@withIOContext false
try {
sequenceOf(
"fav-sync",
"fav-sync.management",
"fav-sync.lock",
"fav-sync.note",
).map {
File(context.filesDir, it)
}.filter(File::exists).forEach {
if (it.isDirectory) {
it.deleteRecursively()
} else {
it.delete()
}
}
} catch (e: Exception) {
xLogE("Failed to delete old favorites database", e)
}
return@withIOContext true
}
}
@@ -0,0 +1,17 @@
package mihon.core.migration.migrations
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.data.DatabaseHandler
class DeleteOldMangaDexTracksMigration : Migration {
override val version: Float = 17f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val handler = migrationContext.get<DatabaseHandler>() ?: return@withIOContext false
// Delete old mangadex trackers
handler.await { ehQueries.deleteBySyncId(6) }
return@withIOContext true
}
}
@@ -0,0 +1,17 @@
package mihon.core.migration.migrations
import eu.kanade.tachiyomi.data.track.TrackerManager
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
class LogoutFromMALMigration : Migration {
override val version: Float = 12f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
// Force MAL log out due to login flow change
migrationContext.get<TrackerManager>()?.myAnimeList?.logout()
return@withIOContext true
}
}
@@ -0,0 +1,17 @@
package mihon.core.migration.migrations
import eu.kanade.tachiyomi.data.track.TrackerManager
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
class LogoutFromMangaDexMigration : Migration {
override val version: Float = 45f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
// Force MangaDex log out due to login flow change
migrationContext.get<TrackerManager>()?.mdList?.logout()
return@withIOContext true
}
}
@@ -0,0 +1,191 @@
package mihon.core.migration.migrations
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.tachiyomi.source.Source
import exh.source.MERGED_SOURCE_ID
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.data.DatabaseHandler
import tachiyomi.data.chapter.ChapterMapper
import tachiyomi.domain.chapter.interactor.DeleteChapters
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.ChapterUpdate
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.GetMangaBySource
import tachiyomi.domain.manga.interactor.InsertMergedReference
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.manga.model.MergedMangaReference
import tachiyomi.domain.source.service.SourceManager
class MergedMangaRewriteMigration : Migration {
override val version: Float = 7f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val handler = migrationContext.get<DatabaseHandler>() ?: return@withIOContext false
val getMangaBySource = migrationContext.get<GetMangaBySource>() ?: return@withIOContext false
val getManga = migrationContext.get<GetManga>() ?: return@withIOContext false
val updateManga = migrationContext.get<UpdateManga>() ?: return@withIOContext false
val insertMergedReference = migrationContext.get<InsertMergedReference>() ?: return@withIOContext false
val sourceManager = migrationContext.get<SourceManager>() ?: return@withIOContext false
val deleteChapters = migrationContext.get<DeleteChapters>() ?: return@withIOContext false
val updateChapter = migrationContext.get<UpdateChapter>() ?: return@withIOContext false
val mergedMangas = getMangaBySource.await(MERGED_SOURCE_ID)
if (mergedMangas.isNotEmpty()) {
val mangaConfigs = mergedMangas.mapNotNull { mergedManga ->
readMangaConfig(mergedManga)?.let { mergedManga to it }
}
if (mangaConfigs.isNotEmpty()) {
val mangaToUpdate = mutableListOf<MangaUpdate>()
val mergedMangaReferences = mutableListOf<MergedMangaReference>()
mangaConfigs.onEach { mergedManga ->
val newFirst = mergedManga.second.children.firstOrNull()?.url?.let {
if (getManga.await(it, MERGED_SOURCE_ID) != null) return@onEach
mangaToUpdate += MangaUpdate(id = mergedManga.first.id, url = it)
mergedManga.first.copy(url = it)
} ?: mergedManga.first
mergedMangaReferences += MergedMangaReference(
id = -1,
isInfoManga = false,
getChapterUpdates = false,
chapterSortMode = 0,
chapterPriority = 0,
downloadChapters = false,
mergeId = newFirst.id,
mergeUrl = newFirst.url,
mangaId = newFirst.id,
mangaUrl = newFirst.url,
mangaSourceId = MERGED_SOURCE_ID,
)
mergedManga.second.children.distinct().forEachIndexed { index, mangaSource ->
val load = mangaSource.load(getManga, sourceManager) ?: return@forEachIndexed
mergedMangaReferences += MergedMangaReference(
id = -1,
isInfoManga = index == 0,
getChapterUpdates = true,
chapterSortMode = 0,
chapterPriority = 0,
downloadChapters = true,
mergeId = newFirst.id,
mergeUrl = newFirst.url,
mangaId = load.manga.id,
mangaUrl = load.manga.url,
mangaSourceId = load.source.id,
)
}
}
updateManga.awaitAll(mangaToUpdate)
insertMergedReference.awaitAll(mergedMangaReferences)
val loadedMangaList = mangaConfigs
.map { it.second.children }
.flatten()
.mapNotNull { it.load(getManga, sourceManager) }
.distinct()
val chapters =
handler.awaitList {
ehQueries.getChaptersByMangaIds(
mergedMangas.map { it.id },
ChapterMapper::mapChapter,
)
}
val mergedMangaChapters =
handler.awaitList {
ehQueries.getChaptersByMangaIds(
loadedMangaList.map { it.manga.id },
ChapterMapper::mapChapter,
)
}
val mergedMangaChaptersMatched = mergedMangaChapters.mapNotNull { chapter ->
loadedMangaList.firstOrNull {
it.manga.id == chapter.id
}?.let { it to chapter }
}
val parsedChapters = chapters.filter {
it.read || it.lastPageRead != 0L
}.mapNotNull { chapter -> readUrlConfig(chapter.url)?.let { chapter to it } }
val chaptersToUpdate = mutableListOf<ChapterUpdate>()
parsedChapters.forEach { parsedChapter ->
mergedMangaChaptersMatched.firstOrNull {
it.second.url == parsedChapter.second.url &&
it.first.source.id == parsedChapter.second.source &&
it.first.manga.url == parsedChapter.second.mangaUrl
}?.let {
chaptersToUpdate += ChapterUpdate(
it.second.id,
read = parsedChapter.first.read,
lastPageRead = parsedChapter.first.lastPageRead,
)
}
}
deleteChapters.await(mergedMangaChapters.map { it.id })
updateChapter.awaitAll(chaptersToUpdate)
}
}
return@withIOContext true
}
@Serializable
private data class UrlConfig(
@SerialName("s")
val source: Long,
@SerialName("u")
val url: String,
@SerialName("m")
val mangaUrl: String,
)
@Serializable
private data class MangaConfig(
@SerialName("c")
val children: List<MangaSource>,
) {
companion object {
fun readFromUrl(url: String): MangaConfig? {
return try {
Json.decodeFromString(url)
} catch (e: Exception) {
null
}
}
}
}
private fun readMangaConfig(manga: Manga): MangaConfig? {
return MangaConfig.readFromUrl(manga.url)
}
@Serializable
private data class MangaSource(
@SerialName("s")
val source: Long,
@SerialName("u")
val url: String,
) {
suspend fun load(getManga: GetManga, sourceManager: SourceManager): LoadedMangaSource? {
val manga = getManga.await(url, source) ?: return null
val source = sourceManager.getOrStub(source)
return LoadedMangaSource(source, manga)
}
}
private fun readUrlConfig(url: String): UrlConfig? {
return try {
Json.decodeFromString(url)
} catch (e: Exception) {
null
}
}
private data class LoadedMangaSource(val source: Source, val manga: Manga)
}

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