Compare commits

..

103 Commits

Author SHA1 Message Date
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
Fermín Cirella 911e959fcf Add option to reset custom manga info (#1118)
* Add option to reset custom manga info

* Remove extra line

* Update app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt

---------

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>
2024-03-16 23:59:19 -04:00
KaiserBh a425cae73b fix: The trigger for library update wasn't working properly. (#1119)
Missed them, so it was always updating library without actually syncing even when the trigger was on.

Signed-off-by: KaiserBh <kaiserbh@proton.me>
2024-03-16 23:56:00 -04:00
Jobobby04 d12a9d329b [skip ci] Add instructions for supporting Cloud Sync Google Drive Impl 2024-03-16 15:59:00 -04:00
Jobobby04 9018757496 Oops 2024-03-16 13:47:55 -04:00
ɴᴇᴋᴏ b0d91fa83f Update zh-rTW (#1117)
* Update plurals.xml

* Update strings.xml

* Update plurals.xml

* Update strings.xml

* Update plurals.xml

* Update plurals.xml

* Update strings.xml

* Update plurals.xml

* plurals.xml

* plurals.xml

* Delete i18n/src/commonMain/resources/MR/zh-rTW/strings.xml

* Add files via upload
2024-03-16 13:46:27 -04:00
Jobobby04 1caa929aa0 Add preview prefix 2024-03-16 13:45:47 -04:00
Jobobby04 04e5be12e1 Write client_secrets.json on build 2024-03-16 13:37:41 -04:00
Jobobby04 1136644a57 Remove Client Secrets 2024-03-16 13:32:58 -04:00
Jobobby04 d70258b956 Cleanup sync code 2024-03-16 13:14:40 -04:00
Jobobby04 54cb379a50 Update Detekt baseline 2024-03-16 12:36:51 -04:00
Jobobby04 0e959c4594 Move strings to SY strings 2024-03-16 12:23:30 -04:00
Shamicen 6719f22eff implement mihonapp/mihon#326 (#1104)
* implement mihonapp/mihon#326

Archives are now being read from channels

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

* disable parallelisms for loading into memory

* switched to mutex

* detekt changes

* more detekt baseline changes

---------

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2024-03-16 11:59:00 -04:00
Jobobby04 45711cd394 Update Client Secret 2024-03-16 11:55:48 -04:00
KaiserBh 334e9fb680 feat: add cross device sync (#1005)
* feat: add cross device sync.

* chore: add google api.

* chore: add SY specifics.

* feat: add backupSource, backupPref, and "SY" backupSavedSearches.

I forgot to add the data into the merging logic, So remote always have empty value :(. Better late than never.

* feat(sync): Allow to choose what to sync.

Various improvement and added the option to choose what they want to sync. Added sync library button to LibraryTab as well.

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

* oops.

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

* refactor: fix up the sync triggers, and update imports.

* refactor

* chore: review pointers.

* refactor: update imports

* refactor: add more error guard for gdrive.

Also changed it to be app specific, we don't want them to use sync data from SY or other forks as some of the model and backup is different. So if people using other forks they should use the same data and not mismatch.

* fix: conflict and refactor.

* refactor: update imports.

* chore: fix some of detekt error.

* refactor: add breaks and max retries.

I think we were reaching deadlock or infinite loop causing the sync to go forever.

* feat: db changes to accommodate new syncing logic.

Using timestamp to sync is a bit skewed due to system clock etc and therefore there was a lot of issues with it such as removing a manga that shouldn't have been removed. Marking chapters as unread even though it was marked as a read. Hopefully by using versioning system it should eliminate those issues.

* chore: add migrations

* chore: version and is_syncing fields.

* chore: add SY only stuff.

* fix: oops wrong index.

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

* chore: review pointers.

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

* chore: remove the option to reset timestamp

We don't need this anymore since we are utilizing versioning system.

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

* refactor: Forgot to use the new versioning system.

I forgot to cherry pick this from mihon branch.

* chore: remove isSyncing from Chapter/Manga model.

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

* chore: remove unused import.

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

* chore: remove isSyncing leftover.

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

* chore: remove isSyncing.

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

* refactor: make sure the manga version is bumped.

When there is changes in the chapters table such as reading or updating last read page we should bump the manga version. This way the manga is synced properly as in the History and last_read history is synced properly. This should fix the sorting issue.

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

---------

Signed-off-by: KaiserBh <kaiserbh@proton.me>
2024-03-16 11:53:20 -04:00
ɴᴇᴋᴏ 6e0bc981a6 Update README.md (#1113)
Tachi -> Mihon
2024-03-15 20:36:28 -04:00
Cuong M. Tran b7e55bc9f8 Update detekt's baseline & run detekt for future build (#1106)
* Update detekt baseline from mihon

* Update detekt baseline to current code & enable gradle's detekt task for future build
2024-03-15 20:35:39 -04:00
Shamicen a069e577ba Change preferences containing passwords to appStateKeys (#1083)
* Change preferences containing passwords to appStateKeys

* Change versionCode to 65

* fix merge conflict and add instructions to get library back after migration
2024-03-15 20:28:37 -04:00
Jobobby04 0eb622643b Use github run_number to create tag 2024-03-15 20:18:44 -04:00
Jobobby04 d93d0eea89 Shorten Anilst UA(hopefully Cloudflare likes this one) 2024-03-15 20:14:37 -04:00
AntsyLich 82846205b2 Fix crash in track date selection dialog
Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
(cherry picked from commit f08713587685ddb27cb8ce7184e2dd21ae7968ae)
2024-03-15 20:08:14 -04:00
AntsyLich 4a4fecb1e8 Bump default user agent
(cherry picked from commit f66f52c244b786ae09f8e4ae683575907068d15f)
2024-03-15 20:08:06 -04:00
renovate[bot] ee6bc20f27 Update dependency io.nlopez.compose.rules:detekt to v0.3.12 (#500)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 0d6f426dbd8874c7861b6cc245f9e6ff43dbbe2c)
2024-03-15 20:07:56 -04:00
Weblate (bot) 446a5cd5b3 Translations update from Hosted Weblate (#445)
* Translated using Weblate (Esperanto)

Currently translated at 65.1% (517 of 794 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (794 of 794 strings)

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

* Translated using Weblate (Croatian)

Currently translated at 100.0% (794 of 794 strings)

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

* Translated using Weblate (Serbian)

Currently translated at 100.0% (794 of 794 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (794 of 794 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% (794 of 794 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (18 of 18 strings)

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

* Translated using Weblate (Croatian)

Currently translated at 100.0% (18 of 18 strings)

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

* Translated using Weblate (Serbian)

Currently translated at 100.0% (18 of 18 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (18 of 18 strings)

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

* Translated using Weblate (Filipino)

Currently translated at 100.0% (794 of 794 strings)

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

* Translated using Weblate (Filipino)

Currently translated at 100.0% (18 of 18 strings)

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

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (18 of 18 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (794 of 794 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (18 of 18 strings)

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

* Translated using Weblate (Nepali)

Currently translated at 100.0% (794 of 794 strings)

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

* Translated using Weblate (Polish)

Currently translated at 100.0% (794 of 794 strings)

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

* Translated using Weblate (Polish)

Currently translated at 100.0% (18 of 18 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (794 of 794 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (794 of 794 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (18 of 18 strings)

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

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (794 of 794 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 100.0% (794 of 794 strings)

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

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (18 of 18 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 94.2% (748 of 794 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 94.4% (17 of 18 strings)

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

* 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/

---------

Co-authored-by: Radoŝ Porka <animatorzPolski@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: David Katrinka <davidkatrinka1995@gmail.com>
Co-authored-by: gekka <1778962971@qq.com>
Co-authored-by: ɴᴇᴋᴏ <s99095lkjjim@gmail.com>
Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: B4LiN7 <B4LiN7@users.noreply.hosted.weblate.org>
Co-authored-by: FateXBlood <zecrofelix@gmail.com>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: Dexroneum <Rozhenkov69@gmail.com>
Co-authored-by: TheKingTermux <achmadmaulana0233@gmail.com>
Co-authored-by: Tim Bolhoeve <bolhoevetim@gmail.com>
Co-authored-by: Lyfja <45209212+lyfja@users.noreply.github.com>
(cherry picked from commit edd7d0522c305a8aec8ab524214d3a26106dac31)

# Conflicts:
#	i18n/src/commonMain/resources/MR/hu/strings.xml
#	i18n/src/commonMain/resources/MR/sr/strings.xml
#	i18n/src/commonMain/resources/MR/zh-rTW/strings.xml
2024-03-15 20:07:49 -04:00
KaiserBh cdb07c893b feat: db changes to accommodate new cross device syncing logic. (#450)
* feat: db changes to accommodate new syncing logic.

Using timestamp to sync is a bit skewed due to system clock etc and therefore there was a lot of issues with it such as removing a manga that shouldn't have been removed. Marking chapters as unread even though it was marked as a read. Hopefully by using versioning system it should eliminate those issues.

* chore: add new line.

* chore: remove isSyncing from Chapter/Manga model.

* chore: remove isSyncing leftover.

* chore: remove isSyncing.

* refactor: remove isSync guard.

Just use it directly to 1 now since we don't have the isSyncing field in Manga or Chapter.

* Lint and stuff

* Add missing ,

---------

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

# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt
#	app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt
#	app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt
#	data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt
#	data/src/main/sqldelight/tachiyomi/migrations/2.sqm
#	domain/src/main/java/tachiyomi/domain/manga/model/MangaUpdate.kt
2024-03-15 20:06:20 -04:00
Redjard a4d88515fb Fix shizuku being buggy for multi user setups (#494)
* Fix #493

Fetch the current userid separately because shizuku always runs as the main user and would otherwise install and update for the main user

* Update app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt

---------

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

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt
2024-03-15 19:55:36 -04:00
FooIbar 345d0821c6 Fix dual page split for local source (#485)
`InputStream.available()` is implementation-dependent, should never assume it will return the total number of bytes in the stream.

(cherry picked from commit d0e64d3a66d227ca61fc8d956b03cab5ac3b84f0)

# Conflicts:
#	core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt
2024-03-15 19:54:06 -04:00
az4521 a9fd1f8811 Update image-decoder (#466)
Use newer image-decoder lib

fixes crashing when trying to load corrupt images below 12 bytes in size

(cherry picked from commit 154f4d327caea9ceef6a53e739324ee0a9ed959d)

# Conflicts:
#	gradle/libs.versions.toml
2024-03-15 19:53:26 -04:00
Jobobby04 31e5ba4caf Fix multiple issues regarding sources loading too late 2024-03-15 19:51:56 -04:00
Jobobby04 202900edf0 Fix build error after Android Gradle 8.3 2024-03-03 22:39:58 -05:00
AntsyLich f79959c7bc Fix ChapterDownloadIndicator
Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
(cherry picked from commit d8b9a9f593911569ff2bceb49b4f020978d0d2e1)
2024-03-03 22:01:15 -05:00
AntsyLich 237d8d6b33 Small cleanup
(cherry picked from commit b7e091d5d039e00cababc7daf555280df6cf9c03)
2024-03-03 22:01:05 -05:00
renovate[bot] 117e0d5792 Update dependency com.android.tools.build:gradle to v8.3.0 (#471)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 31e052ac15679496f9f2c3882e53096119e0e6cf)
2024-03-03 22:00:56 -05:00
renovate[bot] 64bbe941a4 Update dependency io.mockk:mockk to v1.13.10 (#470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 60480686daa9456c78d7cb45c5ad2e23644d1bd2)
2024-03-03 22:00:49 -05:00
Jobobby04 dcd44c42ed Merge branch 'release' 2024-03-02 15:39:33 -05:00
Jobobby04 6c563d7619 Fix for duplicate read 2024-03-02 15:38:32 -05:00
Jobobby04 97f22c500b 1.10.5 2024-03-02 15:38:05 -05:00
Jobobby04 9cbeccfa15 Fix for duplicate read 2024-03-02 15:34:28 -05:00
MajorTanya 86722a31d0 Fix DelayedTrackingUpdateJob spam on update errors (#411)
* Fix DelayedTrackingUpdateJob spam on update errors

DelayedTrackingUpdateJob would start spamming when it encountered an
error (e.g. a tracker has an issue) and never stop.
This seems to stem from a circular dependency between the Job's
`doWork` and TrackChapter's `await`.

TrackChapter sets up a completely new instance of the
DelayedTrackingUpdateJob if any Exception was thrown during the track
update.

This causes the Job to get replaced (as per the WorkManager's set
ExistingWorkPolicy).

Because of this, the guard clause at the start of doWork would never
trigger, as all instances of the Job would report being the 0th try
(because they were completely new instances).

This simple fix introduces a boolean `isRetry` parameter to
TrackChapter's await method, which is set to `false` by default.
DelayedTrackingUpdateJob however sets this parameter to `true`, which
means TrackChapter won't try to set up the Job again.

* Rename isRetry parameter to setupJobOnFailure

This also inverts the logic, so true & false were swapped.

(cherry picked from commit 617bf491eee1d1010dc23c3f6df5d148700feb44)
2024-03-02 15:32:56 -05:00
AntsyLich 589b33a673 Fix detekt issue
(cherry picked from commit 9254079957e383e4aa5c914ccd9705612e0892d0)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt
2024-03-02 15:32:31 -05:00
AntsyLich dae0348710 Don't add custom User Agent for MAL
Closes #298

(cherry picked from commit 7974a1fc0c2b0e237d5704a033a60ec0fd60cdbc)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt
2024-03-02 15:32:29 -05:00
Jobobby04 a7f6155627 Ignore chapters with 0 or under chapter numbers
(cherry picked from commit d8082de1db)
2024-03-02 15:31:38 -05:00
Jobobby04 7f5bc4a3e5 Use new gradle workflow 2024-03-02 15:28:40 -05:00
Jobobby04 ec32278f1a Throw IOException 2024-03-02 14:25:31 -05:00
AntsyLich c02c5aa915 Revert changes to gradle.properties
(cherry picked from commit d6ba3c824972c8a2196516edd42e9e8be6385f36)
2024-03-02 14:24:34 -05:00
AntsyLich f267f2ad5b detekt my beloved
(cherry picked from commit c56f4665ef0276c54f5abebd9ab93e2e283739a6)
2024-03-02 14:24:25 -05:00
renovate[bot] 03bc09c1aa Update dependency me.saket.swipe:swipe to v1.3.0 (#343)
* Update dependency me.saket.swipe:swipe to v1.3.0

* Update MangaChapterListItem.kt

---------

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

# Conflicts:
#	app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt
2024-03-02 14:24:16 -05:00
AntsyLich c4df418081 Switch to Coil3
Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
(cherry picked from commit f72b6e4d7c1f2f93d705402e4d80c94160bef54d)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/App.kt
#	app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt
2024-03-02 14:22:54 -05:00
AntsyLich a9c79d5fb3 Remove custom Pager
Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
(cherry picked from commit 84984ef7e1d7242924120cd2f171cb9dd75bc916)
2024-03-02 14:02:34 -05:00
AntsyLich 529100a947 Enable experimental Compose compiler optimization
Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
(cherry picked from commit 9f48def1e2718abd5b4aad3cb6ee8af6b39e76cc)
2024-03-02 14:02:12 -05:00
AntsyLich 062f6d5aa0 ChapterDownloadIndicator: Remove composed modifier usage
Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
(cherry picked from commit e83bfb0d3511f3c049d1dfe6ca13e74467655e08)
2024-03-02 14:01:43 -05:00
AntsyLich deb0a95985 Upgrade Compose
(cherry picked from commit 0301362430af6f74678dcae801b70d6aeb371a56)
2024-03-02 14:01:34 -05:00
Shamicen ca70f80900 Made some changes to ComicInfo metadata (#459)
* Made some changes to ComicInfo metadata

The web field now contains a " " separated list of source and tracker urls.
The translator field will now use the source name if the scanlator field is empty.

* lint

* use already existing source instance

* made translator not nullable

* implemented requested changes

created new Mihon exclusive ComicInfo source field  and populated it with SourceName

reverted previous changes to translator field

* Update core-metadata/src/main/java/tachiyomi/core/metadata/comicinfo/ComicInfo.kt

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

* Update app/src/main/java/eu/kanade/domain/manga/model/Manga.kt

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

* Update app/src/main/java/eu/kanade/domain/manga/model/Manga.kt

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

* Update app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt

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

* Update app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt

---------

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

# Conflicts:
#	app/src/main/java/eu/kanade/domain/manga/model/Manga.kt
#	app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt
#	core-metadata/src/main/java/tachiyomi/core/metadata/comicinfo/ComicInfo.kt
2024-03-02 14:01:12 -05:00
renovate[bot] 23e3ec20b6 Update Kotlin (#422)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 802a2c5c1ea73c79967fb5b535b6249506df1870)
2024-03-02 13:59:43 -05:00
renovate[bot] 32d97ed194 Update dependency io.coil-kt:coil-bom to v2.6.0 (#447)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit c1c174698525e982107cc955d8dff711c4498989)
2024-03-02 13:59:34 -05:00
renovate[bot] 167a4e9820 Update dependency org.junit.jupiter:junit-jupiter to v5.10.2 (#419)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 4fcbd80a8ec72f4113d0ee9d457c000dcd8e7440)
2024-03-02 13:59:25 -05:00
renovate[bot] aef0b50663 Update dependency com.google.firebase:firebase-analytics-ktx to v21.5.1 (#417)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 16969193c718cc8e6cabb473ecb1d0d7bc02f33e)

# Conflicts:
#	gradle/libs.versions.toml
2024-03-02 13:59:16 -05:00
renovate[bot] 5b5e6c8f44 Update dependency androidx.test.uiautomator:uiautomator to v2.3.0 (#416)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 55637ddfe154c2ed60efb5d9d1466d2106c97f9f)
2024-03-02 13:58:48 -05:00
renovate[bot] 5dc96384bd Update detekt to v1.23.5 (#267)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit e50358dc4be7cbd866a79d052edc62689f0e4ca5)
2024-03-02 13:58:39 -05:00
AntsyLich affdea3ec2 Fix detekt issue
(cherry picked from commit 9254079957e383e4aa5c914ccd9705612e0892d0)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt
2024-03-02 13:57:49 -05:00
AntsyLich 1436d86c7e Don't add custom User Agent for MAL
Closes #298

(cherry picked from commit 7974a1fc0c2b0e237d5704a033a60ec0fd60cdbc)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt
2024-03-02 13:56:42 -05:00
renovate[bot] b7c9eaa981 Update dependency com.squareup.okio:okio to v3.8.0 (#423)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 1521c359412518731ef7338255cdd280321faa70)
2024-03-02 13:56:12 -05:00
Splintor db99ab526a Allow disabling reader's zoom out (#302)
* Allow disabling reader's zoom out (#62)

* Renamed disable zoom out pref and string

* Zoom to default rate if the scale is inferior

* Fixed null value check and formatting

* Fixed detekt

(cherry picked from commit c15f3f2fd5b11cc9c2088ae2fa444f4fe35ea740)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonFrame.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt
2024-03-02 13:55:43 -05:00
renovate[bot] 133c34dee2 Update dependency com.google.gms:google-services to v4.4.1 (#418)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 21020e1797ae9c0373e5132e1dbcd9a64455c17f)
2024-03-02 13:48:11 -05:00
Weblate (bot) 775cf258ba Translations update from Hosted Weblate (#301)
* 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 (Indonesian)

Currently translated at 100.0% (793 of 793 strings)

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

* Translated using Weblate (Chuvash)

Currently translated at 75.7% (601 of 793 strings)

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

* 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 (Chuvash)

Currently translated at 100.0% (17 of 17 strings)

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

* Translated using Weblate (Esperanto)

Currently translated at 100.0% (17 of 17 strings)

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

* Translated using Weblate (Turkish)

Currently translated at 100.0% (793 of 793 strings)

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

* Translated using Weblate (Turkish)

Currently translated at 100.0% (17 of 17 strings)

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

* Translated using Weblate (Turkish)

Currently translated at 100.0% (793 of 793 strings)

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

* Translated using Weblate (Turkish)

Currently translated at 100.0% (793 of 793 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 100.0% (793 of 793 strings)

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

* Translated using Weblate (Polish)

Currently translated at 100.0% (793 of 793 strings)

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

* 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 (Hungarian)

Currently translated at 97.3% (772 of 793 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 94.1% (16 of 17 strings)

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

* 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 (Romanian)

Currently translated at 99.3% (788 of 793 strings)

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

* Translated using Weblate (Romanian)

Currently translated at 100.0% (17 of 17 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (17 of 17 strings)

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

* 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 (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 (Nepali)

Currently translated at 100.0% (793 of 793 strings)

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

* 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 (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 (French)

Currently translated at 100.0% (17 of 17 strings)

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

* Translated using Weblate (Esperanto)

Currently translated at 62.0% (492 of 793 strings)

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

* Translated using Weblate (Esperanto)

Currently translated at 100.0% (17 of 17 strings)

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

* Translated using Weblate (Esperanto)

Currently translated at 63.6% (505 of 793 strings)

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

---------

Co-authored-by: bapeey <90949336+bapeey@users.noreply.github.com>
Co-authored-by: Eji-san <ejierubani@gmail.com>
Co-authored-by: C201 <derasetad@gmail.com>
Co-authored-by: Swyter <swyterzone@gmail.com>
Co-authored-by: Radoŝ Porka <animatorzPolski@gmail.com>
Co-authored-by: Deniz <denizgezgin365@gmail.com>
Co-authored-by: Uzuki Shimamura <hzy980512@126.com>
Co-authored-by: sebastians17 <sebastians117.ss@gmail.com>
Co-authored-by: ɴᴇᴋᴏ <s99095lkjjim@gmail.com>
Co-authored-by: B4LiN7 <B4LiN7@users.noreply.hosted.weblate.org>
Co-authored-by: Saft Octavian <saftoctavian@gmail.com>
Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: FateXBlood <zecrofelix@gmail.com>
Co-authored-by: Naga <yz2000.pro@gmail.com>
(cherry picked from commit 7edecae57f77ece7a5a3b457620c61e225fdc906)
2024-03-02 13:48:00 -05:00
Maddie Witman ed34807a58 Fix some issues from 7ff95e2 (#415)
* Fixed extra header introduced in 7ff95e2

* Removed parentheses to make detekt happy

* Updated relative date display for dates in the future

* Small cleanup for header creation logic

* replaced "and" with "&&" for better formatting

(cherry picked from commit 07f963d5ae16c3c8d7be025a7e9439ad2110ac71)
2024-03-02 13:47:43 -05:00
beerpsi 31acbbdcdc [ExtensionLoader] Prioritize extension classpath over app classpath (#433)
(cherry picked from commit ab02568ac6e9dabc7a41036bb3d8c77138125544)
2024-03-02 13:47:21 -05:00
MajorTanya ef7708e324 Fix DelayedTrackingUpdateJob spam on update errors (#411)
* Fix DelayedTrackingUpdateJob spam on update errors

DelayedTrackingUpdateJob would start spamming when it encountered an
error (e.g. a tracker has an issue) and never stop.
This seems to stem from a circular dependency between the Job's
`doWork` and TrackChapter's `await`.

TrackChapter sets up a completely new instance of the
DelayedTrackingUpdateJob if any Exception was thrown during the track
update.

This causes the Job to get replaced (as per the WorkManager's set
ExistingWorkPolicy).

Because of this, the guard clause at the start of doWork would never
trigger, as all instances of the Job would report being the 0th try
(because they were completely new instances).

This simple fix introduces a boolean `isRetry` parameter to
TrackChapter's await method, which is set to `false` by default.
DelayedTrackingUpdateJob however sets this parameter to `true`, which
means TrackChapter won't try to set up the Job again.

* Rename isRetry parameter to setupJobOnFailure

This also inverts the logic, so true & false were swapped.

(cherry picked from commit 617bf491eee1d1010dc23c3f6df5d148700feb44)
2024-03-02 13:47:09 -05:00
MajorTanya de353c3334 Address overridePendingTransition deprecation (#410)
This function is deprecated starting with API 34 "UpsideDownCake" and
should be replaced with `overrideActivityTransition`.

(cherry picked from commit 840b647b4b4e738fac546b7437dd5449679232a1)
2024-03-02 13:46:42 -05:00
AntsyLich b47a317c48 Tweak detekt config
(cherry picked from commit 1b0bbb84401005801e61107a0c36443af691b8e6)
2024-03-02 13:44:22 -05:00
AntsyLich 2b163c91a9 Cleanup [BaseColorScheme.getColorScheme]
(cherry picked from commit 95d4df9ca80a88e87e633fc24c7fec677bc9d9b6)
2024-03-02 13:44:13 -05:00
AntsyLich d380a078a2 Update gradle.properties
(cherry picked from commit fb86c470f6cfcb0b6c1bb7b2790366e0d0c0662e)
2024-03-02 13:43:09 -05:00
AntsyLich 556afacd13 Small cleanup in WorkerInfoScreen
(cherry picked from commit 5aec8f8018236a38106483da08f9cbc28261ac9b)
2024-03-02 13:42:56 -05:00
AntsyLich 598d622d0b Revert a mishap in 7ff95e21babda98dd1b479912278d6029cd15f0d
(cherry picked from commit e183cbb231c6d48a17c573dbd3f2c8ce04ff7031)
2024-03-02 13:42:37 -05:00
AntsyLich 3a1d0d65bf Ignore detekt [LongParameterList] for composables
(cherry picked from commit 6bdb37be65757ca903c5c2a249ca03331b04d673)
2024-03-02 13:41:52 -05:00
Jobobby04 cc7b8a9b69 Improve duplicate chapter set as read 2024-03-02 13:41:35 -05:00
Maddie Witman 6c6f09ac5a Refactor use of Java.util.date to Java.time.*, to fix localized date issues. (#402)
* Add support for localdate based relative times

* Update History Screen to use new localdate based relative times

* Update Updates Screen to use new localdate based relative times

* Cleaned up date util classes

* Updated build time display

* Code cleanup

* Fixed crash in settings

* Updated Preferences item

* Worker Info works

* Fixed Tracker date display

* Code changes to pass detekt

(cherry picked from commit 7ff95e21babda98dd1b479912278d6029cd15f0d)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryScreenModel.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt
#	app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt
2024-03-02 13:41:18 -05:00
MajorTanya 1fe309f363 Minor refactor of theming when expressions (#396)
* Minor refactor of theming when expressions

Avoids triggering detekt's CyclomaticComplexMethod warning because of
too many when branches, which would happen with one more theme being
added in these two locations.

In TachiyomiTheme, the Monet theme is separated because it requires
the current Compose context to function. The other themes do not and
are delegated to a Map.

* Implement requested changes

- moved themeResources out of the ThemingDelegate interface
- replaced single condition when with if expression

(cherry picked from commit 96c236e5c38248c875f2ac7596cd51845aa651ea)
2024-03-02 12:10:21 -05:00
renovate[bot] 3417fdb1a4 Update dependency androidx.test.ext:junit-ktx to v1.2.0-alpha03 (#340)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 72f3756a3b89de292d75d41fe6b5a59172039c45)
2024-03-02 12:10:13 -05:00
renovate[bot] a6394672e7 Update dependency androidx.test.espresso:espresso-core to v3.6.0-alpha03 (#339)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 0780385d2ebc9caca0bda0151d828ea6e036c768)
2024-03-02 12:10:07 -05:00
renovate[bot] 1fc97e4b7a Update lifecycle.version to v2.7.0 (#268)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 31e9273b1ff4ecfd1992beaa8ad10fa27f726cc2)
2024-03-02 12:09:58 -05:00
renovate[bot] fe853aa1c5 Update dependency com.github.requery:sqlite-android to v3.45.0 (#260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 088e37b2d8d4d790e926eda34119377c2f94ccf6)

# Conflicts:
#	gradle/libs.versions.toml
2024-03-02 12:09:47 -05:00
renovate[bot] 9c3f805eab Update dependency io.github.fornewid:material-motion-compose-core to v1.2.0 (#257)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 5b88f1bd94ae3c696681fe34a22453575e747ff6)
2024-03-02 12:08:30 -05:00
renovate[bot] 410eda6d6c Update dependency androidx.benchmark:benchmark-macro-junit4 to v1.2.3 (#255)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 18beb20aac774a83d6cb13f6dc276c3683aaf9a2)
2024-03-02 12:08:21 -05:00
renovate[bot] 719c24fb38 Update dependency gradle to v8.6 (#341)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 9bff20cb1a0918d7789b281952624fed890fbab7)
2024-03-02 12:08:04 -05:00
Jobobby04 a7cb182bbe Cleanup 2024-03-02 12:01:49 -05:00
Fermín Cirella dbb970d7b5 Make manga page preview row count configurable (#1087)
* Make manga page preview row count configurable

* Replace string with plural
2024-03-02 11:58:16 -05:00
Dexroneum 887a27cf3e [RU] Translations (#1085) 2024-03-02 11:57:24 -05:00
Shamicen eed8ffb9d4 fix password protect downloads and copying ComicInfo files in LocalSource (#1084)
* fix password protect downloads

* fixed copying of ComicInfo file in LocalSource.kt

* Return correct archive file

* Applied upstream fix

* Use tempFileManager instead of file path

* Use streams instead of files
2024-03-02 11:56:57 -05:00
Cuong M. Tran dd412e33ad Rename MangaDex's FollowStatus's property to better reflect its type (#1082) 2024-03-02 11:56:11 -05:00
ɴᴇᴋᴏ 94e5c33785 Update zh-rTW strings.xml (#1080)
* Update strings.xml

* Update new zh-rTW strings.xml

* Update strings.xml

https://github.com/jobobby04/TachiyomiSY/pull/1079#issue-2140674661
2024-03-02 11:55:47 -05:00
Luqman 6f3f109723 add mark read dupes on reading (#1079)
* Feature: mark read dupes

* dupe chapter reading add summary

* Update ReaderViewModel.kt

* Update i18n/src/commonMain/resources/MR/base/strings.xml

* Update app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt

---------

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>
2024-03-02 11:55:09 -05:00
Jobobby04 d8082de1db Ignore chapters with 0 or under chapter numbers 2024-02-18 11:47:19 -05:00
Jobobby04 2daccb57b5 1.10.4 2024-02-17 18:58:06 -05:00
Jobobby04 e64bddf9d5 Fix crashes related to MangaCover 2024-02-17 18:55:03 -05:00
pabalaba 9f6f15f64d Update workflow dependencies (#310)
* feat: updated build_pull_request actions version

* feat: updated build_push actions version

* feat: updated issue_moderator action version

* feat: updated deprecated arguments in build_pull_request

* feat: updated deprecated arguments in build_push

* feat: removed explicit declaration of gradle version

(cherry picked from commit 170daf9fb2e566fea598eb5dc0cd710f979c7b7a)

# Conflicts:
#	.github/workflows/build_pull_request.yml
#	.github/workflows/build_push.yml
2024-02-17 18:50:58 -05:00
234 changed files with 6441 additions and 1690 deletions
+2 -2
View File
@@ -53,7 +53,7 @@ body:
label: TachiyomiSY version
description: You can find your TachiyomiSY version in **More → About**.
placeholder: |
Example: "1.10.3"
Example: "1.10.5"
validations:
required: true
@@ -96,7 +96,7 @@ body:
required: true
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https:/mihon.app/docs/guides/troubleshooting/).
required: true
- label: I have updated the app to version **[1.10.3](https://github.com/jobobby04/tachiyomisy/releases/latest)**.
- label: I have updated the app to version **[1.10.5](https://github.com/jobobby04/tachiyomisy/releases/latest)**.
required: true
- label: I have updated all installed extensions.
required: true
+1 -1
View File
@@ -31,7 +31,7 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to version **[1.10.3](https://github.com/jobobby04/tachiyomisy/releases/latest)**.
- label: I have updated the app to version **[1.10.5](https://github.com/jobobby04/tachiyomisy/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+4 -3
View File
@@ -32,10 +32,11 @@ jobs:
java-version: 17
distribution: adopt
- name: Set up gradle
uses: gradle/actions/setup-gradle@v3
- name: Build app
uses: gradle/gradle-command-action@v2
with:
arguments: assembleDevDebug
run: ./gradlew detekt assembleDevDebug
- name: Upload APK
uses: actions/upload-artifact@v3
+12 -4
View File
@@ -18,7 +18,7 @@ jobs:
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
uses: gradle/wrapper-validation-action@v2
- name: Setup Android SDK
run: |
@@ -30,6 +30,9 @@ jobs:
java-version: 17
distribution: adopt
- name: Set up gradle
uses: gradle/actions/setup-gradle@v3
# SY <--
- name: Write google-services.json
uses: DamianReeves/write-file-action@v1.2
@@ -37,12 +40,17 @@ jobs:
path: app/google-services.json
contents: ${{ secrets.GOOGLE_SERVICES_TEXT }}
write-mode: overwrite
- name: Write client_secrets.json
uses: DamianReeves/write-file-action@v1.2
with:
path: app/src/main/assets/client_secrets.json
contents: ${{ secrets.CLIENT_SECRETS_TEXT }}
write-mode: overwrite
# SY -->
- name: Build app and run unit tests
uses: gradle/gradle-command-action@v2
with:
arguments: assembleStandardRelease testStandardReleaseUnitTest --stacktrace
run: ./gradlew detekt assembleStandardRelease testStandardReleaseUnitTest --stacktrace
- name: Sign APK
uses: r0adkll/sign-android-release@v1
+4 -7
View File
@@ -18,13 +18,10 @@ jobs:
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- name: TAG - Bump version and push tag
uses: anothrNick/github-tag-action@1.39.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WITH_V: true
RELEASE_BRANCHES: master
DEFAULT_BUMP: patch
- name: Create Tag
run: |
git tag "preview-${{ github.run_number }}"
git push origin "preview-${{ github.run_number }}"
- name: PING - Dispatch initiating repository event
run: |
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Moderate issues
uses: tachiyomiorg/issue-moderator-action@v2
uses: tachiyomiorg/issue-moderator-action@v2.6.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
duplicate-label: Duplicate
+1
View File
@@ -22,3 +22,4 @@ TODO.md
CHANGELOG.md
/captures
build.sh
/app/src/main/assets/client_secrets.json
+17
View File
@@ -52,3 +52,20 @@ When creating a fork, remember to:
- To avoid having your data polluting the main app's analytics and crash report services:
- If you want to use Firebase analytics, replace [`google-services.json`](https://github.com/tachiyomiorg/tachiyomi/blob/master/app/src/standard/google-services.json) with your own
- If you want to use ACRA crash reporting, replace the `ACRA_URI` endpoint in [`build.gradle.kts`](https://github.com/tachiyomiorg/tachiyomi/blob/master/app/build.gradle.kts) with your own
### Supporting Cloud Sync - Google Drive Implementation
1. Go to [Google Cloud Console](https://console.cloud.google.com)
2. Create a new project
3. Go to API & Services -> Library -> Google Drive API and click enable
4. Go to API & Services -> Oauth consent screen
5. Create it, fill in the app name, user support email, and developer contact information
6. In the next screen, click add or remove scopes, and add the `.../auth/drive.appdata` and `.../auth/drive.file` scopes
7. Don't add any test users and go back to the dashboard
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
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/`
+4 -5
View File
@@ -1,16 +1,16 @@
| Preview Builds | Release Builds | Tachiyomi Support Server |
| Preview Builds | Release Builds | Mihon Support Server |
|-------|----------|----------|
| [![Preview](https://github.com/jobobby04/TachiyomiSYPreview/workflows/Remote%20Dispatch%20Build%20App/badge.svg)](https://github.com/jobobby04/TachiyomiSYPreview/releases) | [![stable release](https://img.shields.io/github/release/jobobby04/tachiyomisy.svg?maxAge=3600&label=download)](https://github.com/jobobby04/tachiyomisy/releases/latest) | [![Discord](https://img.shields.io/discord/1195734228319617024.svg?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/mihon) |
# ![app icon](./.github/readme-images/app-icon.png)TachiyomiSY
Tachiyomi is a free and open source manga reader for Android 6.0 and above. This version of Tachiyomi, TachiyomiSY was based off TachiyomiAZ. This version is meant to push forward in the ways of usability and features. TachiyomiSY tries to push forward where it can, but staying in a place where it can easily grab updates and features from the main app, it tries to make new features, or take features from other forks like J2K and Neko.
Mihon is a free and open source manga reader for Android 6.0 and above. This version of Mihon, TachiyomiSY was based off TachiyomiAZ. This version is meant to push forward in the ways of usability and features. TachiyomiSY tries to push forward where it can, but staying in a place where it can easily grab updates and features from the main app, it tries to make new features, or take features from other forks like J2K and Neko.
![screenshots of app](./.github/readme-images/screens.png)
## Features
Features of Tachiyomi(original) include:
Features of Mihon(original) include:
* Online reading from a variety of sources
* Local reading of downloaded content
* A configurable reader with multiple viewers, reading directions and other settings.
@@ -42,7 +42,6 @@ Features of TachiyomiSY include:
* Page preload customization
* Customize image cache size
* Batch import of custom sources and featured extensions
* Automatic CAPTCHA solving
* Advanced source settings page, searching, enable/disable all
* Click tag for local search, long click tag for global search
* Merge multiple of the same manga from different sources
@@ -116,4 +115,4 @@ See [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md).
## FAQ
[See our website.](https://mihon.app/)
You can also reach out to us on [Discord](https://discord.gg/mihon).
You can also reach out to us on [Discord](https://discord.gg/mihon).
+13 -4
View File
@@ -26,8 +26,8 @@ android {
defaultConfig {
applicationId = "eu.kanade.tachiyomi.sy"
versionCode = 63
versionName = "1.10.3"
versionCode = 66
versionName = "1.10.5"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
@@ -215,7 +215,7 @@ dependencies {
// Disk
implementation(libs.disklrucache)
implementation(libs.unifile)
implementation(libs.junrar)
implementation(libs.bundles.archive)
// SY -->
implementation(libs.zip4j)
// SY <--
@@ -248,6 +248,9 @@ dependencies {
implementation(libs.compose.materialmotion)
implementation(libs.swipe)
implementation(libs.google.api.services.drive)
implementation(libs.google.api.client.oauth)
// Logging
implementation(libs.logcat)
@@ -310,7 +313,7 @@ tasks {
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
"-opt-in=coil.annotation.ExperimentalCoilApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
@@ -330,6 +333,12 @@ tasks {
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",
)
}
}
+25 -1
View File
@@ -122,10 +122,19 @@
# XmlUtil
-keep public enum nl.adaptivity.xmlutil.EventType { *; }
# Apache Commons Compress
-keep class * extends org.apache.commons.compress.archivers.zip.ZipExtraField { <init>(); }
# Firebase
-keep class com.google.firebase.installations.** { *; }
-keep interface com.google.firebase.installations.** { *; }
# Google Drive
-keep class com.google.api.services.** { *; }
# Google OAuth
-keep class com.google.api.client.** { *; }
# SY -->
# SqlCipher
-keepclassmembers class net.zetetic.database.sqlcipher.SQLiteCustomFunction { *; }
@@ -260,6 +269,9 @@
-keep,allowoptimization class * extends uy.kohesive.injekt.api.TypeReference
-keep,allowoptimization public class io.requery.android.database.sqlite.SQLiteConnection { *; }
# Keep apache http client
-keep class org.apache.http.** { *; }
# Suggested rules
-dontwarn com.oracle.svm.core.annotate.AutomaticFeature
-dontwarn com.oracle.svm.core.annotate.Delete
@@ -272,4 +284,16 @@
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn java.lang.Module
-dontwarn org.graalvm.nativeimage.hosted.RuntimeResourceAccess
-dontwarn org.jspecify.annotations.NullMarked
-dontwarn org.jspecify.annotations.NullMarked
-dontwarn javax.naming.InvalidNameException
-dontwarn javax.naming.NamingException
-dontwarn javax.naming.directory.Attribute
-dontwarn javax.naming.directory.Attributes
-dontwarn javax.naming.ldap.LdapName
-dontwarn javax.naming.ldap.Rdn
-dontwarn org.ietf.jgss.GSSContext
-dontwarn org.ietf.jgss.GSSCredential
-dontwarn org.ietf.jgss.GSSException
-dontwarn org.ietf.jgss.GSSManager
-dontwarn org.ietf.jgss.GSSName
-dontwarn org.ietf.jgss.Oid
+14
View File
@@ -188,6 +188,20 @@
<data android:host="shikimori-auth" />
</intent-filter>
</activity>
<activity
android:name=".ui.setting.track.GoogleDriveLoginActivity"
android:label="GoogleDrive"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="eu.kanade.google.oauth" />
</intent-filter>
</activity>
<activity
android:name="exh.ui.login.EhLoginActivity"
@@ -39,4 +39,5 @@ fun Chapter.toDbChapter(): DbChapter = ChapterImpl().also {
it.date_upload = dateUpload
it.chapter_number = chapterNumber.toFloat()
it.source_order = sourceOrder.toInt()
it.last_modified = lastModifiedAt
}
@@ -104,7 +104,13 @@ fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get()): Boolean {
/**
* Creates a ComicInfo instance based on the manga and chapter metadata.
*/
fun getComicInfo(manga: Manga, chapter: Chapter, chapterUrl: String, categories: List<String>?) = ComicInfo(
fun getComicInfo(
manga: Manga,
chapter: Chapter,
urls: List<String>,
categories: List<String>?,
sourceName: String,
) = ComicInfo(
title = ComicInfo.Title(chapter.name),
series = ComicInfo.Series(manga.title),
number = chapter.chapterNumber.takeIf { it >= 0 }?.let {
@@ -114,7 +120,7 @@ fun getComicInfo(manga: Manga, chapter: Chapter, chapterUrl: String, categories:
ComicInfo.Number(it.toString())
}
},
web = ComicInfo.Web(chapterUrl),
web = ComicInfo.Web(urls.joinToString(" ")),
summary = manga.description?.let { ComicInfo.Summary(it) },
writer = manga.author?.let { ComicInfo.Writer(it) },
penciller = manga.artist?.let { ComicInfo.Penciller(it) },
@@ -124,6 +130,7 @@ fun getComicInfo(manga: Manga, chapter: Chapter, chapterUrl: String, categories:
ComicInfoPublishingStatus.toComicInfoValue(manga.status),
),
categories = categories?.let { ComicInfo.CategoriesTachiyomi(it.joinToString()) },
source = ComicInfo.SourceMihon(sourceName),
// SY -->
padding = CbzCrypto.createComicInfoPadding()?.let { ComicInfo.PaddingTachiyomiSY(it) },
// SY <--
@@ -0,0 +1,89 @@
package eu.kanade.domain.sync
import eu.kanade.domain.sync.models.SyncSettings
import eu.kanade.tachiyomi.data.sync.models.SyncTriggerOptions
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import java.util.UUID
class SyncPreferences(
private val preferenceStore: PreferenceStore,
) {
fun clientHost() = preferenceStore.getString("sync_client_host", "https://sync.tachiyomi.org")
fun clientAPIKey() = preferenceStore.getString("sync_client_api_key", "")
fun lastSyncTimestamp() = preferenceStore.getLong(Preference.appStateKey("last_sync_timestamp"), 0L)
fun syncInterval() = preferenceStore.getInt("sync_interval", 0)
fun syncService() = preferenceStore.getInt("sync_service", 0)
fun googleDriveAccessToken() = preferenceStore.getString(
Preference.appStateKey("google_drive_access_token"),
"",
)
fun googleDriveRefreshToken() = preferenceStore.getString(
Preference.appStateKey("google_drive_refresh_token"),
"",
)
fun uniqueDeviceID(): String {
val uniqueIDPreference = preferenceStore.getString("unique_device_id", "")
// Retrieve the current value of the preference
var uniqueID = uniqueIDPreference.get()
if (uniqueID.isBlank()) {
uniqueID = UUID.randomUUID().toString()
uniqueIDPreference.set(uniqueID)
}
return uniqueID
}
fun isSyncEnabled(): Boolean {
return syncService().get() != 0
}
fun getSyncSettings(): SyncSettings {
return SyncSettings(
libraryEntries = preferenceStore.getBoolean("library_entries", true).get(),
categories = preferenceStore.getBoolean("categories", true).get(),
chapters = preferenceStore.getBoolean("chapters", true).get(),
tracking = preferenceStore.getBoolean("tracking", true).get(),
history = preferenceStore.getBoolean("history", true).get(),
appSettings = preferenceStore.getBoolean("appSettings", true).get(),
sourceSettings = preferenceStore.getBoolean("sourceSettings", true).get(),
privateSettings = preferenceStore.getBoolean("privateSettings", true).get(),
)
}
fun setSyncSettings(syncSettings: SyncSettings) {
preferenceStore.getBoolean("library_entries", true).set(syncSettings.libraryEntries)
preferenceStore.getBoolean("categories", true).set(syncSettings.categories)
preferenceStore.getBoolean("chapters", true).set(syncSettings.chapters)
preferenceStore.getBoolean("tracking", true).set(syncSettings.tracking)
preferenceStore.getBoolean("history", true).set(syncSettings.history)
preferenceStore.getBoolean("appSettings", true).set(syncSettings.appSettings)
preferenceStore.getBoolean("sourceSettings", true).set(syncSettings.sourceSettings)
preferenceStore.getBoolean("privateSettings", true).set(syncSettings.privateSettings)
}
fun getSyncTriggerOptions(): SyncTriggerOptions {
return SyncTriggerOptions(
syncOnChapterRead = preferenceStore.getBoolean("sync_on_chapter_read", false).get(),
syncOnChapterOpen = preferenceStore.getBoolean("sync_on_chapter_open", false).get(),
syncOnAppStart = preferenceStore.getBoolean("sync_on_app_start", false).get(),
syncOnAppResume = preferenceStore.getBoolean("sync_on_app_resume", false).get(),
)
}
fun setSyncTriggerOptions(syncTriggerOptions: SyncTriggerOptions) {
preferenceStore.getBoolean("sync_on_chapter_read", false)
.set(syncTriggerOptions.syncOnChapterRead)
preferenceStore.getBoolean("sync_on_chapter_open", false)
.set(syncTriggerOptions.syncOnChapterOpen)
preferenceStore.getBoolean("sync_on_app_start", false)
.set(syncTriggerOptions.syncOnAppStart)
preferenceStore.getBoolean("sync_on_app_resume", false)
.set(syncTriggerOptions.syncOnAppResume)
}
}
@@ -0,0 +1,12 @@
package eu.kanade.domain.sync.models
data class SyncSettings(
val libraryEntries: Boolean = true,
val categories: Boolean = true,
val chapters: Boolean = true,
val tracking: Boolean = true,
val history: Boolean = true,
val appSettings: Boolean = true,
val sourceSettings: Boolean = true,
val privateSettings: Boolean = false,
)
@@ -23,7 +23,7 @@ class TrackChapter(
private val delayedTrackingStore: DelayedTrackingStore,
) {
suspend fun await(context: Context, mangaId: Long, chapterNumber: Double) {
suspend fun await(context: Context, mangaId: Long, chapterNumber: Double, setupJobOnFailure: Boolean = true) {
withNonCancellableContext {
val tracks = getTracks.await(mangaId)
if (tracks.isEmpty()) return@withNonCancellableContext
@@ -34,7 +34,7 @@ class TrackChapter(
service == null ||
!service.isLoggedIn ||
chapterNumber <= track.lastChapterRead /* SY --> */ ||
(service is MdList && track.status == FollowStatus.UNFOLLOWED.int.toLong())/* SY <-- */
(service is MdList && track.status == FollowStatus.UNFOLLOWED.long)/* SY <-- */
) {
return@mapNotNull null
}
@@ -50,7 +50,9 @@ class TrackChapter(
delayedTrackingStore.remove(track.id)
} catch (e: Exception) {
delayedTrackingStore.add(track.id, chapterNumber)
DelayedTrackingUpdateJob.setupTask(context)
if (setupJobOnFailure) {
DelayedTrackingUpdateJob.setupTask(context)
}
throw e
}
}
@@ -45,7 +45,7 @@ class DelayedTrackingUpdateJob(private val context: Context, workerParams: Worke
logcat(LogPriority.DEBUG) {
"Updating delayed track item: ${track.mangaId}, last chapter read: ${track.lastChapterRead}"
}
trackChapter.await(context, track.mangaId, track.lastChapterRead)
trackChapter.await(context, track.mangaId, track.lastChapterRead, setupJobOnFailure = false)
}
}
@@ -8,8 +8,8 @@ import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.isDynamicColorAvailable
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
class UiPreferences(
@@ -46,6 +46,8 @@ class UiPreferences(
fun mergeInOverflow() = preferenceStore.getBoolean("merge_in_overflow", true)
fun previewsRowCount() = preferenceStore.getInt("pref_previews_row_count", 4)
fun useNewSourceNavigation() = preferenceStore.getBoolean("use_new_source_navigation", true)
fun bottomBarLabels() = preferenceStore.getBoolean("pref_show_bottom_bar_labels", true)
@@ -57,9 +59,9 @@ class UiPreferences(
// SY <--
companion object {
fun dateFormat(format: String): DateFormat = when (format) {
"" -> DateFormat.getDateInstance(DateFormat.SHORT)
else -> SimpleDateFormat(format, Locale.getDefault())
fun dateFormat(format: String): DateTimeFormatter = when (format) {
"" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
else -> DateTimeFormatter.ofPattern(format, Locale.getDefault())
}
}
}
@@ -25,7 +25,7 @@ import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import eu.kanade.domain.source.model.icon
import eu.kanade.presentation.util.rememberResourceBitmapPainter
import eu.kanade.tachiyomi.R
@@ -50,7 +50,8 @@ import tachiyomi.presentation.core.components.Badge
import tachiyomi.presentation.core.components.BadgeGroup
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import java.util.Date
import java.time.Instant
import java.time.ZoneId
@Composable
fun BrowseSourceEHentaiList(
@@ -128,9 +129,11 @@ fun BrowseSourceEHentaiListItem(
}
val datePosted by produceState("", metadata) {
value = withIOContext {
runCatching { metadata.datePosted?.let { MetadataUtil.EX_DATE_FORMAT.format(Date(it)) } }
.getOrNull()
.orEmpty()
runCatching {
metadata.datePosted?.let {
MetadataUtil.EX_DATE_FORMAT.format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()))
}
}.getOrNull().orEmpty()
}
}
val genre by produceState<Pair<GenreColor, StringResource>?>(null, metadata) {
@@ -9,20 +9,26 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@Composable
fun relativeDateText(
dateEpochMillis: Long,
): String {
return relativeDateText(
date = Date(dateEpochMillis).takeIf { dateEpochMillis > 0L },
localDate = LocalDate.ofInstant(
Instant.ofEpochMilli(dateEpochMillis),
ZoneId.systemDefault(),
)
.takeIf { dateEpochMillis > 0L },
)
}
@Composable
fun relativeDateText(
date: Date?,
localDate: LocalDate?,
): String {
val context = LocalContext.current
@@ -30,11 +36,10 @@ fun relativeDateText(
val relativeTime = remember { preferences.relativeTime().get() }
val dateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) }
return date
?.toRelativeString(
context = context,
relative = relativeTime,
dateFormat = dateFormat,
)
return localDate?.toRelativeString(
context = context,
relative = relativeTime,
dateFormat = dateFormat,
)
?: stringResource(MR.strings.not_applicable)
}
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
@@ -29,7 +30,6 @@ import androidx.compose.ui.util.fastForEachIndexed
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.HorizontalPager
import tachiyomi.presentation.core.components.material.TabText
import tachiyomi.presentation.core.i18n.stringResource
@@ -78,9 +78,8 @@ fun TabbedDialog(
modifier = Modifier.animateContentSize(),
state = pagerState,
verticalAlignment = Alignment.Top,
) { page ->
content(page)
}
pageContent = { page -> content(page) }
)
}
}
}
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
@@ -24,7 +25,6 @@ import dev.icerock.moko.resources.StringResource
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
import tachiyomi.presentation.core.components.HorizontalPager
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.TabText
import tachiyomi.presentation.core.i18n.stringResource
@@ -29,7 +29,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen
import java.util.Date
import java.time.LocalDate
@Composable
fun HistoryScreen(
@@ -134,7 +134,7 @@ private fun HistoryScreenContent(
}
sealed interface HistoryUiModel {
data class Header(val date: Date) : HistoryUiModel
data class Header(val date: LocalDate) : HistoryUiModel
data class Item(val item: HistoryWithRelations) : HistoryUiModel
}
@@ -7,6 +7,7 @@ import kotlinx.collections.immutable.toImmutableList
import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.domain.manga.model.MangaCover
import java.time.Instant
import java.time.LocalDate
import java.time.temporal.ChronoUnit
import java.util.Date
import kotlin.random.Random
@@ -73,10 +74,10 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
private object HistoryUiModelExamples {
val headerToday = header()
val headerTomorrow =
HistoryUiModel.Header(Date.from(Instant.now().plus(1, ChronoUnit.DAYS)))
HistoryUiModel.Header(LocalDate.now().plusDays(1))
fun header(instantBuilder: (Instant) -> Instant = { it }) =
HistoryUiModel.Header(Date.from(instantBuilder(Instant.now())))
HistoryUiModel.Header(LocalDate.from(instantBuilder(Instant.now())))
fun items() = sequence {
var count = 1
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -22,7 +23,6 @@ import eu.kanade.tachiyomi.ui.library.LibraryItem
import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.HorizontalPager
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.util.plus
@@ -38,6 +38,7 @@ fun LibraryToolbar(
onClickRefresh: () -> Unit,
onClickGlobalUpdate: () -> Unit,
onClickOpenRandomManga: () -> Unit,
onClickSyncNow: () -> Unit,
// SY -->
onClickSyncExh: (() -> Unit)?,
// SY <--
@@ -60,6 +61,7 @@ fun LibraryToolbar(
onClickRefresh = onClickRefresh,
onClickGlobalUpdate = onClickGlobalUpdate,
onClickOpenRandomManga = onClickOpenRandomManga,
onClickSyncNow = onClickSyncNow,
// SY -->
onClickSyncExh = onClickSyncExh,
// SY <--
@@ -77,6 +79,7 @@ private fun LibraryRegularToolbar(
onClickRefresh: () -> Unit,
onClickGlobalUpdate: () -> Unit,
onClickOpenRandomManga: () -> Unit,
onClickSyncNow: () -> Unit,
// SY -->
onClickSyncExh: (() -> Unit)?,
// SY <--
@@ -125,7 +128,10 @@ private fun LibraryRegularToolbar(
title = stringResource(MR.strings.action_open_random_manga),
onClick = onClickOpenRandomManga,
),
AppBar.OverflowAction(
title = stringResource(MR.strings.sync_library),
onClick = onClickSyncNow,
),
).builder().apply {
// SY -->
if (onClickSyncExh != null) {
@@ -106,7 +106,8 @@ import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrollingUp
import tachiyomi.source.local.isLocal
import java.time.Instant
import java.util.Date
import java.time.ZoneId
import java.time.ZonedDateTime
@Composable
fun MangaScreen(
@@ -150,6 +151,7 @@ fun MangaScreen(
onMergeWithAnotherClicked: () -> Unit,
onOpenPagePreview: (Int) -> Unit,
onMorePreviewsClicked: () -> Unit,
previewsRowCount: Int,
// SY <--
// For bottom action menu
@@ -208,6 +210,7 @@ fun MangaScreen(
onMergeWithAnotherClicked = onMergeWithAnotherClicked,
onOpenPagePreview = onOpenPagePreview,
onMorePreviewsClicked = onMorePreviewsClicked,
previewsRowCount = previewsRowCount,
// SY <--
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
@@ -253,6 +256,7 @@ fun MangaScreen(
onMergeWithAnotherClicked = onMergeWithAnotherClicked,
onOpenPagePreview = onOpenPagePreview,
onMorePreviewsClicked = onMorePreviewsClicked,
previewsRowCount = previewsRowCount,
// SY <--
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
@@ -308,6 +312,7 @@ private fun MangaScreenSmallImpl(
onMergeWithAnotherClicked: () -> Unit,
onOpenPagePreview: (Int) -> Unit,
onMorePreviewsClicked: () -> Unit,
previewsRowCount: Int,
// SY <--
// For bottom action menu
@@ -544,13 +549,14 @@ private fun MangaScreenSmallImpl(
}
}
if (state.pagePreviewsState !is PagePreviewState.Unused) {
if (state.pagePreviewsState !is PagePreviewState.Unused && previewsRowCount > 0) {
PagePreviewItems(
pagePreviewState = state.pagePreviewsState,
onOpenPage = onOpenPagePreview,
onMorePreviewsClicked = onMorePreviewsClicked,
maxWidth = maxWidth,
setMaxWidth = { maxWidth = it }
setMaxWidth = { maxWidth = it },
rowCount = previewsRowCount,
)
}
// SY <--
@@ -632,6 +638,7 @@ fun MangaScreenLargeImpl(
onMergeWithAnotherClicked: () -> Unit,
onOpenPagePreview: (Int) -> Unit,
onMorePreviewsClicked: () -> Unit,
previewsRowCount: Int,
// SY <--
// For bottom action menu
@@ -832,11 +839,12 @@ fun MangaScreenLargeImpl(
onMergeWithAnotherClicked = onMergeWithAnotherClicked,
)
}
if (state.pagePreviewsState !is PagePreviewState.Unused) {
if (state.pagePreviewsState !is PagePreviewState.Unused && previewsRowCount > 0) {
PagePreviews(
pagePreviewState = state.pagePreviewsState,
onOpenPage = onOpenPagePreview,
onMorePreviewsClicked = onMorePreviewsClicked,
rowCount = previewsRowCount,
)
}
// SY <--
@@ -979,9 +987,10 @@ private fun LazyListScope.sharedChapterItems(
?.let {
// SY -->
if (manga.isEhBasedManga()) {
MetadataUtil.EX_DATE_FORMAT.format(Date(it))
MetadataUtil.EX_DATE_FORMAT
.format(ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()))
} else {
relativeDateText(Date(item.chapter.dateUpload))
relativeDateText(item.chapter.dateUpload)
}
// SY <--
},
@@ -2,7 +2,6 @@ package eu.kanade.presentation.manga.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -24,8 +23,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
@@ -91,6 +91,7 @@ private fun NotDownloadedIndicator(
.size(IconButtonTokens.StateLayerSize)
.commonClickable(
enabled = enabled,
hapticFeedback = LocalHapticFeedback.current,
onLongClick = { onClick(ChapterDownloadAction.START_NOW) },
onClick = { onClick(ChapterDownloadAction.START) },
)
@@ -120,6 +121,7 @@ private fun DownloadingIndicator(
.size(IconButtonTokens.StateLayerSize)
.commonClickable(
enabled = enabled,
hapticFeedback = LocalHapticFeedback.current,
onLongClick = { onClick(ChapterDownloadAction.CANCEL) },
onClick = { isMenuExpanded = true },
),
@@ -136,6 +138,8 @@ private fun DownloadingIndicator(
modifier = IndicatorModifier,
color = strokeColor,
strokeWidth = IndicatorStrokeWidth,
trackColor = Color.Transparent,
strokeCap = StrokeCap.Butt,
)
} else {
val animatedProgress by animateFloatAsState(
@@ -152,6 +156,9 @@ private fun DownloadingIndicator(
modifier = IndicatorModifier,
color = strokeColor,
strokeWidth = IndicatorSize / 2,
trackColor = Color.Transparent,
strokeCap = StrokeCap.Butt,
gapSize = 0.dp,
)
}
DropdownMenu(expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false }) {
@@ -191,6 +198,7 @@ private fun DownloadedIndicator(
.size(IconButtonTokens.StateLayerSize)
.commonClickable(
enabled = enabled,
hapticFeedback = LocalHapticFeedback.current,
onLongClick = { isMenuExpanded = true },
onClick = { isMenuExpanded = true },
),
@@ -225,6 +233,7 @@ private fun ErrorIndicator(
.size(IconButtonTokens.StateLayerSize)
.commonClickable(
enabled = enabled,
hapticFeedback = LocalHapticFeedback.current,
onLongClick = { onClick(ChapterDownloadAction.START) },
onClick = { onClick(ChapterDownloadAction.START) },
),
@@ -241,26 +250,23 @@ private fun ErrorIndicator(
private fun Modifier.commonClickable(
enabled: Boolean,
hapticFeedback: HapticFeedback,
onLongClick: () -> Unit,
onClick: () -> Unit,
) = composed {
val haptic = LocalHapticFeedback.current
Modifier.combinedClickable(
enabled = enabled,
onLongClick = {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
onClick = onClick,
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(
bounded = false,
radius = IconButtonTokens.StateLayerSize / 2,
),
)
}
) = this.combinedClickable(
enabled = enabled,
onLongClick = {
onLongClick()
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
},
onClick = onClick,
role = Role.Button,
interactionSource = null,
indication = ripple(
bounded = false,
radius = IconButtonTokens.StateLayerSize / 2,
),
)
private val IndicatorSize = 26.dp
private val IndicatorPadding = 2.dp
@@ -239,6 +239,7 @@ fun LibraryBottomActionMenu(
onClickCleanTitles: (() -> Unit)?,
onClickMigrate: (() -> Unit)?,
onClickAddToMangaDex: (() -> Unit)?,
onClickResetInfo: (() -> Unit)?,
// SY <--
modifier: Modifier = Modifier,
) {
@@ -267,7 +268,7 @@ fun LibraryBottomActionMenu(
}
}
// SY -->
val showOverflow = onClickCleanTitles != null || onClickAddToMangaDex != null
val showOverflow = onClickCleanTitles != null || onClickAddToMangaDex != null || onClickResetInfo != null
val configuration = LocalConfiguration.current
val moveMarkPrev = remember { !configuration.isTabletUi() }
var overFlowOpen by remember { mutableStateOf(false) }
@@ -364,6 +365,12 @@ fun LibraryBottomActionMenu(
onClick = onClickAddToMangaDex,
)
}
if (onClickResetInfo != null) {
DropdownMenuItem(
text = { Text(text = stringResource(SYMR.strings.reset_info)) },
onClick = onClickResetInfo,
)
}
}
} else {
Button(
@@ -24,36 +24,27 @@ import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.kanade.tachiyomi.data.download.model.Download
import me.saket.swipe.SwipeableActionsBox
import me.saket.swipe.rememberSwipeableActionsState
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.ReadItemAlpha
import tachiyomi.presentation.core.components.material.SecondaryItemAlpha
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.selectedBackground
import kotlin.math.absoluteValue
@Composable
fun MangaChapterListItem(
@@ -78,158 +69,127 @@ fun MangaChapterListItem(
onChapterSwipe: (LibraryPreferences.ChapterSwipeAction) -> Unit,
modifier: Modifier = Modifier,
) {
val haptic = LocalHapticFeedback.current
val density = LocalDensity.current
val textAlpha = if (read) ReadItemAlpha else 1f
val textSubtitleAlpha = if (read) ReadItemAlpha else SecondaryItemAlpha
// Increase touch slop of swipe action to reduce accidental trigger
val configuration = LocalViewConfiguration.current
CompositionLocalProvider(
LocalViewConfiguration provides object : ViewConfiguration by configuration {
override val touchSlop: Float = configuration.touchSlop * 3f
},
val start = getSwipeAction(
action = chapterSwipeStartAction,
read = read,
bookmark = bookmark,
downloadState = downloadStateProvider(),
background = MaterialTheme.colorScheme.primaryContainer,
onSwipe = { onChapterSwipe(chapterSwipeStartAction) },
)
val end = getSwipeAction(
action = chapterSwipeEndAction,
read = read,
bookmark = bookmark,
downloadState = downloadStateProvider(),
background = MaterialTheme.colorScheme.primaryContainer,
onSwipe = { onChapterSwipe(chapterSwipeEndAction) },
)
SwipeableActionsBox(
modifier = Modifier.clipToBounds(),
startActions = listOfNotNull(start),
endActions = listOfNotNull(end),
swipeThreshold = swipeActionThreshold,
backgroundUntilSwipeThreshold = MaterialTheme.colorScheme.surfaceContainerLowest,
) {
val start = getSwipeAction(
action = chapterSwipeStartAction,
read = read,
bookmark = bookmark,
downloadState = downloadStateProvider(),
background = MaterialTheme.colorScheme.primaryContainer,
onSwipe = { onChapterSwipe(chapterSwipeStartAction) },
)
val end = getSwipeAction(
action = chapterSwipeEndAction,
read = read,
bookmark = bookmark,
downloadState = downloadStateProvider(),
background = MaterialTheme.colorScheme.primaryContainer,
onSwipe = { onChapterSwipe(chapterSwipeEndAction) },
)
val swipeableActionsState = rememberSwipeableActionsState()
LaunchedEffect(Unit) {
// Haptic effect when swipe over threshold
val swipeActionThresholdPx = with(density) { swipeActionThreshold.toPx() }
snapshotFlow { swipeableActionsState.offset.value.absoluteValue > swipeActionThresholdPx }
.collect { if (it) haptic.performHapticFeedback(HapticFeedbackType.LongPress) }
}
SwipeableActionsBox(
modifier = Modifier.clipToBounds(),
state = swipeableActionsState,
startActions = listOfNotNull(start),
endActions = listOfNotNull(end),
swipeThreshold = swipeActionThreshold,
backgroundUntilSwipeThreshold = MaterialTheme.colorScheme.surfaceContainerLowest,
Row(
modifier = modifier
.selectedBackground(selected)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp),
) {
Row(
modifier = modifier
.selectedBackground(selected)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp),
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
var textHeight by remember { mutableIntStateOf(0) }
if (!read) {
Icon(
imageVector = Icons.Filled.Circle,
contentDescription = stringResource(MR.strings.unread),
modifier = Modifier
.height(8.dp)
.padding(end = 4.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
if (bookmark) {
Icon(
imageVector = Icons.Filled.Bookmark,
contentDescription = stringResource(MR.strings.action_filter_bookmarked),
modifier = Modifier
.sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
tint = MaterialTheme.colorScheme.primary,
)
}
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current.copy(alpha = textAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = { textHeight = it.size.height },
var textHeight by remember { mutableIntStateOf(0) }
if (!read) {
Icon(
imageVector = Icons.Filled.Circle,
contentDescription = stringResource(MR.strings.unread),
modifier = Modifier
.height(8.dp)
.padding(end = 4.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
if (bookmark) {
Icon(
imageVector = Icons.Filled.Bookmark,
contentDescription = stringResource(MR.strings.action_filter_bookmarked),
modifier = Modifier
.sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
tint = MaterialTheme.colorScheme.primary,
)
}
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current.copy(alpha = textAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = { textHeight = it.size.height },
)
}
Row {
ProvideTextStyle(
value = MaterialTheme.typography.bodyMedium.copy(
fontSize = 12.sp,
color = LocalContentColor.current.copy(alpha = textSubtitleAlpha),
),
) {
if (date != null) {
Text(
text = date,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (
readProgress != null ||
scanlator != null/* SY --> */ ||
sourceName != null/* SY <-- */
) {
DotSeparatorText()
}
}
if (readProgress != null) {
Text(
text = readProgress,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = LocalContentColor.current.copy(alpha = ReadItemAlpha),
)
if (scanlator != null/* SY --> */ || sourceName != null/* SY <-- */) DotSeparatorText()
}
// SY -->
if (sourceName != null) {
Text(
text = sourceName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (scanlator != null) DotSeparatorText()
}
// SY <--
if (scanlator != null) {
Text(
text = scanlator,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(modifier = Modifier.alpha(textSubtitleAlpha)) {
ProvideTextStyle(value = MaterialTheme.typography.bodySmall) {
if (date != null) {
Text(
text = date,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (readProgress != null || scanlator != null/* SY --> */ || sourceName != null/* SY <-- */) DotSeparatorText()
}
if (readProgress != null) {
Text(
text = readProgress,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = LocalContentColor.current.copy(alpha = ReadItemAlpha),
)
if (scanlator != null/* SY --> */ || sourceName != null/* SY <-- */) DotSeparatorText()
}
// SY -->
if (sourceName != null) {
Text(
text = sourceName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (scanlator != null) DotSeparatorText()
}
// SY <--
if (scanlator != null) {
Text(
text = scanlator,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
ChapterDownloadIndicator(
enabled = downloadIndicatorEnabled,
modifier = Modifier.padding(start = 4.dp),
downloadStateProvider = downloadStateProvider,
downloadProgressProvider = downloadProgressProvider,
onClick = { onDownloadClick?.invoke(it) },
)
}
ChapterDownloadIndicator(
enabled = downloadIndicatorEnabled,
modifier = Modifier.padding(start = 4.dp),
downloadStateProvider = downloadStateProvider,
downloadProgressProvider = downloadProgressProvider,
onClick = { onDownloadClick?.invoke(it) },
)
}
}
}
@@ -11,7 +11,7 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.Role
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import eu.kanade.presentation.util.rememberResourceBitmapPainter
import eu.kanade.tachiyomi.R
@@ -38,10 +38,10 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.view.updatePadding
import coil.imageLoader
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.size.Size
import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.size.Size
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DropdownMenu
@@ -169,7 +169,9 @@ fun MangaCoverDialog(
.data(coverDataProvider())
.size(Size.ORIGINAL)
.memoryCachePolicy(CachePolicy.DISABLED)
.target { drawable ->
.target { image ->
val drawable = image.asDrawable(view.context.resources)
// Copy bitmap in case it came from memory cache
// Because SSIV needs to thoroughly read the image
val copy = (drawable as? BitmapDrawable)?.let {
@@ -73,7 +73,7 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.SManga
@@ -25,14 +25,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import eu.kanade.domain.manga.model.PagePreview
import eu.kanade.presentation.manga.MangaScreenItem
import eu.kanade.tachiyomi.ui.manga.PagePreviewState
@@ -102,6 +101,7 @@ fun PagePreviews(
pagePreviewState: PagePreviewState,
onOpenPage: (Int) -> Unit,
onMorePreviewsClicked: () -> Unit,
rowCount: Int,
) {
Column(Modifier.fillMaxWidth()) {
var maxWidth by remember {
@@ -113,7 +113,7 @@ fun PagePreviews(
}
pagePreviewState is PagePreviewState.Success -> {
val itemPerRowCount = (maxWidth / 120.dp).floor()
pagePreviewState.pagePreviews.take(4 * itemPerRowCount).chunked(itemPerRowCount).forEach {
pagePreviewState.pagePreviews.take(rowCount * itemPerRowCount).chunked(itemPerRowCount).forEach {
PagePreviewRow(
onOpenPage = onOpenPage,
items = remember(it) { it.toImmutableList() }
@@ -132,7 +132,8 @@ fun LazyListScope.PagePreviewItems(
onOpenPage: (Int) -> Unit,
onMorePreviewsClicked: () -> Unit,
maxWidth: Dp,
setMaxWidth: (Dp) -> Unit
setMaxWidth: (Dp) -> Unit,
rowCount: Int,
) {
when {
pagePreviewState is PagePreviewState.Loading || maxWidth == Dp.Hairline -> {
@@ -148,7 +149,7 @@ fun LazyListScope.PagePreviewItems(
items(
key = { "${MangaScreenItem.CHAPTER_PREVIEW_ROW}-$it" },
contentType = { MangaScreenItem.CHAPTER_PREVIEW_ROW },
items = pagePreviewState.pagePreviews.take(4 * itemPerRowCount).chunked(itemPerRowCount),
items = pagePreviewState.pagePreviews.take(rowCount * itemPerRowCount).chunked(itemPerRowCount),
) {
PagePreviewRow(
onOpenPage = onOpenPage,
@@ -60,7 +60,6 @@ import eu.kanade.tachiyomi.util.CrashLogUtil
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.isDevFlavor
import eu.kanade.tachiyomi.util.system.isPreviewBuildType
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import eu.kanade.tachiyomi.util.system.isShizukuInstalled
import eu.kanade.tachiyomi.util.system.powerManager
import eu.kanade.tachiyomi.util.system.setDefaultSettings
@@ -23,11 +23,12 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableMap
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.LocalDate
object SettingsAppearanceScreen : SearchableSettings {
@@ -106,7 +107,7 @@ object SettingsAppearanceScreen : SearchableSettings {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val now = remember { Instant.now().toEpochMilli() }
val now = remember { LocalDate.now() }
val dateFormat by uiPreferences.dateFormat().collectAsState()
val formattedNow = remember(dateFormat) {
@@ -157,6 +158,8 @@ object SettingsAppearanceScreen : SearchableSettings {
// SY -->
@Composable
fun getForkGroup(uiPreferences: UiPreferences): Preference.PreferenceGroup {
val previewsRowCount by uiPreferences.previewsRowCount().collectAsState()
return Preference.PreferenceGroup(
stringResource(SYMR.strings.pref_category_fork),
preferenceItems = persistentListOf(
@@ -174,6 +177,21 @@ object SettingsAppearanceScreen : SearchableSettings {
title = stringResource(SYMR.strings.put_merge_in_overflow),
subtitle = stringResource(SYMR.strings.put_merge_in_overflow_summary),
),
Preference.PreferenceItem.SliderPreference(
value = previewsRowCount,
title = stringResource(SYMR.strings.pref_previews_row_count),
subtitle = if (previewsRowCount > 0) pluralStringResource(
SYMR.plurals.row_count,
previewsRowCount,
previewsRowCount,
) else stringResource(MR.strings.disabled),
min = 0,
max = 10,
onValueChanged = {
uiPreferences.previewsRowCount().set(it)
true
},
),
),
)
}
@@ -15,16 +15,19 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MultiChoiceSegmentedButtonRow
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -35,10 +38,13 @@ import androidx.core.net.toUri
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.hippo.unifile.UniFile
import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
import eu.kanade.presentation.more.settings.screen.data.StorageInfo
import eu.kanade.presentation.more.settings.screen.data.SyncSettingsSelector
import eu.kanade.presentation.more.settings.screen.data.SyncTriggerOptionsScreen
import eu.kanade.presentation.more.settings.widget.BasePreferenceWidget
import eu.kanade.presentation.more.settings.widget.PrefsHorizontalPadding
import eu.kanade.presentation.util.relativeTimeSpanString
@@ -46,10 +52,15 @@ import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.cache.PagePreviewCache
import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.data.sync.SyncManager
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveSyncService
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.displayablePath
@@ -91,13 +102,16 @@ object SettingsDataScreen : SearchableSettings {
val backupPreferences = Injekt.get<BackupPreferences>()
val storagePreferences = Injekt.get<StoragePreferences>()
val syncPreferences = remember { Injekt.get<SyncPreferences>() }
val syncService by syncPreferences.syncService().collectAsState()
return persistentListOf(
getStorageLocationPref(storagePreferences = storagePreferences),
Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.pref_storage_location_info)),
getBackupAndRestoreGroup(backupPreferences = backupPreferences),
getDataGroup(),
)
) + getSyncPreferences(syncPreferences = syncPreferences, syncService = syncService)
}
@Composable
@@ -330,4 +344,225 @@ object SettingsDataScreen : SearchableSettings {
),
)
}
@Composable
private fun getSyncPreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> {
return listOf(
Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_service_category),
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = syncPreferences.syncService(),
title = stringResource(MR.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),
),
onValueChanged = { true },
),
),
),
) + getSyncServicePreferences(syncPreferences, syncService)
}
@Composable
private fun getSyncServicePreferences(syncPreferences: SyncPreferences, syncService: Int): List<Preference> {
val syncServiceType = SyncManager.SyncService.fromInt(syncService)
val basePreferences = getBasePreferences(syncServiceType, syncPreferences)
return if (syncServiceType != SyncManager.SyncService.NONE) {
basePreferences + getAdditionalPreferences(syncPreferences)
} else {
basePreferences
}
}
@Composable
private fun getBasePreferences(
syncServiceType: SyncManager.SyncService,
syncPreferences: SyncPreferences,
): List<Preference> {
return when (syncServiceType) {
SyncManager.SyncService.NONE -> emptyList()
SyncManager.SyncService.SYNCYOMI -> getSelfHostPreferences(syncPreferences)
SyncManager.SyncService.GOOGLE_DRIVE -> getGoogleDrivePreferences()
}
}
@Composable
private fun getAdditionalPreferences(syncPreferences: SyncPreferences): List<Preference> {
return listOf(getSyncNowPref(), getAutomaticSyncGroup(syncPreferences))
}
@Composable
private fun getGoogleDrivePreferences(): List<Preference> {
val context = LocalContext.current
val googleDriveSync = Injekt.get<GoogleDriveService>()
return listOf(
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_google_drive_sign_in),
onClick = {
val intent = googleDriveSync.getSignInIntent()
context.startActivity(intent)
},
),
getGoogleDrivePurge(),
)
}
@Composable
private fun getGoogleDrivePurge(): Preference.PreferenceItem.TextPreference {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val googleDriveSync = remember { GoogleDriveSyncService(context) }
var showPurgeDialog by remember { mutableStateOf(false) }
if (showPurgeDialog) {
PurgeConfirmationDialog(
onConfirm = {
showPurgeDialog = false
scope.launch {
val result = googleDriveSync.deleteSyncDataFromGoogleDrive()
when (result) {
GoogleDriveSyncService.DeleteSyncDataStatus.NOT_INITIALIZED -> context.toast(
MR.strings.google_drive_not_signed_in,
duration = 5000,
)
GoogleDriveSyncService.DeleteSyncDataStatus.NO_FILES -> context.toast(
MR.strings.google_drive_sync_data_not_found,
duration = 5000,
)
GoogleDriveSyncService.DeleteSyncDataStatus.SUCCESS -> context.toast(
MR.strings.google_drive_sync_data_purged,
duration = 5000,
)
GoogleDriveSyncService.DeleteSyncDataStatus.ERROR -> context.toast(
MR.strings.google_drive_sync_data_purge_error,
duration = 10000,
)
}
}
},
onDismissRequest = { showPurgeDialog = false },
)
}
return Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_google_drive_purge_sync_data),
onClick = { showPurgeDialog = true },
)
}
@Composable
private fun PurgeConfirmationDialog(
onConfirm: () -> Unit,
onDismissRequest: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(MR.strings.pref_purge_confirmation_title)) },
text = { Text(text = stringResource(MR.strings.pref_purge_confirmation_message)) },
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = stringResource(MR.strings.action_ok))
}
},
)
}
@Composable
private fun getSelfHostPreferences(syncPreferences: SyncPreferences): List<Preference> {
val scope = rememberCoroutineScope()
return listOf(
Preference.PreferenceItem.EditTextPreference(
title = stringResource(MR.strings.pref_sync_host),
subtitle = stringResource(MR.strings.pref_sync_host_summ),
pref = syncPreferences.clientHost(),
onValueChanged = { newValue ->
scope.launch {
// Trim spaces at the beginning and end, then remove trailing slash if present
val trimmedValue = newValue.trim()
val modifiedValue = trimmedValue.trimEnd { it == '/' }
syncPreferences.clientHost().set(modifiedValue)
}
true
},
),
Preference.PreferenceItem.EditTextPreference(
title = stringResource(MR.strings.pref_sync_api_key),
subtitle = stringResource(MR.strings.pref_sync_api_key_summ),
pref = syncPreferences.clientAPIKey(),
),
)
}
@Composable
private fun getSyncNowPref(): Preference.PreferenceGroup {
val navigator = LocalNavigator.currentOrThrow
return Preference.PreferenceGroup(
title = stringResource(MR.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),
onClick = {
navigator.push(SyncSettingsSelector())
},
),
),
)
}
@Composable
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),
onClick = { navigator.push(SyncTriggerOptionsScreen()) },
)
}
@Composable
private fun getAutomaticSyncGroup(syncPreferences: SyncPreferences): Preference.PreferenceGroup {
val context = LocalContext.current
val syncIntervalPref = syncPreferences.syncInterval()
val lastSync by syncPreferences.lastSyncTimestamp().collectAsState()
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_sync_automatic_category),
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
pref = syncIntervalPref,
title = stringResource(MR.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),
360 to stringResource(MR.strings.update_6hour),
720 to stringResource(MR.strings.update_12hour),
1440 to stringResource(MR.strings.update_24hour),
2880 to stringResource(MR.strings.update_48hour),
10080 to stringResource(MR.strings.update_weekly),
),
onValueChanged = {
SyncDataJob.setupTask(context, it)
true
},
),
Preference.PreferenceItem.InfoPreference(
stringResource(MR.strings.last_synchronization, relativeTimeSpanString(lastSync)),
),
),
)
}
}
@@ -235,11 +235,13 @@ object SettingsLibraryScreen : SearchableSettings {
pref = libraryPreferences.newShowUpdatesCount(),
title = stringResource(MR.strings.pref_library_update_show_tab_badge),
),
// SY -->
Preference.PreferenceItem.SwitchPreference(
pref = libraryPreferences.libraryReadDuplicateChapters(),
title = stringResource(MR.strings.pref_library_mark_duplicate_chapters),
subtitle = stringResource(MR.strings.pref_library_mark_duplicate_chapters_summary),
title = stringResource(SYMR.strings.pref_library_mark_duplicate_chapters),
subtitle = stringResource(SYMR.strings.pref_library_mark_duplicate_chapters_summary),
),
// SY <--
),
)
}
@@ -178,6 +178,13 @@ object SettingsReaderScreen : SearchableSettings {
pref = readerPreferences.skipDupe(),
title = stringResource(MR.strings.pref_skip_dupe_chapters),
),
// SY -->
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.markReadDupe(),
title = stringResource(SYMR.strings.pref_mark_read_dupe_chapters),
subtitle = stringResource(SYMR.strings.pref_mark_read_dupe_chapters_summary),
),
// SY <--
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.alwaysShowChapterTransition(),
title = stringResource(MR.strings.pref_always_show_chapter_transition),
@@ -382,17 +389,16 @@ object SettingsReaderScreen : SearchableSettings {
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.webtoonDoubleTapZoomEnabled(),
title = stringResource(MR.strings.pref_double_tap_zoom),
enabled = true,
),
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.webtoonDisableZoomOut(),
title = stringResource(MR.strings.pref_webtoon_disable_zoom_out),
),
// SY -->
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.pageTransitionsWebtoon(),
title = stringResource(MR.strings.pref_page_transitions),
),
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.webtoonEnableZoomOut(),
title = stringResource(SYMR.strings.enable_zoom_out),
),
// SY <--
),
)
@@ -566,10 +572,14 @@ object SettingsReaderScreen : SearchableSettings {
.toMap()
.toImmutableMap(),
),
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.cacheArchiveMangaOnDisk(),
title = stringResource(SYMR.strings.cache_archived_manga_to_disk),
subtitle = stringResource(SYMR.strings.cache_archived_manga_to_disk_subtitle),
Preference.PreferenceItem.ListPreference(
pref = readerPreferences.archiveReaderMode(),
title = stringResource(SYMR.strings.pref_archive_reader_mode),
subtitle = stringResource(SYMR.strings.pref_archive_reader_mode_summary),
entries = ReaderPreferences.archiveModeTypes
.mapIndexed { index, it -> index to stringResource(it) }
.toMap()
.toImmutableMap(),
),
),
)
@@ -56,10 +56,10 @@ 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.text.DateFormat
import java.text.SimpleDateFormat
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.TimeZone
object AboutScreen : Screen() {
@@ -293,16 +293,9 @@ object AboutScreen : Screen() {
internal fun getFormattedBuildTime(): String {
return try {
val inputDf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'", Locale.US)
inputDf.timeZone = TimeZone.getTimeZone("UTC")
val buildTime = inputDf.parse(BuildConfig.BUILD_TIME)
val outputDf = DateFormat.getDateTimeInstance(
DateFormat.MEDIUM,
DateFormat.SHORT,
Locale.getDefault(),
)
outputDf.timeZone = TimeZone.getDefault()
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()))
} catch (e: Exception) {
@@ -0,0 +1,142 @@
package eu.kanade.presentation.more.settings.screen.data
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.domain.sync.models.SyncSettings
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.data.backup.create.BackupOptions
import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.update
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction
import tachiyomi.presentation.core.components.SectionCard
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SyncSettingsSelector : Screen() {
@Composable
override fun Content() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val model = rememberScreenModel { SyncSettingsSelectorModel() }
val state by model.state.collectAsState()
Scaffold(
topBar = {
AppBar(
title = stringResource(MR.strings.pref_choose_what_to_sync),
navigateUp = navigator::pop,
scrollBehavior = it,
)
},
) { contentPadding ->
LazyColumnWithAction(
contentPadding = contentPadding,
actionLabel = stringResource(MR.strings.label_sync),
actionEnabled = state.options.anyEnabled(),
onClickAction = {
if (!SyncDataJob.isAnyJobRunning(context)) {
model.syncNow(context)
navigator.pop()
} else {
context.toast(MR.strings.sync_in_progress)
}
},
) {
item {
SectionCard(MR.strings.label_library) {
Options(BackupOptions.libraryOptions, state, model)
}
}
item {
SectionCard(MR.strings.label_settings) {
Options(BackupOptions.settingsOptions, state, model)
}
}
}
}
}
@Composable
private fun Options(
options: ImmutableList<BackupOptions.Entry>,
state: SyncSettingsSelectorModel.State,
model: SyncSettingsSelectorModel,
) {
options.forEach { option ->
LabeledCheckbox(
label = stringResource(option.label),
checked = option.getter(state.options),
onCheckedChange = {
model.toggle(option.setter, it)
},
enabled = option.enabled(state.options),
)
}
}
}
private class SyncSettingsSelectorModel(
val syncPreferences: SyncPreferences = Injekt.get(),
) : StateScreenModel<SyncSettingsSelectorModel.State>(
State(syncOptionsToBackupOptions(syncPreferences.getSyncSettings())),
) {
fun toggle(setter: (BackupOptions, Boolean) -> BackupOptions, enabled: Boolean) {
mutableState.update {
val updatedOptions = setter(it.options, enabled)
syncPreferences.setSyncSettings(backupOptionsToSyncOptions(updatedOptions))
it.copy(options = updatedOptions)
}
}
fun syncNow(context: Context) {
SyncDataJob.startNow(context)
}
@Immutable
data class State(
val options: BackupOptions = BackupOptions(),
) companion object {
private fun syncOptionsToBackupOptions(syncSettings: SyncSettings): BackupOptions {
return BackupOptions(
libraryEntries = syncSettings.libraryEntries,
categories = syncSettings.categories,
chapters = syncSettings.chapters,
tracking = syncSettings.tracking,
history = syncSettings.history,
appSettings = syncSettings.appSettings,
sourceSettings = syncSettings.sourceSettings,
privateSettings = syncSettings.privateSettings,
)
}
private fun backupOptionsToSyncOptions(backupOptions: BackupOptions): SyncSettings {
return SyncSettings(
libraryEntries = backupOptions.libraryEntries,
categories = backupOptions.categories,
chapters = backupOptions.chapters,
tracking = backupOptions.tracking,
history = backupOptions.history,
appSettings = backupOptions.appSettings,
sourceSettings = backupOptions.sourceSettings,
privateSettings = backupOptions.privateSettings,
)
}
}
}
@@ -0,0 +1,101 @@
package eu.kanade.presentation.more.settings.screen.data
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.data.sync.models.SyncTriggerOptions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.update
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction
import tachiyomi.presentation.core.components.SectionCard
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SyncTriggerOptionsScreen : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val model = rememberScreenModel { SyncOptionsScreenModel() }
val state by model.state.collectAsState()
Scaffold(
topBar = {
AppBar(
title = stringResource(MR.strings.pref_sync_options),
navigateUp = navigator::pop,
scrollBehavior = it,
)
},
) { contentPadding ->
LazyColumnWithAction(
contentPadding = contentPadding,
actionLabel = stringResource(MR.strings.action_save),
actionEnabled = state.options.anyEnabled(),
onClickAction = {
navigator.pop()
},
) {
item {
SectionCard(MR.strings.label_triggers) {
Options(SyncTriggerOptions.mainOptions, state, model)
}
}
}
}
}
@Composable
private fun Options(
options: ImmutableList<SyncTriggerOptions.Entry>,
state: SyncOptionsScreenModel.State,
model: SyncOptionsScreenModel,
) {
options.forEach { option ->
LabeledCheckbox(
label = stringResource(option.label),
checked = option.getter(state.options),
onCheckedChange = {
model.toggle(option.setter, it)
},
enabled = option.enabled(state.options),
)
}
}
}
private class SyncOptionsScreenModel(
val syncPreferences: SyncPreferences = Injekt.get(),
) : StateScreenModel<SyncOptionsScreenModel.State>(
State(
syncPreferences.getSyncTriggerOptions(),
),
) {
fun toggle(setter: (SyncTriggerOptions, Boolean) -> SyncTriggerOptions, enabled: Boolean) {
mutableState.update {
val updatedTriggerOptions = setter(it.options, enabled)
syncPreferences.setSyncTriggerOptions(updatedTriggerOptions)
it.copy(
options = updatedTriggerOptions,
)
}
}
@Immutable
data class State(
val options: SyncTriggerOptions = SyncTriggerOptions(),
)
}
@@ -42,7 +42,9 @@ import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.plus
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
class WorkerInfoScreen : Screen() {
@@ -148,13 +150,16 @@ class WorkerInfoScreen : Screen() {
}
appendLine("State: ${workInfo.state}")
if (workInfo.state == WorkInfo.State.ENQUEUED) {
appendLine(
"Next scheduled run: ${Date(workInfo.nextScheduleTimeMillis).toDateTimestampString(
val timestamp = LocalDateTime.ofInstant(
Instant.ofEpochMilli(workInfo.nextScheduleTimeMillis),
ZoneId.systemDefault(),
)
.toDateTimestampString(
UiPreferences.dateFormat(
Injekt.get<UiPreferences>().dateFormat().get(),
),
)}",
)
)
appendLine("Next scheduled run: $timestamp",)
appendLine("Attempt #${workInfo.runAttemptCount + 1}")
}
appendLine()
@@ -22,7 +22,10 @@ import exh.source.isEhBasedManga
import kotlinx.collections.immutable.ImmutableList
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.library.service.LibraryPreferences
import java.util.Date
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
@Composable
fun ChapterListDialog(
@@ -56,9 +59,13 @@ fun ChapterListDialog(
?.let {
// SY -->
if (manga?.isEhBasedManga() == true) {
MetadataUtil.EX_DATE_FORMAT.format(Date(it))
MetadataUtil.EX_DATE_FORMAT
.format(ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()))
} else {
Date(it).toRelativeString(context, dateRelativeTime, chapterItem.dateFormat)
LocalDate.ofInstant(
Instant.ofEpochMilli(it),
ZoneId.systemDefault(),
).toRelativeString(context, dateRelativeTime, chapterItem.dateFormat)
}
// SY <--
},
@@ -217,11 +217,6 @@ private fun ColumnScope.WebtoonViewerSettings(screenModel: ReaderSettingsScreenM
label = stringResource(MR.strings.pref_page_transitions),
pref = screenModel.preferences.pageTransitionsWebtoon(),
)
CheckboxItem(
label = stringResource(SYMR.strings.enable_zoom_out),
pref = screenModel.preferences.webtoonEnableZoomOut(),
)
// SY <--
val dualPageSplitWebtoon by screenModel.preferences.dualPageSplitWebtoon().collectAsState()
@@ -254,6 +249,10 @@ private fun ColumnScope.WebtoonViewerSettings(screenModel: ReaderSettingsScreenM
label = stringResource(MR.strings.pref_double_tap_zoom),
pref = screenModel.preferences.webtoonDoubleTapZoomEnabled(),
)
CheckboxItem(
label = stringResource(MR.strings.pref_webtoon_disable_zoom_out),
pref = screenModel.preferences.webtoonDisableZoomOut(),
)
}
// SY -->
@@ -8,6 +8,7 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalContext
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.domain.ui.model.AppTheme
import eu.kanade.presentation.theme.colorscheme.BaseColorScheme
import eu.kanade.presentation.theme.colorscheme.GreenAppleColorScheme
import eu.kanade.presentation.theme.colorscheme.LavenderColorScheme
import eu.kanade.presentation.theme.colorscheme.MidnightDuskColorScheme
@@ -62,23 +63,27 @@ private fun getThemeColorScheme(
appTheme: AppTheme,
isAmoled: Boolean,
): ColorScheme {
val colorScheme = when (appTheme) {
AppTheme.DEFAULT -> TachiyomiColorScheme
AppTheme.MONET -> MonetColorScheme(LocalContext.current)
AppTheme.GREEN_APPLE -> GreenAppleColorScheme
AppTheme.LAVENDER -> LavenderColorScheme
AppTheme.MIDNIGHT_DUSK -> MidnightDuskColorScheme
AppTheme.NORD -> NordColorScheme
AppTheme.STRAWBERRY_DAIQUIRI -> StrawberryColorScheme
AppTheme.TAKO -> TakoColorScheme
AppTheme.TEALTURQUOISE -> TealTurqoiseColorScheme
AppTheme.TIDAL_WAVE -> TidalWaveColorScheme
AppTheme.YINYANG -> YinYangColorScheme
AppTheme.YOTSUBA -> YotsubaColorScheme
else -> TachiyomiColorScheme
val colorScheme = if (appTheme == AppTheme.MONET) {
MonetColorScheme(LocalContext.current)
} else {
colorSchemes.getOrDefault(appTheme, TachiyomiColorScheme)
}
return colorScheme.getColorScheme(
isSystemInDarkTheme(),
isAmoled,
)
}
private val colorSchemes: Map<AppTheme, BaseColorScheme> = mapOf(
AppTheme.DEFAULT to TachiyomiColorScheme,
AppTheme.GREEN_APPLE to GreenAppleColorScheme,
AppTheme.LAVENDER to LavenderColorScheme,
AppTheme.MIDNIGHT_DUSK to MidnightDuskColorScheme,
AppTheme.NORD to NordColorScheme,
AppTheme.STRAWBERRY_DAIQUIRI to StrawberryColorScheme,
AppTheme.TAKO to TakoColorScheme,
AppTheme.TEALTURQUOISE to TealTurqoiseColorScheme,
AppTheme.TIDAL_WAVE to TidalWaveColorScheme,
AppTheme.YINYANG to YinYangColorScheme,
AppTheme.YOTSUBA to YotsubaColorScheme,
)
@@ -9,18 +9,15 @@ internal abstract class BaseColorScheme {
abstract val lightScheme: ColorScheme
fun getColorScheme(isDark: Boolean, isAmoled: Boolean): ColorScheme {
return (if (isDark) darkScheme else lightScheme)
.let {
if (isDark && isAmoled) {
it.copy(
background = Color.Black,
onBackground = Color.White,
surface = Color.Black,
onSurface = Color.White,
)
} else {
it
}
}
if (!isDark) return lightScheme
if (!isAmoled) return darkScheme
return darkScheme.copy(
background = Color.Black,
onBackground = Color.White,
surface = Color.Black,
onSurface = Color.White,
)
}
}
@@ -52,17 +52,18 @@ import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.presentation.track.components.TrackLogoIcon
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.ui.manga.track.TrackItem
import eu.kanade.tachiyomi.util.lang.toLocalDate
import eu.kanade.tachiyomi.util.system.copyToClipboard
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import java.text.DateFormat
import java.time.format.DateTimeFormatter
private const val UnsetStatusTextAlpha = 0.5F
@Composable
fun TrackInfoDialogHome(
trackItems: List<TrackItem>,
dateFormat: DateFormat,
dateFormat: DateTimeFormatter,
onStatusClick: (TrackItem) -> Unit,
onChapterClick: (TrackItem) -> Unit,
onScoreClick: (TrackItem) -> Unit,
@@ -104,11 +105,11 @@ fun TrackInfoDialogHome(
.takeIf { supportsScoring && item.track.score != 0.0 },
onScoreClick = { onScoreClick(item) }
.takeIf { supportsScoring },
startDate = remember(item.track.startDate) { dateFormat.format(item.track.startDate) }
startDate = remember(item.track.startDate) { dateFormat.format(item.track.startDate.toLocalDate()) }
.takeIf { supportsReadingDates && item.track.startDate != 0L },
onStartDateClick = { onStartDateEdit(item) } // TODO
.takeIf { supportsReadingDates },
endDate = dateFormat.format(item.track.finishDate)
endDate = dateFormat.format(item.track.finishDate.toLocalDate())
.takeIf { supportsReadingDates && item.track.finishDate != 0L },
onEndDateClick = { onEndDateEdit(item) }
.takeIf { supportsReadingDates },
@@ -5,7 +5,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import eu.kanade.tachiyomi.ui.manga.track.TrackItem
import eu.kanade.test.DummyTracker
import tachiyomi.domain.track.model.Track
import java.text.DateFormat
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
internal class TrackInfoDialogHomePreviewProvider :
PreviewParameterProvider<@Composable () -> Unit> {
@@ -46,7 +47,7 @@ internal class TrackInfoDialogHomePreviewProvider :
trackItemWithoutTrack,
trackItemWithTrack,
),
dateFormat = DateFormat.getDateInstance(),
dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM),
onStatusClick = {},
onChapterClick = {},
onScoreClick = {},
@@ -61,7 +62,7 @@ internal class TrackInfoDialogHomePreviewProvider :
private val noTrackers = @Composable {
TrackInfoDialogHome(
trackItems = listOf(),
dateFormat = DateFormat.getDateInstance(),
dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM),
onStatusClick = {},
onChapterClick = {},
onScoreClick = {},
@@ -36,7 +36,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen
import java.util.Date
import java.time.LocalDate
import kotlin.time.Duration.Companion.seconds
@Composable
@@ -212,6 +212,6 @@ private fun UpdatesBottomBar(
}
sealed interface UpdatesUiModel {
data class Header(val date: Date) : UpdatesUiModel
data class Header(val date: LocalDate) : UpdatesUiModel
data class Item(val item: UpdatesItem) : UpdatesUiModel
}
+42 -17
View File
@@ -15,12 +15,16 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.disk.DiskCache
import coil.util.DebugLogger
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.util.DebugLogger
import com.elvishew.xlog.LogConfiguration
import com.elvishew.xlog.LogLevel
import com.elvishew.xlog.XLog
@@ -33,6 +37,7 @@ import com.google.firebase.ktx.Firebase
import eu.kanade.domain.DomainModule
import eu.kanade.domain.SYDomainModule
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode
import eu.kanade.tachiyomi.crash.CrashActivity
@@ -44,6 +49,7 @@ import eu.kanade.tachiyomi.data.coil.PagePreviewFetcher
import eu.kanade.tachiyomi.data.coil.PagePreviewKeyer
import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.di.AppModule
import eu.kanade.tachiyomi.di.PreferenceModule
import eu.kanade.tachiyomi.di.SYPreferenceModule
@@ -81,7 +87,7 @@ import java.security.Security
import java.text.SimpleDateFormat
import java.util.Locale
class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factory {
private val basePreferences: BasePreferences by injectLazy()
private val networkPreferences: NetworkPreferences by injectLazy()
@@ -164,30 +170,37 @@ class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
/*if (!LogcatLogger.isInstalled && networkPreferences.verboseLogging().get()) {
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
}*/
val syncPreferences: SyncPreferences = Injekt.get()
val syncTriggerOpt = syncPreferences.getSyncTriggerOptions()
if (syncPreferences.isSyncEnabled() && syncTriggerOpt.syncOnAppStart
) {
SyncDataJob.startNow(this@App)
}
}
override fun newImageLoader(): ImageLoader {
override fun newImageLoader(context: Context): ImageLoader {
return ImageLoader.Builder(this).apply {
val callFactoryInit = { Injekt.get<NetworkHelper>().client }
val diskCacheInit = { CoilDiskCache.get(this@App) }
val callFactoryLazy = lazy { Injekt.get<NetworkHelper>().client }
val diskCacheLazy = lazy { CoilDiskCache.get(this@App) }
components {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(ImageDecoderDecoder.Factory())
add(AnimatedImageDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(OkHttpNetworkFetcherFactory(callFactoryLazy::value))
add(TachiyomiImageDecoder.Factory())
add(MangaCoverFetcher.MangaFactory(lazy(callFactoryInit), lazy(diskCacheInit)))
add(MangaCoverFetcher.MangaCoverFactory(lazy(callFactoryInit), lazy(diskCacheInit)))
add(MangaCoverFetcher.MangaFactory(callFactoryLazy, diskCacheLazy))
add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy, diskCacheLazy))
add(MangaKeyer())
add(MangaCoverKeyer())
// SY -->
add(PagePreviewKeyer())
add(PagePreviewFetcher.Factory(lazy(callFactoryInit), lazy(diskCacheInit)))
add(PagePreviewFetcher.Factory(callFactoryLazy, diskCacheLazy))
// SY <--
}
callFactory(callFactoryInit)
diskCache(diskCacheInit)
diskCache(diskCacheLazy::value)
crossfade((300 * this@App.animatorDurationScale).toInt())
allowRgb565(DeviceUtil.isLowRamDevice(this@App))
if (networkPreferences.verboseLogging().get()) logger(DebugLogger())
@@ -195,12 +208,18 @@ class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
// Coil spawns a new thread for every image load by default
fetcherDispatcher(Dispatchers.IO.limitedParallelism(8))
decoderDispatcher(Dispatchers.IO.limitedParallelism(2))
transformationDispatcher(Dispatchers.IO.limitedParallelism(2))
}.build()
}
override fun onStart(owner: LifecycleOwner) {
SecureActivityDelegate.onApplicationStart()
val syncPreferences: SyncPreferences = Injekt.get()
val syncTriggerOpt = syncPreferences.getSyncTriggerOptions()
if (syncPreferences.isSyncEnabled() && syncTriggerOpt.syncOnAppResume
) {
SyncDataJob.startNow(this@App)
}
}
override fun onStop(owner: LifecycleOwner) {
@@ -234,6 +253,12 @@ class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to modify notification channels" }
}
val syncPreferences: SyncPreferences = Injekt.get()
val syncTriggerOpt = syncPreferences.getSyncTriggerOptions()
if (syncPreferences.isSyncEnabled() && syncTriggerOpt.syncOnAppStart) {
SyncDataJob.startNow(this@App)
}
}
// EXH
@@ -132,34 +132,34 @@ class BackupCreator(
}
}
private suspend fun backupCategories(options: BackupOptions): List<BackupCategory> {
suspend fun backupCategories(options: BackupOptions): List<BackupCategory> {
if (!options.categories) return emptyList()
return categoriesBackupCreator.backupCategories()
}
private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
return mangaBackupCreator.backupMangas(mangas, options)
}
private fun backupSources(mangas: List<Manga>): List<BackupSource> {
fun backupSources(mangas: List<Manga>): List<BackupSource> {
return sourcesBackupCreator.backupSources(mangas)
}
private fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
if (!options.appSettings) return emptyList()
return preferenceBackupCreator.backupAppPreferences(includePrivatePreferences = options.privateSettings)
}
private fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {
fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {
if (!options.sourceSettings) return emptyList()
return preferenceBackupCreator.backupSourcePreferences(includePrivatePreferences = options.privateSettings)
}
// SY -->
private suspend fun backupSavedSearches(): List<BackupSavedSearch> {
suspend fun backupSavedSearches(): List<BackupSavedSearch> {
return savedSearchBackupCreator.backupSavedSearches()
}
// SY <--
@@ -134,6 +134,7 @@ private fun Manga.toBackupManga(/* SY --> */customMangaInfo: CustomMangaInfo?/*
updateStrategy = this.updateStrategy,
lastModifiedAt = this.lastModifiedAt,
favoriteModifiedAt = this.favoriteModifiedAt,
version = this.version,
// SY -->
).also { backupManga ->
customMangaInfo?.let {
@@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import tachiyomi.domain.chapter.model.Chapter
@Suppress("MagicNumber")
@Serializable
data class BackupChapter(
// in 1.x some of these values have different names
@@ -21,6 +22,7 @@ data class BackupChapter(
@ProtoNumber(9) var chapterNumber: Float = 0F,
@ProtoNumber(10) var sourceOrder: Long = 0,
@ProtoNumber(11) var lastModifiedAt: Long = 0,
@ProtoNumber(12) var version: Long = 0,
) {
fun toChapterImpl(): Chapter {
return Chapter.create().copy(
@@ -35,36 +37,40 @@ data class BackupChapter(
dateUpload = this@BackupChapter.dateUpload,
sourceOrder = this@BackupChapter.sourceOrder,
lastModifiedAt = this@BackupChapter.lastModifiedAt,
version = this@BackupChapter.version,
)
}
}
val backupChapterMapper =
{ _: Long,
_: Long,
url: String,
name: String,
scanlator: String?,
read: Boolean,
bookmark: Boolean,
lastPageRead: Long,
chapterNumber: Double,
source_order: Long,
dateFetch: Long,
dateUpload: Long,
lastModifiedAt: Long,
->
BackupChapter(
url = url,
name = name,
chapterNumber = chapterNumber.toFloat(),
scanlator = scanlator,
read = read,
bookmark = bookmark,
lastPageRead = lastPageRead,
dateFetch = dateFetch,
dateUpload = dateUpload,
sourceOrder = source_order,
lastModifiedAt = lastModifiedAt,
)
}
val backupChapterMapper = {
_: Long,
_: Long,
url: String,
name: String,
scanlator: String?,
read: Boolean,
bookmark: Boolean,
lastPageRead: Long,
chapterNumber: Double,
sourceOrder: Long,
dateFetch: Long,
dateUpload: Long,
lastModifiedAt: Long,
version: Long,
_: Long,
->
BackupChapter(
url = url,
name = name,
chapterNumber = chapterNumber.toFloat(),
scanlator = scanlator,
read = read,
bookmark = bookmark,
lastPageRead = lastPageRead,
dateFetch = dateFetch,
dateUpload = dateUpload,
sourceOrder = sourceOrder,
lastModifiedAt = lastModifiedAt,
version = version,
)
}
@@ -6,7 +6,10 @@ import kotlinx.serialization.protobuf.ProtoNumber
import tachiyomi.domain.manga.model.CustomMangaInfo
import tachiyomi.domain.manga.model.Manga
@Suppress("DEPRECATION")
@Suppress(
"DEPRECATION",
"MagicNumber",
)
@Serializable
data class BackupManga(
// in 1.x some of these values have different names
@@ -40,6 +43,7 @@ data class BackupManga(
@ProtoNumber(106) var lastModifiedAt: Long = 0,
@ProtoNumber(107) var favoriteModifiedAt: Long? = null,
@ProtoNumber(108) var excludedScanlators: List<String> = emptyList(),
@ProtoNumber(109) var version: Long = 0,
// SY specific values
@ProtoNumber(600) var mergedMangaReferences: List<BackupMergedMangaReference> = emptyList(),
@@ -76,6 +80,7 @@ data class BackupManga(
updateStrategy = this@BackupManga.updateStrategy,
lastModifiedAt = this@BackupManga.lastModifiedAt,
favoriteModifiedAt = this@BackupManga.favoriteModifiedAt,
version = this@BackupManga.version,
)
}
}
@@ -34,7 +34,7 @@ class BackupRestorer(
private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(),
private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context),
private val mangaRestorer: MangaRestorer = MangaRestorer(),
private val mangaRestorer: MangaRestorer = MangaRestorer(isSync),
// SY -->
private val savedSearchRestorer: SavedSearchRestorer = SavedSearchRestorer(),
// SY <--
@@ -33,6 +33,8 @@ import java.util.Date
import kotlin.math.max
class MangaRestorer(
private var isSync: Boolean = false,
private val handler: DatabaseHandler = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(),
private val getMangaByUrlAndSourceId: GetMangaByUrlAndSourceId = Injekt.get(),
@@ -47,7 +49,6 @@ class MangaRestorer(
private val getFlatMetadataById: GetFlatMetadataById = Injekt.get(),
// SY <--
) {
private var now = ZonedDateTime.now()
private var currentFetchWindow = fetchInterval.getWindow(now)
@@ -97,6 +98,11 @@ class MangaRestorer(
customManga = backupManga.getCustomMangaInfo(),
// SY <--
)
if (isSync) {
mangasQueries.resetIsSyncing()
chaptersQueries.resetIsSyncing()
}
}
}
@@ -105,7 +111,7 @@ class MangaRestorer(
}
private suspend fun restoreExistingManga(manga: Manga, dbManga: Manga): Manga {
return if (manga.lastModifiedAt > dbManga.lastModifiedAt) {
return if (manga.version > dbManga.version) {
updateManga(dbManga.copyFrom(manga).copy(id = dbManga.id))
} else {
updateManga(manga.copyFrom(dbManga).copy(id = dbManga.id))
@@ -124,10 +130,11 @@ class MangaRestorer(
ogStatus = newer.status,
// SY <--
initialized = this.initialized || newer.initialized,
version = newer.version,
)
}
private suspend fun updateManga(manga: Manga): Manga {
suspend fun updateManga(manga: Manga): Manga {
handler.await(true) {
mangasQueries.update(
source = manga.source,
@@ -150,6 +157,8 @@ class MangaRestorer(
dateAdded = manga.dateAdded,
mangaId = manga.id,
updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode),
version = manga.version,
isSyncing = 1,
)
}
return manga
@@ -161,6 +170,7 @@ class MangaRestorer(
return manga.copy(
initialized = manga.description != null,
id = insertManga(manga),
version = manga.version,
)
}
@@ -169,36 +179,15 @@ class MangaRestorer(
.associateBy { it.url }
val (existingChapters, newChapters) = backupChapters
.mapNotNull {
val chapter = it.toChapterImpl().copy(mangaId = manga.id)
.mapNotNull { backupChapter ->
val chapter = backupChapter.toChapterImpl().copy(mangaId = manga.id)
val dbChapter = dbChaptersByUrl[chapter.url]
?: // New chapter
return@mapNotNull chapter
if (chapter.forComparison() == dbChapter.forComparison()) {
// Same state; skip
return@mapNotNull null
when {
dbChapter == null -> chapter // New chapter
chapter.forComparison() == dbChapter.forComparison() -> null // Same state; skip
else -> updateChapterBasedOnSyncState(chapter, dbChapter)
}
// Update to an existing chapter
var updatedChapter = chapter
.copyFrom(dbChapter)
.copy(
id = dbChapter.id,
bookmark = chapter.bookmark || dbChapter.bookmark,
)
if (dbChapter.read && !updatedChapter.read) {
updatedChapter = updatedChapter.copy(
read = true,
lastPageRead = dbChapter.lastPageRead,
)
} else if (updatedChapter.lastPageRead == 0L && dbChapter.lastPageRead != 0L) {
updatedChapter = updatedChapter.copy(
lastPageRead = dbChapter.lastPageRead,
)
}
updatedChapter
}
.partition { it.id > 0 }
@@ -206,8 +195,29 @@ class MangaRestorer(
updateExistingChapters(existingChapters)
}
private fun updateChapterBasedOnSyncState(chapter: Chapter, dbChapter: Chapter): Chapter {
return if (isSync) {
chapter.copy(
id = dbChapter.id,
bookmark = chapter.bookmark || dbChapter.bookmark,
read = chapter.read,
lastPageRead = chapter.lastPageRead,
)
} else {
chapter.copyFrom(dbChapter).let {
when {
dbChapter.read && !it.read -> it.copy(read = true, lastPageRead = dbChapter.lastPageRead)
it.lastPageRead == 0L && dbChapter.lastPageRead != 0L -> it.copy(
lastPageRead = dbChapter.lastPageRead,
)
else -> it
}
}
}
}
private fun Chapter.forComparison() =
this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L, lastModifiedAt = 0L)
this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L, lastModifiedAt = 0L, version = 0L)
private suspend fun insertNewChapters(chapters: List<Chapter>) {
handler.await(true) {
@@ -224,6 +234,7 @@ class MangaRestorer(
chapter.sourceOrder,
chapter.dateFetch,
chapter.dateUpload,
chapter.version,
)
}
}
@@ -245,6 +256,8 @@ class MangaRestorer(
dateFetch = null,
dateUpload = null,
chapterId = chapter.id,
version = chapter.version,
isSyncing = 1,
)
}
}
@@ -277,6 +290,7 @@ class MangaRestorer(
coverLastModified = manga.coverLastModified,
dateAdded = manga.dateAdded,
updateStrategy = manga.updateStrategy,
version = manga.version,
)
mangasQueries.selectLastInsertedRowId()
}
@@ -299,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 -->
@@ -345,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
@@ -1,19 +1,19 @@
package eu.kanade.tachiyomi.data.coil
import androidx.core.net.toUri
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.disk.DiskCache
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.network.HttpException
import coil.request.Options
import coil.request.Parameters
import coil3.Extras
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.disk.DiskCache
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.getOrDefault
import coil3.request.Options
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher.Companion.USE_CUSTOM_COVER
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher.Companion.USE_CUSTOM_COVER_KEY
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.online.HttpSource
import logcat.LogPriority
@@ -22,6 +22,7 @@ import okhttp3.Call
import okhttp3.Request
import okhttp3.Response
import okhttp3.internal.http.HTTP_NOT_MODIFIED
import okio.FileSystem
import okio.Path.Companion.toOkioPath
import okio.Source
import okio.buffer
@@ -33,6 +34,7 @@ import tachiyomi.domain.manga.model.MangaCover
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.IOException
/**
* A [Fetcher] that fetches cover image for [Manga] object.
@@ -42,7 +44,7 @@ import java.io.File
* handled by Coil's [DiskCache].
*
* Available request parameter:
* - [USE_CUSTOM_COVER]: Use custom cover if set by user, default is true
* - [USE_CUSTOM_COVER_KEY]: Use custom cover if set by user, default is true
*/
class MangaCoverFetcher(
private val url: String?,
@@ -61,7 +63,7 @@ class MangaCoverFetcher(
override suspend fun fetch(): FetchResult {
// Use custom cover if exists
val useCustomCover = options.parameters.value(USE_CUSTOM_COVER) ?: true
val useCustomCover = options.extras.getOrDefault(USE_CUSTOM_COVER_KEY)
if (useCustomCover) {
val customCoverFile = customCoverFileLazy.value
if (customCoverFile.exists()) {
@@ -80,8 +82,12 @@ class MangaCoverFetcher(
}
private fun fileLoader(file: File): FetchResult {
return SourceResult(
source = ImageSource(file = file.toOkioPath(), diskCacheKey = diskCacheKey),
return SourceFetchResult(
source = ImageSource(
file = file.toOkioPath(),
fileSystem = FileSystem.SYSTEM,
diskCacheKey = diskCacheKey
),
mimeType = "image/*",
dataSource = DataSource.DISK,
)
@@ -92,8 +98,8 @@ class MangaCoverFetcher(
.openInputStream()
.source()
.buffer()
return SourceResult(
source = ImageSource(source = source, context = options.context),
return SourceFetchResult(
source = ImageSource(source = source, fileSystem = FileSystem.SYSTEM),
mimeType = "image/*",
dataSource = DataSource.DISK,
)
@@ -121,7 +127,7 @@ class MangaCoverFetcher(
}
// Read from snapshot
return SourceResult(
return SourceFetchResult(
source = snapshot.toImageSource(),
mimeType = "image/*",
dataSource = DataSource.DISK,
@@ -141,7 +147,7 @@ class MangaCoverFetcher(
// Read from disk cache
snapshot = writeToDiskCache(response)
if (snapshot != null) {
return SourceResult(
return SourceFetchResult(
source = snapshot.toImageSource(),
mimeType = "image/*",
dataSource = DataSource.NETWORK,
@@ -149,8 +155,8 @@ class MangaCoverFetcher(
}
// Read from response if cache is unused or unusable
return SourceResult(
source = ImageSource(source = responseBody.source(), context = options.context),
return SourceFetchResult(
source = ImageSource(source = responseBody.source(), fileSystem = FileSystem.SYSTEM),
mimeType = "image/*",
dataSource = if (response.cacheResponse != null) DataSource.DISK else DataSource.NETWORK,
)
@@ -169,17 +175,20 @@ class MangaCoverFetcher(
val response = client.newCall(newRequest()).await()
if (!response.isSuccessful && response.code != HTTP_NOT_MODIFIED) {
response.close()
throw HttpException(response)
throw IOException(response.message)
}
return response
}
private fun newRequest(): Request {
val request = Request.Builder()
.url(url!!)
.headers(sourceLazy.value?.headers ?: options.headers)
// Support attaching custom data to the network request.
.tag(Parameters::class.java, options.parameters)
val request = Request.Builder().apply {
url(url!!)
val sourceHeaders = sourceLazy.value?.headers
if (sourceHeaders != null) {
headers(sourceHeaders)
}
}
when {
options.networkCachePolicy.readEnabled -> {
@@ -264,7 +273,12 @@ class MangaCoverFetcher(
}
private fun DiskCache.Snapshot.toImageSource(): ImageSource {
return ImageSource(file = data, diskCacheKey = diskCacheKey, closeable = this)
return ImageSource(
file = data,
fileSystem = FileSystem.SYSTEM,
diskCacheKey = diskCacheKey,
closeable = this,
)
}
private fun getResourceType(cover: String?): Type? {
@@ -330,7 +344,7 @@ class MangaCoverFetcher(
}
companion object {
const val USE_CUSTOM_COVER = "use_custom_cover"
val USE_CUSTOM_COVER_KEY = Extras.Key(true)
private val CACHE_CONTROL_NO_STORE = CacheControl.Builder().noStore().build()
private val CACHE_CONTROL_NO_NETWORK_NO_CACHE = CacheControl.Builder().noCache().onlyIfCached().build()
@@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.data.coil
import coil.key.Keyer
import coil.request.Options
import coil3.key.Keyer
import coil3.request.Options
import eu.kanade.domain.manga.model.hasCustomCover
import eu.kanade.tachiyomi.data.cache.CoverCache
import tachiyomi.domain.manga.model.MangaCover
@@ -1,15 +1,13 @@
package eu.kanade.tachiyomi.data.coil
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.disk.DiskCache
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.network.HttpException
import coil.request.Options
import coil.request.Parameters
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.disk.DiskCache
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import eu.kanade.domain.manga.model.PagePreview
import eu.kanade.tachiyomi.data.cache.PagePreviewCache
import eu.kanade.tachiyomi.network.await
@@ -21,12 +19,14 @@ import okhttp3.Call
import okhttp3.Request
import okhttp3.Response
import okhttp3.internal.http.HTTP_NOT_MODIFIED
import okio.FileSystem
import okio.Path.Companion.toOkioPath
import okio.Source
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.IOException
/**
* A [Fetcher] that fetches page preview image for [PagePreview] object.
@@ -54,8 +54,12 @@ class PagePreviewFetcher(
}
private fun fileLoader(file: File): FetchResult {
return SourceResult(
source = ImageSource(file = file.toOkioPath(), diskCacheKey = diskCacheKey),
return SourceFetchResult(
source = ImageSource(
file = file.toOkioPath(),
fileSystem = FileSystem.SYSTEM,
diskCacheKey = diskCacheKey
),
mimeType = "image/*",
dataSource = DataSource.DISK,
)
@@ -76,7 +80,7 @@ class PagePreviewFetcher(
}
// Read from snapshot
return SourceResult(
return SourceFetchResult(
source = snapshot.toImageSource(),
mimeType = "image/*",
dataSource = DataSource.DISK,
@@ -96,7 +100,7 @@ class PagePreviewFetcher(
// Read from disk cache
snapshot = writeToDiskCache(response)
if (snapshot != null) {
return SourceResult(
return SourceFetchResult(
source = snapshot.toImageSource(),
mimeType = "image/*",
dataSource = DataSource.NETWORK,
@@ -104,8 +108,8 @@ class PagePreviewFetcher(
}
// Read from response if cache is unused or unusable
return SourceResult(
source = ImageSource(source = responseBody.source(), context = options.context),
return SourceFetchResult(
source = ImageSource(source = responseBody.source(), fileSystem = FileSystem.SYSTEM),
mimeType = "image/*",
dataSource = if (response.cacheResponse != null) DataSource.DISK else DataSource.NETWORK,
)
@@ -125,7 +129,7 @@ class PagePreviewFetcher(
) ?: callFactoryLazy.value.newCall(newRequest()).await()
if (!response.isSuccessful && response.code != HTTP_NOT_MODIFIED) {
response.close()
throw HttpException(response)
throw IOException(response.message)
}
return response
}
@@ -144,11 +148,14 @@ class PagePreviewFetcher(
}
private fun newRequest(): Request {
val request = Request.Builder()
.url(page.imageUrl)
.headers((sourceLazy.value as? HttpSource)?.headers ?: options.headers)
// Support attaching custom data to the network request.
.tag(Parameters::class.java, options.parameters)
val request = Request.Builder().apply {
url(page.imageUrl)
val sourceHeaders = (sourceLazy.value as? HttpSource)?.headers
if (sourceHeaders != null) {
headers(sourceHeaders)
}
}
request.cacheControl(getCacheControl())
@@ -218,7 +225,12 @@ class PagePreviewFetcher(
}
private fun DiskCache.Snapshot.toImageSource(): ImageSource {
return ImageSource(file = data, diskCacheKey = diskCacheKey, closeable = this)
return ImageSource(
file = data,
fileSystem = FileSystem.SYSTEM,
diskCacheKey = diskCacheKey,
closeable = this
)
}
class Factory(
@@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.data.coil
import coil.key.Keyer
import coil.request.Options
import coil3.key.Keyer
import coil3.request.Options
import eu.kanade.domain.manga.model.PagePreview
class PagePreviewKeyer : Keyer<PagePreview> {
@@ -2,13 +2,14 @@ package eu.kanade.tachiyomi.data.coil
import android.os.Build
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DecodeResult
import coil.decode.Decoder
import coil.decode.ImageDecoderDecoder
import coil.decode.ImageSource
import coil.fetch.SourceResult
import coil.request.Options
import coil3.ImageLoader
import coil3.asCoilImage
import coil3.decode.DecodeResult
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
@@ -51,14 +52,14 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
check(bitmap != null) { "Failed to decode image" }
return DecodeResult(
drawable = bitmap.toDrawable(options.context.resources),
image = bitmap.asCoilImage(),
isSampled = false,
)
}
class Factory : Decoder.Factory {
override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder? {
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
if (!isApplicable(result.source.source())) return null
return TachiyomiImageDecoder(result.source, options)
}
@@ -79,7 +80,7 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
}
}
override fun equals(other: Any?) = other is ImageDecoderDecoder.Factory
override fun equals(other: Any?) = other is Factory
override fun hashCode() = javaClass.hashCode()
}
@@ -21,6 +21,8 @@ interface Chapter : SChapter, Serializable {
var source_order: Int
var last_modified: Long
var version: Long
}
fun Chapter.toDomainChapter(): DomainChapter? {
@@ -39,5 +41,6 @@ fun Chapter.toDomainChapter(): DomainChapter? {
chapterNumber = chapter_number.toDouble(),
scanlator = scanlator,
lastModifiedAt = last_modified,
version = version,
)
}
@@ -28,6 +28,8 @@ class ChapterImpl : Chapter {
override var last_modified: Long = 0
override var version: Long = 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
@@ -18,6 +18,7 @@ import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
@@ -336,19 +337,15 @@ class DownloadCache(
}
// Try to wait until extensions and sources have loaded
var sources = getSources()
if (sources.isEmpty()) {
withTimeoutOrNull(30.seconds) {
while (!extensionManager.isInitialized) {
delay(2.seconds)
}
// SY -->
var sources = emptyList<Source>()
withTimeoutOrNull(30.seconds) {
extensionManager.isInitialized.first { it }
sourceManager.isInitialized.first { it }
while (extensionManager.availableExtensionsFlow.value.isNotEmpty() && sources.isEmpty()) {
delay(2.seconds)
sources = getSources()
}
}
sources = getSources()
}
// SY <--
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import android.os.Build
import com.hippo.unifile.UniFile
import eu.kanade.domain.chapter.model.toSChapter
import eu.kanade.domain.manga.model.getComicInfo
@@ -43,11 +42,10 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.ZipParameters
import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.addFilesToZip
import tachiyomi.core.common.storage.extension
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNow
@@ -61,12 +59,12 @@ import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.BufferedOutputStream
import java.io.File
import java.nio.charset.StandardCharsets
import java.util.Locale
import java.util.zip.CRC32
import java.util.zip.ZipEntry
@@ -86,6 +84,7 @@ class Downloader(
private val downloadPreferences: DownloadPreferences = Injekt.get(),
private val xml: XML = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(),
private val getTracks: GetTracks = Injekt.get(),
// SY -->
private val sourcePreferences: SourcePreferences = Injekt.get(),
// SY <--
@@ -573,10 +572,6 @@ class Downloader(
tmpDir,
imageFile,
filenamePrefix,
// SY -->
zip4jFile = null,
zip4jEntry = null,
// SY <--
)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to split downloaded image" }
@@ -663,31 +658,15 @@ class Downloader(
dirname: String,
tmpDir: UniFile,
) {
val zipFile = File(context.externalCacheDir, "$dirname.cbz$TMP_DIR_SUFFIX")
val zip = ZipFile(zipFile)
val zipParameters = ZipParameters()
CbzCrypto.setZipParametersEncrypted(zipParameters)
zip.setPassword(CbzCrypto.getDecryptedPasswordCbz())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) zip.charset = StandardCharsets.ISO_8859_1
tmpDir.filePath?.let { addPaddingToImage(File(it)) }
zip.addFiles(
tmpDir.listFiles()?.map { img -> img.filePath?.let { File(it) } },
zipParameters,
)
zip.close()
val realZip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX")!!
realZip.openOutputStream().use { out ->
zipFile.inputStream().use {
it.copyTo(out)
}
tmpDir.listFiles()?.toList()?.let { files ->
mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX")
?.addFilesToZip(files, CbzCrypto.getDecryptedPasswordCbz())
}
mangaDir.findFile("$dirname.cbz$TMP_DIR_SUFFIX")?.renameTo("$dirname.cbz")
tmpDir.delete()
zipFile.delete()
}
private fun addPaddingToImage(imageDir: File) {
@@ -713,9 +692,22 @@ class Downloader(
chapter: Chapter,
source: HttpSource,
) {
val chapterUrl = source.getChapterUrl(chapter.toSChapter())
val categories = getCategories.await(manga.id).map { it.name.trim() }.takeUnless { it.isEmpty() }
val comicInfo = getComicInfo(manga, chapter, chapterUrl, categories)
val urls = getTracks.await(manga.id)
.mapNotNull { track ->
track.remoteUrl.takeUnless { url -> url.isBlank() }?.trim()
}
.plus(source.getChapterUrl(chapter.toSChapter()).trim())
.distinct()
val comicInfo = getComicInfo(
manga,
chapter,
urls,
categories,
source.name
)
// Remove the old file
dir.findFile(COMIC_INFO_FILE, true)?.delete()
dir.createFile(COMIC_INFO_FILE)!!.openOutputStream().use {
@@ -21,14 +21,15 @@ import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.model.copyFrom
import eu.kanade.domain.manga.model.toSManga
import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.data.track.TrackStatus
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.source.online.all.MergedSource
@@ -64,6 +65,7 @@ import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.UnsortedPreferences
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.NoChaptersException
import tachiyomi.domain.download.service.DownloadPreferences
@@ -101,7 +103,6 @@ import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
class LibraryUpdateJob(private val context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) {
@@ -400,8 +401,14 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
.sortedByDescending { it.sourceOrder }.run {
if (libraryPreferences.libraryReadDuplicateChapters().get()) {
val readChapters = getChaptersByMangaId.await(manga.id).filter { it.read }
val newReadChapters = this.filter { chapter -> readChapters.any { it.chapterNumber == chapter.chapterNumber } }
.also { setReadStatus.await(true, *it.toTypedArray()) }
val newReadChapters = this.filter { chapter ->
chapter.chapterNumber > 0 &&
readChapters.any { it.chapterNumber == chapter.chapterNumber }
}
if (newReadChapters.isNotEmpty()) {
setReadStatus.await(true, *newReadChapters.toTypedArray())
}
this.filterNot { newReadChapters.contains(it) }
} else {
@@ -631,9 +638,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
var tracker = dbTracks.firstOrNull { it.trackerId == TrackerManager.MDLIST }
?: mdList.createInitialTracker(manga).toDomainTrack(idRequired = false)
if (tracker?.status == FollowStatus.UNFOLLOWED.int.toLong()) {
if (tracker?.status == FollowStatus.UNFOLLOWED.long) {
tracker = tracker.copy(
status = FollowStatus.READING.int.toLong(),
status = FollowStatus.READING.long,
)
val updatedTrack = mdList.update(tracker.toDbTrack())
insertTrack.await(updatedTrack.toDomainTrack(false)!!)
@@ -796,6 +803,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
// SY <--
): Boolean {
val wm = context.workManager
// Check if the LibraryUpdateJob is already running
if (wm.isRunning(TAG)) {
// Already running either as a scheduled or manual job
return false
@@ -809,12 +817,41 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
KEY_GROUP_EXTRA to groupExtra,
// SY <--
)
val request = OneTimeWorkRequestBuilder<LibraryUpdateJob>()
.addTag(TAG)
.addTag(WORK_NAME_MANUAL)
.setInputData(inputData)
.build()
wm.enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request)
val syncPreferences: SyncPreferences = Injekt.get()
// 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)) {
// SyncDataJob is already running
return false
}
// Define the SyncDataJob
val syncDataJob = OneTimeWorkRequestBuilder<SyncDataJob>()
.addTag(SyncDataJob.TAG_MANUAL)
.build()
// Chain SyncDataJob to run before LibraryUpdateJob
val libraryUpdateJob = OneTimeWorkRequestBuilder<LibraryUpdateJob>()
.addTag(TAG)
.addTag(WORK_NAME_MANUAL)
.setInputData(inputData)
.build()
wm.beginUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, syncDataJob)
.then(libraryUpdateJob)
.enqueue()
} else {
val request = OneTimeWorkRequestBuilder<LibraryUpdateJob>()
.addTag(TAG)
.addTag(WORK_NAME_MANUAL)
.setInputData(inputData)
.build()
wm.enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request)
}
return true
}
@@ -9,9 +9,10 @@ import android.graphics.BitmapFactory
import android.net.Uri
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import coil.imageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import coil3.imageLoader
import coil3.request.ImageRequest
import coil3.request.transformations
import coil3.transform.CircleCropTransformation
import eu.kanade.presentation.util.formatChapterNumber
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.core.security.SecurityPreferences
@@ -294,7 +295,7 @@ class LibraryUpdateNotifier(
.transformations(CircleCropTransformation())
.size(NOTIF_ICON_SIZE)
.build()
val drawable = context.imageLoader.execute(request).drawable
val drawable = context.imageLoader.execute(request).image?.asDrawable(context.resources)
return drawable?.getBitmapOrNull()
}
@@ -10,6 +10,7 @@ import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.data.updater.AppUpdateDownloadJob
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
@@ -71,6 +72,8 @@ class NotificationReceiver : BroadcastReceiver() {
"application/x-protobuf+gzip",
)
ACTION_CANCEL_RESTORE -> cancelRestore(context)
ACTION_CANCEL_SYNC -> cancelSync(context)
// Cancel library update and dismiss notification
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
// Start downloading app update
@@ -188,6 +191,15 @@ class NotificationReceiver : BroadcastReceiver() {
AppUpdateDownloadJob.stop(context)
}
/**
* Method called when user wants to stop a backup restore job.
*
* @param context context of application
*/
private fun cancelSync(context: Context) {
SyncDataJob.stop(context)
}
/**
* Method called when user wants to mark manga chapters as read
*
@@ -240,6 +252,8 @@ class NotificationReceiver : BroadcastReceiver() {
private const val ACTION_CANCEL_RESTORE = "$ID.$NAME.CANCEL_RESTORE"
private const val ACTION_CANCEL_SYNC = "$ID.$NAME.CANCEL_SYNC"
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
private const val ACTION_START_APP_UPDATE = "$ID.$NAME.ACTION_START_APP_UPDATE"
@@ -618,5 +632,25 @@ class NotificationReceiver : BroadcastReceiver() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/**
* Returns [PendingIntent] that cancels a sync restore job.
*
* @param context context of application
* @param notificationId id of notification
* @return [PendingIntent]
*/
internal fun cancelSyncPendingBroadcast(context: Context, notificationId: Int): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_CANCEL_SYNC
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}
}
@@ -0,0 +1,102 @@
package eu.kanade.tachiyomi.data.sync
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
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.workManager
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
class SyncDataJob(private val context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) {
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" }
}
return try {
SyncManager(context).syncData()
Result.success()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
notifier.showSyncError(e.message)
Result.failure()
} finally {
context.cancelNotification(Notifications.ID_RESTORE_PROGRESS)
}
}
override suspend fun getForegroundInfo(): ForegroundInfo {
return ForegroundInfo(
Notifications.ID_RESTORE_PROGRESS,
notifier.showSyncProgress().build(),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
},
)
}
companion object {
private const val TAG_JOB = "SyncDataJob"
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 setupTask(context: Context, prefInterval: Int? = null) {
val syncPreferences = Injekt.get<SyncPreferences>()
val interval = prefInterval ?: syncPreferences.syncInterval().get()
if (interval > 0) {
val request = PeriodicWorkRequestBuilder<SyncDataJob>(
interval.toLong(),
TimeUnit.MINUTES,
10,
TimeUnit.MINUTES,
)
.addTag(TAG_AUTO)
.build()
context.workManager.enqueueUniquePeriodicWork(TAG_AUTO, ExistingPeriodicWorkPolicy.UPDATE, request)
} else {
context.workManager.cancelUniqueWork(TAG_AUTO)
}
}
fun startNow(context: Context) {
val request = OneTimeWorkRequestBuilder<SyncDataJob>()
.addTag(TAG_MANUAL)
.build()
context.workManager.enqueueUniqueWork(TAG_MANUAL, ExistingWorkPolicy.KEEP, request)
}
fun stop(context: Context) {
context.workManager.cancelUniqueWork(TAG_MANUAL)
}
}
}
@@ -0,0 +1,328 @@
package eu.kanade.tachiyomi.data.sync
import android.content.Context
import android.net.Uri
import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.tachiyomi.data.backup.create.BackupCreator
import eu.kanade.tachiyomi.data.backup.create.BackupOptions
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupSerializer
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.backup.restore.RestoreOptions
import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveSyncService
import eu.kanade.tachiyomi.data.sync.service.SyncData
import eu.kanade.tachiyomi.data.sync.service.SyncYomiSyncService
import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority
import logcat.logcat
import tachiyomi.core.common.util.system.logcat
import tachiyomi.data.Chapters
import tachiyomi.data.DatabaseHandler
import tachiyomi.data.manga.MangaMapper.mapManga
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.io.IOException
import java.util.Date
import kotlin.system.measureTimeMillis
/**
* A manager to handle synchronization tasks in the app, such as updating
* sync preferences and performing synchronization with a remote server.
*
* @property context The application context.
*/
class SyncManager(
private val context: Context,
private val handler: DatabaseHandler = Injekt.get(),
private val syncPreferences: SyncPreferences = Injekt.get(),
private var json: Json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
},
private val getCategories: GetCategories = Injekt.get(),
) {
private val backupCreator: BackupCreator = BackupCreator(context, false)
private val notifier: SyncNotifier = SyncNotifier(context)
private val mangaRestorer: MangaRestorer = MangaRestorer()
enum class SyncService(val value: Int) {
NONE(0),
SYNCYOMI(1),
GOOGLE_DRIVE(2),
;
companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: NONE
}
}
/**
* Syncs data with a sync service.
*
* This function retrieves local data (favorites, manga, extensions, and categories)
* from the database using the BackupManager, then synchronizes the data with a sync service.
*/
suspend fun syncData() {
// Reset isSyncing in case it was left over or failed syncing during restore.
handler.await(inTransaction = true) {
mangasQueries.resetIsSyncing()
chaptersQueries.resetIsSyncing()
}
val syncOptions = syncPreferences.getSyncSettings()
val databaseManga = getAllMangaThatNeedsSync()
val backupOptions = BackupOptions(
libraryEntries = syncOptions.libraryEntries,
categories = syncOptions.categories,
chapters = syncOptions.chapters,
tracking = syncOptions.tracking,
history = syncOptions.history,
appSettings = syncOptions.appSettings,
sourceSettings = syncOptions.sourceSettings,
privateSettings = syncOptions.privateSettings,
)
val backup = Backup(
backupManga = backupCreator.backupMangas(databaseManga, backupOptions),
backupCategories = backupCreator.backupCategories(backupOptions),
backupSources = backupCreator.backupSources(databaseManga),
backupPreferences = backupCreator.backupAppPreferences(backupOptions),
backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions),
// SY -->
backupSavedSearches = backupCreator.backupSavedSearches(),
// SY <--
)
// Create the SyncData object
val syncData = SyncData(
backup = backup,
)
// Handle sync based on the selected service
val syncService = when (val syncService = SyncService.fromInt(syncPreferences.syncService().get())) {
SyncService.SYNCYOMI -> {
SyncYomiSyncService(
context,
json,
syncPreferences,
notifier,
)
}
SyncService.GOOGLE_DRIVE -> {
GoogleDriveSyncService(context, json, syncPreferences)
}
else -> {
logcat(LogPriority.ERROR) { "Invalid sync service type: $syncService" }
null
}
}
val remoteBackup = syncService?.doSync(syncData)
// Stop the sync early if the remote backup is null or empty
if (remoteBackup?.backupManga?.size == 0) {
notifier.showSyncError("No data found on remote server.")
return
}
// Check if it's first sync based on lastSyncTimestamp
if (syncPreferences.lastSyncTimestamp().get() == 0L && databaseManga.isNotEmpty()) {
// It's first sync no need to restore data. (just update remote data)
syncPreferences.lastSyncTimestamp().set(Date().time)
notifier.showSyncSuccess("Updated remote data successfully")
return
}
if (remoteBackup != null) {
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,
// 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,
),
)
// update the sync timestamp
syncPreferences.lastSyncTimestamp().set(Date().time)
} else {
logcat(LogPriority.ERROR) { "Failed to write sync data to file" }
}
}
}
private fun writeSyncDataToCache(context: Context, backup: Backup): Uri? {
val cacheFile = File(context.cacheDir, "tachiyomi_sync_data.proto.gz")
return try {
cacheFile.outputStream().use { output ->
output.write(ProtoBuf.encodeToByteArray(BackupSerializer, backup))
Uri.fromFile(cacheFile)
}
} catch (e: IOException) {
logcat(LogPriority.ERROR, throwable = e) { "Failed to write sync data to cache" }
null
}
}
/**
* Retrieves all manga from the local database.
*
* @return a list of all manga stored in the database
*/
private suspend fun getAllMangaFromDB(): List<Manga> {
return handler.awaitList { mangasQueries.getAllManga(::mapManga) }
}
private suspend fun getAllMangaThatNeedsSync(): List<Manga> {
return handler.awaitList { mangasQueries.getMangasWithFavoriteTimestamp(::mapManga) }
}
private suspend fun isMangaDifferent(localManga: Manga, remoteManga: BackupManga): Boolean {
val localChapters = handler.await { chaptersQueries.getChaptersByMangaId(localManga.id, 0).executeAsList() }
val localCategories = getCategories.await(localManga.id).map { it.order }
if (areChaptersDifferent(localChapters, remoteManga.chapters)) {
return true
}
if (localManga.version != remoteManga.version) {
return true
}
if (localCategories.toSet() != remoteManga.categories.toSet()) {
return true
}
return false
}
private fun areChaptersDifferent(localChapters: List<Chapters>, remoteChapters: List<BackupChapter>): Boolean {
val localChapterMap = localChapters.associateBy { it.url }
val remoteChapterMap = remoteChapters.associateBy { it.url }
if (localChapterMap.size != remoteChapterMap.size) {
return true
}
for ((url, localChapter) in localChapterMap) {
val remoteChapter = remoteChapterMap[url]
// If a matching remote chapter doesn't exist, or the version numbers are different, consider them different
if (remoteChapter == null || localChapter.version != remoteChapter.version) {
return true
}
}
return false
}
/**
* Filters the favorite and non-favorite manga from the backup and checks
* if the favorite manga is different from the local database.
* @param backup the Backup object containing the backup data.
* @return a Pair of lists, where the first list contains different favorite manga
* and the second list contains non-favorite manga.
*/
private suspend fun filterFavoritesAndNonFavorites(backup: Backup): Pair<List<BackupManga>, List<BackupManga>> {
val favorites = mutableListOf<BackupManga>()
val nonFavorites = mutableListOf<BackupManga>()
val logTag = "filterFavoritesAndNonFavorites"
val elapsedTimeMillis = measureTimeMillis {
val databaseManga = getAllMangaFromDB()
val localMangaMap = databaseManga.associateBy {
Triple(it.source, it.url, it.title)
}
logcat(LogPriority.DEBUG, logTag) { "Starting to filter favorites and non-favorites from backup data." }
backup.backupManga.forEach { remoteManga ->
val compositeKey = Triple(remoteManga.source, remoteManga.url, remoteManga.title)
val localManga = localMangaMap[compositeKey]
when {
// Checks if the manga is in favorites and needs updating or adding
remoteManga.favorite -> {
if (localManga == null || isMangaDifferent(localManga, remoteManga)) {
logcat(LogPriority.DEBUG, logTag) { "Adding to favorites: ${remoteManga.title}" }
favorites.add(remoteManga)
} else {
logcat(LogPriority.DEBUG, logTag) { "Already up-to-date favorite: ${remoteManga.title}" }
}
}
// Handle non-favorites
!remoteManga.favorite -> {
logcat(LogPriority.DEBUG, logTag) { "Adding to non-favorites: ${remoteManga.title}" }
nonFavorites.add(remoteManga)
}
}
}
}
val minutes = elapsedTimeMillis / 60000
val seconds = (elapsedTimeMillis % 60000) / 1000
logcat(LogPriority.DEBUG, logTag) {
"Filtering completed in ${minutes}m ${seconds}s. Favorites found: ${favorites.size}, " +
"Non-favorites found: ${nonFavorites.size}"
}
return Pair(favorites, nonFavorites)
}
/**
* Updates the non-favorite manga in the local database with their favorite status from the backup.
* @param nonFavorites the list of non-favorite BackupManga objects from the backup.
*/
private suspend fun updateNonFavorites(nonFavorites: List<BackupManga>) {
val localMangaList = getAllMangaFromDB()
val localMangaMap = localMangaList.associateBy { Triple(it.source, it.url, it.title) }
nonFavorites.forEach { nonFavorite ->
val key = Triple(nonFavorite.source, nonFavorite.url, nonFavorite.title)
localMangaMap[key]?.let { localManga ->
if (localManga.favorite != nonFavorite.favorite) {
val updatedManga = localManga.copy(favorite = nonFavorite.favorite)
mangaRestorer.updateManga(updatedManga)
}
}
}
}
}
@@ -0,0 +1,86 @@
package eu.kanade.tachiyomi.data.sync
import android.content.Context
import android.graphics.BitmapFactory
import androidx.core.app.NotificationCompat
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.cancelNotification
import eu.kanade.tachiyomi.util.system.notificationBuilder
import eu.kanade.tachiyomi.util.system.notify
import uy.kohesive.injekt.injectLazy
class SyncNotifier(private val context: Context) {
private val preferences: SecurityPreferences by injectLazy()
private val progressNotificationBuilder = context.notificationBuilder(
Notifications.CHANNEL_BACKUP_RESTORE_PROGRESS,
) {
setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
setSmallIcon(R.drawable.ic_tachi)
setAutoCancel(false)
setOngoing(true)
setOnlyAlertOnce(true)
}
private val completeNotificationBuilder = context.notificationBuilder(
Notifications.CHANNEL_BACKUP_RESTORE_PROGRESS,
) {
setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
setSmallIcon(R.drawable.ic_tachi)
setAutoCancel(false)
}
private fun NotificationCompat.Builder.show(id: Int) {
context.notify(id, build())
}
fun showSyncProgress(content: String = "", progress: Int = 0, maxAmount: Int = 100): NotificationCompat.Builder {
val builder = with(progressNotificationBuilder) {
setContentTitle(context.getString(R.string.syncing_library))
if (!preferences.hideNotificationContent().get()) {
setContentText(content)
}
setProgress(maxAmount, progress, true)
setOnlyAlertOnce(true)
clearActions()
addAction(
R.drawable.ic_close_24dp,
context.getString(R.string.action_cancel),
NotificationReceiver.cancelSyncPendingBroadcast(context, Notifications.ID_RESTORE_PROGRESS),
)
}
builder.show(Notifications.ID_RESTORE_PROGRESS)
return builder
}
fun showSyncError(error: String?) {
context.cancelNotification(Notifications.ID_RESTORE_PROGRESS)
with(completeNotificationBuilder) {
setContentTitle(context.getString(R.string.sync_error))
setContentText(error)
show(Notifications.ID_RESTORE_COMPLETE)
}
}
fun showSyncSuccess(message: String?) {
context.cancelNotification(Notifications.ID_RESTORE_PROGRESS)
with(completeNotificationBuilder) {
setContentTitle(context.getString(R.string.sync_complete))
setContentText(message)
show(Notifications.ID_RESTORE_COMPLETE)
}
}
}
@@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.data.sync.models
import dev.icerock.moko.resources.StringResource
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR
data class SyncTriggerOptions(
val syncOnChapterRead: Boolean = false,
val syncOnChapterOpen: Boolean = false,
val syncOnAppStart: Boolean = false,
val syncOnAppResume: Boolean = false,
) {
fun asBooleanArray() = booleanArrayOf(
syncOnChapterRead,
syncOnChapterOpen,
syncOnAppStart,
syncOnAppResume,
)
fun anyEnabled() = syncOnChapterRead ||
syncOnChapterOpen ||
syncOnAppStart ||
syncOnAppResume
companion object {
val mainOptions = persistentListOf(
Entry(
label = MR.strings.sync_on_chapter_read,
getter = SyncTriggerOptions::syncOnChapterRead,
setter = { options, enabled -> options.copy(syncOnChapterRead = enabled) },
),
Entry(
label = MR.strings.sync_on_chapter_open,
getter = SyncTriggerOptions::syncOnChapterOpen,
setter = { options, enabled -> options.copy(syncOnChapterOpen = enabled) },
),
Entry(
label = MR.strings.sync_on_app_start,
getter = SyncTriggerOptions::syncOnAppStart,
setter = { options, enabled -> options.copy(syncOnAppStart = enabled) },
),
Entry(
label = MR.strings.sync_on_app_resume,
getter = SyncTriggerOptions::syncOnAppResume,
setter = { options, enabled -> options.copy(syncOnAppResume = enabled) },
),
)
fun fromBooleanArray(array: BooleanArray) = SyncTriggerOptions(
syncOnChapterRead = array[0],
syncOnChapterOpen = array[1],
syncOnAppStart = array[2],
syncOnAppResume = array[3],
)
}
data class Entry(
val label: StringResource,
val getter: (SyncTriggerOptions) -> Boolean,
val setter: (SyncTriggerOptions, Boolean) -> SyncTriggerOptions,
val enabled: (SyncTriggerOptions) -> Boolean = { true },
)
}
@@ -0,0 +1,528 @@
package eu.kanade.tachiyomi.data.sync.service
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.google.api.client.auth.oauth2.TokenResponseException
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeTokenRequest
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.javanet.NetHttpTransport
import com.google.api.client.json.JsonFactory
import com.google.api.client.json.jackson2.JacksonFactory
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 kotlinx.coroutines.delay
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
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 uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.time.Instant
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
class GoogleDriveSyncService(context: Context, json: Json, syncPreferences: SyncPreferences) : SyncService(
context,
json,
syncPreferences,
) {
constructor(context: Context) : this(
context,
Json {
encodeDefaults = true
ignoreUnknownKeys = true
},
Injekt.get<SyncPreferences>(),
)
enum class DeleteSyncDataStatus {
NOT_INITIALIZED,
NO_FILES,
SUCCESS,
ERROR,
}
private val appName = context.stringResource(MR.strings.app_name)
private val remoteFileName = "${appName}_sync_data.gz"
private val lockFileName = "${appName}_sync.lock"
private val googleDriveService = GoogleDriveService(context)
override suspend fun beforeSync() {
try {
googleDriveService.refreshToken()
val drive = googleDriveService.driveService
?: throw Exception(context.stringResource(MR.strings.google_drive_not_signed_in))
var backoff = 1000L
var retries = 0 // Retry counter
val maxRetries = 10 // Maximum number of retries
while (retries < maxRetries) {
val lockFiles = findLockFile(drive)
logcat(LogPriority.DEBUG) { "Found ${lockFiles.size} lock file(s)" }
when {
lockFiles.isEmpty() -> {
logcat(LogPriority.DEBUG) { "No lock file found, creating a new one" }
createLockFile(drive)
break
}
lockFiles.size == 1 -> {
val lockFile = lockFiles.first()
val createdTime = Instant.parse(lockFile.createdTime.toString())
val ageMinutes = java.time.Duration.between(createdTime, Instant.now()).toMinutes()
logcat(LogPriority.DEBUG) { "Lock file age: $ageMinutes minutes" }
if (ageMinutes <= 3) {
logcat(LogPriority.DEBUG) { "Lock file is new, proceeding with sync" }
break
} else {
logcat(LogPriority.DEBUG) { "Lock file is old, deleting and creating a new one" }
deleteLockFile(drive)
createLockFile(drive)
break
}
}
else -> {
logcat(LogPriority.DEBUG) { "Multiple lock files found, applying backoff" }
delay(backoff) // Apply backoff strategy
backoff = (backoff * 2).coerceAtMost(16000L)
logcat(LogPriority.DEBUG) { "Backoff increased to $backoff milliseconds" }
}
}
retries++ // Increment retry counter
logcat(LogPriority.DEBUG) { "Loop iteration complete, retry count: $retries, backoff time: $backoff" }
}
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.")
}
} 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)
}
}
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))
}
val fileList = getAppDataFileList(drive)
if (fileList.isEmpty()) {
logcat(LogPriority.INFO) { "No files found in app data" }
return null
}
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" }
} 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)
}
}
}
override suspend fun pushSyncData(syncData: SyncData) {
val jsonData = json.encodeToString(syncData)
val drive = googleDriveService.driveService
?: throw Exception(context.stringResource(MR.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())
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")
}
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)
}
}
private fun getAppDataFileList(drive: Drive): MutableList<File> {
try {
// Search for the existing file by name in the appData folder
val query = "mimeType='application/gzip' and name = '$remoteFileName'"
val fileList = drive.files()
.list()
.setSpaces("appDataFolder")
.setQ(query)
.setFields("files(id, name, createdTime)")
.execute()
.files
logcat { "AppData folder file list: $fileList" }
return fileList
} catch (e: Exception) {
logcat(LogPriority.ERROR, throwable = e) { "Error no sync data found in appData folder" }
return mutableListOf()
}
}
private fun createLockFile(drive: Drive) {
try {
val fileMetadata = File().apply {
name = lockFileName
mimeType = "text/plain"
parents = listOf("appDataFolder")
}
// Create an empty content to upload as the lock file
val emptyContent = ByteArrayContent.fromString("text/plain", "")
val file = drive.files().create(fileMetadata, emptyContent)
.setFields("id, name, createdTime")
.execute()
logcat { "Created lock file with ID: ${file.id}" }
} catch (e: Exception) {
logcat(LogPriority.ERROR, throwable = e) { "Error creating lock file" }
throw Exception(e.message, e)
}
}
private fun findLockFile(drive: Drive): MutableList<File> {
return try {
val query = "mimeType='text/plain' and name = '$lockFileName'"
val fileList = drive.files()
.list()
.setSpaces("appDataFolder")
.setQ(query)
.setFields("files(id, name, createdTime)")
.execute().files
logcat { "Lock file search result: $fileList" }
fileList
} catch (e: Exception) {
logcat(LogPriority.ERROR, throwable = e) { "Error finding lock file" }
mutableListOf()
}
}
private fun deleteLockFile(drive: Drive) {
try {
val lockFiles = findLockFile(drive)
if (lockFiles.isNotEmpty()) {
for (file in lockFiles) {
drive.files().delete(file.id).execute()
logcat { "Deleted lock file with ID: ${file.id}" }
}
} else {
logcat { "No lock file found to delete." }
}
} 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)
}
}
suspend fun deleteSyncDataFromGoogleDrive(): DeleteSyncDataStatus {
val drive = googleDriveService.driveService
if (drive == null) {
logcat(LogPriority.ERROR) { "Google Drive service not initialized" }
return DeleteSyncDataStatus.NOT_INITIALIZED
}
googleDriveService.refreshToken()
return withIOContext {
try {
val appDataFileList = getAppDataFileList(drive)
if (appDataFileList.isEmpty()) {
this@GoogleDriveSyncService
.logcat(LogPriority.DEBUG) { "No sync data file found in appData folder of Google Drive" }
DeleteSyncDataStatus.NO_FILES
} else {
for (file in appDataFileList) {
drive.files().delete(file.id).execute()
this@GoogleDriveSyncService.logcat(
LogPriority.DEBUG,
) { "Deleted sync data file in appData folder of Google Drive with file ID: ${file.id}" }
}
DeleteSyncDataStatus.SUCCESS
}
} catch (e: Exception) {
this@GoogleDriveSyncService.logcat(LogPriority.ERROR, throwable = e) {
"Error occurred while interacting with Google Drive"
}
DeleteSyncDataStatus.ERROR
}
}
}
}
class GoogleDriveService(private val context: Context) {
var driveService: Drive? = null
companion object {
const val REDIRECT_URI = "eu.kanade.google.oauth:/oauth2redirect"
}
private val syncPreferences = Injekt.get<SyncPreferences>()
init {
initGoogleDriveService()
}
/**
* Initializes the Google Drive service by obtaining the access token and refresh token from the SyncPreferences
* and setting up the service using the obtained tokens.
*/
private fun initGoogleDriveService() {
val accessToken = syncPreferences.googleDriveAccessToken().get()
val refreshToken = syncPreferences.googleDriveRefreshToken().get()
if (accessToken == "" || refreshToken == "") {
driveService = null
return
}
setupGoogleDriveService(accessToken, refreshToken)
}
/**
* Launches an Intent to open the user's default browser for Google Drive sign-in.
* The Intent carries the authorization URL, which prompts the user to sign in
* and grant the application permission to access their Google Drive account.
* @return An Intent configured to launch a browser for Google Drive OAuth sign-in.
*/
fun getSignInIntent(): Intent {
val authorizationUrl = generateAuthorizationUrl()
return Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(authorizationUrl)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
/**
* Generates the authorization URL required for the user to grant the application
* permission to access their Google Drive account.
* Sets the approval prompt to "force" to ensure that the user is always prompted to grant access,
* even if they have previously granted access.
* @return The authorization URL.
*/
private fun generateAuthorizationUrl(): String {
val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance()
val secrets = GoogleClientSecrets.load(
jsonFactory,
context.assets.open("client_secrets.json").reader(),
)
val flow = GoogleAuthorizationCodeFlow.Builder(
NetHttpTransport(),
jsonFactory,
secrets,
listOf(DriveScopes.DRIVE_FILE, DriveScopes.DRIVE_APPDATA),
).setAccessType("offline").build()
return flow.newAuthorizationUrl()
.setRedirectUri(REDIRECT_URI)
.setApprovalPrompt("force")
.build()
}
internal suspend fun refreshToken() = withIOContext {
val refreshToken = syncPreferences.googleDriveRefreshToken().get()
val accessToken = syncPreferences.googleDriveAccessToken().get()
val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance()
val secrets = GoogleClientSecrets.load(
jsonFactory,
context.assets.open("client_secrets.json").reader(),
)
val credential = GoogleCredential.Builder()
.setJsonFactory(jsonFactory)
.setTransport(NetHttpTransport())
.setClientSecrets(secrets)
.build()
if (refreshToken == "") {
throw Exception(context.stringResource(MR.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
this@GoogleDriveService.logcat(LogPriority.ERROR, throwable = e) {
"Refresh token is invalid, prompt user to sign in again"
}
throw e.message?.let { Exception(it, e) } ?: Exception("Unknown error", e)
} else {
// Token refresh failed; handle this situation
this@GoogleDriveService.logcat(LogPriority.ERROR) { "Failed to refresh access token ${e.message}" }
this@GoogleDriveService.logcat(LogPriority.ERROR) { "Google Drive sync will be disabled" }
throw e.message?.let { Exception(it, e) } ?: Exception("Unknown error", e)
}
} catch (e: IOException) {
// Token refresh failed; handle this situation
this@GoogleDriveService.logcat(LogPriority.ERROR, throwable = e) { "Failed to refresh access token" }
this@GoogleDriveService.logcat(LogPriority.ERROR) { "Google Drive sync will be disabled" }
throw e.message?.let { Exception(it, e) } ?: Exception("Unknown error", e)
}
}
/**
* Sets up the Google Drive service using the provided access token and refresh token.
* @param accessToken The access token obtained from the SyncPreferences.
* @param refreshToken The refresh token obtained from the SyncPreferences.
*/
private fun setupGoogleDriveService(accessToken: String, refreshToken: String) {
val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance()
val secrets = GoogleClientSecrets.load(
jsonFactory,
context.assets.open("client_secrets.json").reader(),
)
val credential = GoogleCredential.Builder()
.setJsonFactory(jsonFactory)
.setTransport(NetHttpTransport())
.setClientSecrets(secrets)
.build()
credential.accessToken = accessToken
credential.refreshToken = refreshToken
driveService = Drive.Builder(
NetHttpTransport(),
jsonFactory,
credential,
).setApplicationName(context.stringResource(MR.strings.app_name))
.build()
}
/**
* Handles the authorization code returned after the user has granted the application permission to access their
* Google Drive account.
* It obtains the access token and refresh token using the authorization code, saves the tokens to the
* SyncPreferences, sets up the Google Drive service using the obtained tokens, and initializes the service.
* @param authorizationCode The authorization code obtained from the OAuthCallbackServer.
* @param activity The current activity.
* @param onSuccess A callback function to be called on successful authorization.
* @param onFailure A callback function to be called on authorization failure.
*/
fun handleAuthorizationCode(
authorizationCode: String,
activity: Activity,
onSuccess: () -> Unit,
onFailure: (String) -> Unit,
) {
val jsonFactory: JsonFactory = JacksonFactory.getDefaultInstance()
val secrets = GoogleClientSecrets.load(
jsonFactory,
context.assets.open("client_secrets.json").reader(),
)
val tokenResponse: GoogleTokenResponse = GoogleAuthorizationCodeTokenRequest(
NetHttpTransport(),
jsonFactory,
secrets.installed.clientId,
secrets.installed.clientSecret,
authorizationCode,
REDIRECT_URI,
).setGrantType("authorization_code").execute()
try {
// Save the access token and refresh token
val accessToken = tokenResponse.accessToken
val refreshToken = tokenResponse.refreshToken
// Save the tokens to SyncPreferences
syncPreferences.googleDriveAccessToken().set(accessToken)
syncPreferences.googleDriveRefreshToken().set(refreshToken)
setupGoogleDriveService(accessToken, refreshToken)
initGoogleDriveService()
activity.runOnUiThread {
onSuccess()
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, throwable = e) { "Failed to handle authorization code" }
activity.runOnUiThread {
onFailure(e.localizedMessage ?: "Unknown error")
}
}
}
}
@@ -0,0 +1,517 @@
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.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSavedSearch
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import logcat.LogPriority
import logcat.logcat
@Serializable
data class SyncData(
val backup: Backup? = null,
)
abstract class SyncService(
val context: Context,
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)
/**
* Merges the local and remote sync data into a single JSON string.
*
* @param localSyncData The SData containing the local sync data.
* @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)
val mergedCategoriesList =
mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories)
val mergedSourcesList =
mergeSourcesLists(localSyncData.backup?.backupSources, remoteSyncData.backup?.backupSources)
val mergedPreferencesList =
mergePreferencesLists(localSyncData.backup?.backupPreferences, remoteSyncData.backup?.backupPreferences)
val mergedSourcePreferencesList = mergeSourcePreferencesLists(
localSyncData.backup?.backupSourcePreferences,
remoteSyncData.backup?.backupSourcePreferences,
)
// SY -->
val mergedSavedSearchesList = mergeSavedSearchesLists(
localSyncData.backup?.backupSavedSearches,
remoteSyncData.backup?.backupSavedSearches,
)
// SY <--
// Create the merged Backup object
val mergedBackup = Backup(
backupManga = mergedMangaList,
backupCategories = mergedCategoriesList,
backupSources = mergedSourcesList,
backupPreferences = mergedPreferencesList,
backupSourcePreferences = mergedSourcePreferencesList,
// SY -->
backupSavedSearches = mergedSavedSearchesList,
// SY <--
)
// Create the merged SData object
return SyncData(
backup = mergedBackup,
)
}
/**
* Merges two lists of BackupManga objects, selecting the most recent manga based on the lastModifiedAt value.
* If lastModifiedAt is null for a manga, it treats that manga as the oldest possible for comparison purposes.
* This function is designed to reconcile local and remote manga lists, ensuring the most up-to-date manga is retained.
*
* @param localMangaList The list of local BackupManga objects or null.
* @param remoteMangaList The list of remote BackupManga objects or null.
* @return A list of BackupManga objects, each representing the most recent version of the manga from either local or remote sources.
*/
private fun mergeMangaLists(
localMangaList: List<BackupManga>?,
remoteMangaList: List<BackupManga>?,
): List<BackupManga> {
val logTag = "MergeMangaLists"
val localMangaListSafe = localMangaList.orEmpty()
val remoteMangaListSafe = remoteMangaList.orEmpty()
logcat(LogPriority.DEBUG, logTag) {
"Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}"
}
fun mangaCompositeKey(manga: BackupManga): String {
return "${manga.source}|${manga.url}|${manga.title.lowercase().trim()}|${manga.author?.lowercase()?.trim()}"
}
// Create maps using composite keys
val localMangaMap = localMangaListSafe.associateBy { mangaCompositeKey(it) }
val remoteMangaMap = remoteMangaListSafe.associateBy { mangaCompositeKey(it) }
logcat(LogPriority.DEBUG, logTag) {
"Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}"
}
val mergedList = (localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey ->
val local = localMangaMap[compositeKey]
val remote = remoteMangaMap[compositeKey]
// New version comparison logic
when {
local != null && remote == null -> local
local == null && remote != null -> remote
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))
} else {
logcat(LogPriority.DEBUG, logTag) {
"Keeping remote version of ${remote.title} with merged chapters."
}
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters))
}
}
else -> null // No manga found for key
}
}
// Counting favorites and non-favorites
val (favorites, nonFavorites) = mergedList.partition { it.favorite }
logcat(LogPriority.DEBUG, logTag) {
"Merge completed. Total merged manga: ${mergedList.size}, Favorites: ${favorites.size}, " +
"Non-Favorites: ${nonFavorites.size}"
}
return mergedList
}
/**
* Merges two lists of BackupChapter objects, selecting the most recent chapter based on the lastModifiedAt value.
* If lastModifiedAt is null for a chapter, it treats that chapter as the oldest possible for comparison purposes.
* This function is designed to reconcile local and remote chapter lists, ensuring the most up-to-date chapter is retained.
*
* @param localChapters The list of local BackupChapter objects.
* @param remoteChapters The list of remote BackupChapter objects.
* @return A list of BackupChapter objects, each representing the most recent version of the chapter from either local or remote sources.
*
* - This function is used in scenarios where local and remote chapter lists need to be synchronized.
* - It iterates over the union of the URLs from both local and remote chapters.
* - For each URL, it compares the corresponding local and remote chapters based on the lastModifiedAt value.
* - If only one source (local or remote) has the chapter for a URL, that chapter is used.
* - If both sources have the chapter, the one with the more recent lastModifiedAt value is chosen.
* - If lastModifiedAt is null or missing, the chapter is considered the oldest for safety, ensuring that any chapter with a valid timestamp is preferred.
* - The resulting list contains the most recent chapters from the combined set of local and remote chapters.
*/
private fun mergeChapters(
localChapters: List<BackupChapter>,
remoteChapters: List<BackupChapter>,
): List<BackupChapter> {
val logTag = "MergeChapters"
fun chapterCompositeKey(chapter: BackupChapter): String {
return "${chapter.url}|${chapter.name}|${chapter.chapterNumber}"
}
val localChapterMap = localChapters.associateBy { chapterCompositeKey(it) }
val remoteChapterMap = remoteChapters.associateBy { chapterCompositeKey(it) }
logcat(LogPriority.DEBUG, logTag) {
"Starting chapter merge. Local chapters: ${localChapters.size}, Remote chapters: ${remoteChapters.size}"
}
// Merge both chapter maps based on version numbers
val mergedChapters = (localChapterMap.keys + remoteChapterMap.keys).distinct().mapNotNull { compositeKey ->
val localChapter = localChapterMap[compositeKey]
val remoteChapter = remoteChapterMap[compositeKey]
logcat(LogPriority.DEBUG, logTag) {
"Processing chapter key: $compositeKey. Local chapter: ${localChapter != null}, " +
"Remote chapter: ${remoteChapter != null}"
}
when {
localChapter != null && remoteChapter == null -> {
logcat(LogPriority.DEBUG, logTag) { "Keeping local chapter: ${localChapter.name}." }
localChapter
}
localChapter == null && remoteChapter != null -> {
logcat(LogPriority.DEBUG, logTag) { "Taking remote chapter: ${remoteChapter.name}." }
remoteChapter
}
localChapter != null && remoteChapter != null -> {
// Use version number to decide which chapter to keep
val chosenChapter = if (localChapter.version >= remoteChapter.version) {
localChapter
} else {
remoteChapter
}
logcat(LogPriority.DEBUG, logTag) {
"Merging chapter: ${chosenChapter.name}. Chosen version from: ${
if (localChapter.version >= remoteChapter.version) "Local" else "Remote"
}, Local version: ${localChapter.version}, Remote version: ${remoteChapter.version}."
}
chosenChapter
}
else -> {
logcat(LogPriority.DEBUG, logTag) {
"No chapter found for composite key: $compositeKey. Skipping."
}
null
}
}
}
logcat(LogPriority.DEBUG, logTag) { "Chapter merge completed. Total merged chapters: ${mergedChapters.size}" }
return mergedChapters
}
/**
* Merges two lists of SyncCategory objects, prioritizing the category with the most recent order value.
*
* @param localCategoriesList The list of local SyncCategory objects.
* @param remoteCategoriesList The list of remote SyncCategory objects.
* @return The merged list of SyncCategory objects.
*/
private fun mergeCategoriesLists(
localCategoriesList: List<BackupCategory>?,
remoteCategoriesList: List<BackupCategory>?,
): List<BackupCategory> {
if (localCategoriesList == null) return remoteCategoriesList ?: emptyList()
if (remoteCategoriesList == null) return localCategoriesList
val localCategoriesMap = localCategoriesList.associateBy { it.name }
val remoteCategoriesMap = remoteCategoriesList.associateBy { it.name }
val mergedCategoriesMap = mutableMapOf<String, BackupCategory>()
localCategoriesMap.forEach { (name, localCategory) ->
val remoteCategory = remoteCategoriesMap[name]
if (remoteCategory != null) {
// Compare and merge local and remote categories
val mergedCategory = if (localCategory.order > remoteCategory.order) {
localCategory
} else {
remoteCategory
}
mergedCategoriesMap[name] = mergedCategory
} else {
// If the category is only in the local list, add it to the merged list
mergedCategoriesMap[name] = localCategory
}
}
// Add any categories from the remote list that are not in the local list
remoteCategoriesMap.forEach { (name, remoteCategory) ->
if (!mergedCategoriesMap.containsKey(name)) {
mergedCategoriesMap[name] = remoteCategory
}
}
return mergedCategoriesMap.values.toList()
}
private fun mergeSourcesLists(
localSources: List<BackupSource>?,
remoteSources: List<BackupSource>?
): List<BackupSource> {
val logTag = "MergeSources"
// Create maps using sourceId as key
val localSourceMap = localSources?.associateBy { it.sourceId } ?: emptyMap()
val remoteSourceMap = remoteSources?.associateBy { it.sourceId } ?: emptyMap()
logcat(LogPriority.DEBUG, logTag) {
"Starting source merge. Local sources: ${localSources?.size}, Remote sources: ${remoteSources?.size}"
}
// Merge both source maps
val mergedSources = (localSourceMap.keys + remoteSourceMap.keys).distinct().mapNotNull { sourceId ->
val localSource = localSourceMap[sourceId]
val remoteSource = remoteSourceMap[sourceId]
logcat(LogPriority.DEBUG, logTag) {
"Processing source ID: $sourceId. Local source: ${localSource != null}, " +
"Remote source: ${remoteSource != null}"
}
when {
localSource != null && remoteSource == null -> {
logcat(LogPriority.DEBUG, logTag) { "Using local source: ${localSource.name}." }
localSource
}
remoteSource != null && localSource == null -> {
logcat(LogPriority.DEBUG, logTag) { "Using remote source: ${remoteSource.name}." }
remoteSource
}
else -> {
logcat(LogPriority.DEBUG, logTag) { "Remote and local is not empty: $sourceId. Skipping." }
null
}
}
}
logcat(LogPriority.DEBUG, logTag) { "Source merge completed. Total merged sources: ${mergedSources.size}" }
return mergedSources
}
private fun mergePreferencesLists(
localPreferences: List<BackupPreference>?,
remotePreferences: List<BackupPreference>?
): List<BackupPreference> {
val logTag = "MergePreferences"
// Create maps using key as the unique identifier
val localPreferencesMap = localPreferences?.associateBy { it.key } ?: emptyMap()
val remotePreferencesMap = remotePreferences?.associateBy { it.key } ?: emptyMap()
logcat(LogPriority.DEBUG, logTag) {
"Starting preferences merge. Local preferences: ${localPreferences?.size}, " +
"Remote preferences: ${remotePreferences?.size}"
}
// Merge both preferences maps
val mergedPreferences = (localPreferencesMap.keys + remotePreferencesMap.keys).distinct().mapNotNull { key ->
val localPreference = localPreferencesMap[key]
val remotePreference = remotePreferencesMap[key]
logcat(LogPriority.DEBUG, logTag) {
"Processing preference key: $key. Local preference: ${localPreference != null}, " +
"Remote preference: ${remotePreference != null}"
}
when {
localPreference != null && remotePreference == null -> {
logcat(LogPriority.DEBUG, logTag) { "Using local preference: ${localPreference.key}." }
localPreference
}
remotePreference != null && localPreference == null -> {
logcat(LogPriority.DEBUG, logTag) { "Using remote preference: ${remotePreference.key}." }
remotePreference
}
else -> {
logcat(LogPriority.DEBUG, logTag) { "Both remote and local have keys. Skipping: $key" }
null
}
}
}
logcat(LogPriority.DEBUG, logTag) {
"Preferences merge completed. Total merged preferences: ${mergedPreferences.size}"
}
return mergedPreferences
}
private fun mergeSourcePreferencesLists(
localPreferences: List<BackupSourcePreferences>?,
remotePreferences: List<BackupSourcePreferences>?
): List<BackupSourcePreferences> {
val logTag = "MergeSourcePreferences"
// Create maps using sourceKey as the unique identifier
val localPreferencesMap = localPreferences?.associateBy { it.sourceKey } ?: emptyMap()
val remotePreferencesMap = remotePreferences?.associateBy { it.sourceKey } ?: emptyMap()
logcat(LogPriority.DEBUG, logTag) {
"Starting source preferences merge. Local source preferences: ${localPreferences?.size}, " +
"Remote source preferences: ${remotePreferences?.size}"
}
// Merge both source preferences maps
val mergedSourcePreferences = (localPreferencesMap.keys + remotePreferencesMap.keys).distinct().mapNotNull { sourceKey ->
val localSourcePreference = localPreferencesMap[sourceKey]
val remoteSourcePreference = remotePreferencesMap[sourceKey]
logcat(LogPriority.DEBUG, logTag) {
"Processing source preference key: $sourceKey. " +
"Local source preference: ${localSourcePreference != null}, " +
"Remote source preference: ${remoteSourcePreference != null}"
}
when {
localSourcePreference != null && remoteSourcePreference == null -> {
logcat(LogPriority.DEBUG, logTag) {
"Using local source preference: ${localSourcePreference.sourceKey}."
}
localSourcePreference
}
remoteSourcePreference != null && localSourcePreference == null -> {
logcat(LogPriority.DEBUG, logTag) {
"Using remote source preference: ${remoteSourcePreference.sourceKey}."
}
remoteSourcePreference
}
localSourcePreference != null && remoteSourcePreference != null -> {
// Merge the individual preferences within the source preferences
val mergedPrefs =
mergeIndividualPreferences(localSourcePreference.prefs, remoteSourcePreference.prefs)
BackupSourcePreferences(sourceKey, mergedPrefs)
}
else -> null
}
}
logcat(LogPriority.DEBUG, logTag) {
"Source preferences merge completed. Total merged source preferences: ${mergedSourcePreferences.size}"
}
return mergedSourcePreferences
}
private fun mergeIndividualPreferences(
localPrefs: List<BackupPreference>,
remotePrefs: List<BackupPreference>
): List<BackupPreference> {
val mergedPrefsMap = (localPrefs + remotePrefs).associateBy { it.key }
return mergedPrefsMap.values.toList()
}
// SY -->
private fun mergeSavedSearchesLists(
localSearches: List<BackupSavedSearch>?,
remoteSearches: List<BackupSavedSearch>?
): List<BackupSavedSearch> {
val logTag = "MergeSavedSearches"
// Define a function to create a composite key from a BackupSavedSearch
fun searchCompositeKey(search: BackupSavedSearch): String {
return "${search.name}|${search.source}"
}
// Create maps using the composite key
val localSearchMap = localSearches?.associateBy { searchCompositeKey(it) } ?: emptyMap()
val remoteSearchMap = remoteSearches?.associateBy { searchCompositeKey(it) } ?: emptyMap()
logcat(LogPriority.DEBUG, logTag) {
"Starting saved searches merge. Local saved searches: ${localSearches?.size}, " +
"Remote saved searches: ${remoteSearches?.size}"
}
// Merge both saved searches maps
val mergedSearches = (localSearchMap.keys + remoteSearchMap.keys).distinct().mapNotNull { compositeKey ->
val localSearch = localSearchMap[compositeKey]
val remoteSearch = remoteSearchMap[compositeKey]
logcat(LogPriority.DEBUG, logTag) {
"Processing saved search key: $compositeKey. Local search: ${localSearch != null}, " +
"Remote search: ${remoteSearch != null}"
}
when {
localSearch != null && remoteSearch == null -> {
logcat(LogPriority.DEBUG, logTag) { "Using local saved search: ${localSearch.name}." }
localSearch
}
remoteSearch != null && localSearch == null -> {
logcat(LogPriority.DEBUG, logTag) { "Using remote saved search: ${remoteSearch.name}." }
remoteSearch
}
else -> {
logcat(LogPriority.DEBUG, logTag) {
"No saved search found for composite key: $compositeKey. Skipping."
}
null
}
}
}
logcat(LogPriority.DEBUG, logTag) {
"Saved searches merge completed. Total merged saved searches: ${mergedSearches.size}"
}
return mergedSearches
}
// SY <--
}
@@ -0,0 +1,198 @@
package eu.kanade.tachiyomi.data.sync.service
import android.content.Context
import eu.kanade.domain.sync.SyncPreferences
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.await
import kotlinx.coroutines.delay
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import logcat.LogPriority
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.gzip
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.system.logcat
import java.util.concurrent.TimeUnit
class SyncYomiSyncService(
context: Context,
json: Json,
syncPreferences: SyncPreferences,
private val notifier: SyncNotifier,
) : SyncService(context, json, syncPreferences) {
@Serializable
enum class SyncStatus {
@SerialName("pending")
Pending,
@SerialName("syncing")
Syncing,
@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)
}
} while (lockFile.status != SyncStatus.Success)
// update lock file acquired_by
client.newCall(lockFileUpdate).await()
}
override suspend fun pullSyncData(): SyncData? {
val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get()
val downloadUrl = "$host/api/sync/download"
val client = OkHttpClient()
val headers = Headers.Builder().add("X-API-Token", apiKey).build()
val downloadRequest = GET(
url = downloadUrl,
headers = headers,
)
val response = client.newCall(downloadRequest).await()
val responseBody = response.body.string()
return if (response.isSuccessful) {
json.decodeFromString<SyncData>(responseBody)
} else {
notifier.showSyncError("Failed to download sync data: $responseBody")
responseBody.let { logcat(LogPriority.ERROR) { "SyncError:$it" } }
null
}
}
override suspend fun pushSyncData(syncData: SyncData) {
val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get()
val uploadUrl = "$host/api/sync/upload"
val timeout = 30L
// Set timeout to 30 seconds
val client = OkHttpClient.Builder()
.connectTimeout(timeout, TimeUnit.SECONDS)
.readTimeout(timeout, TimeUnit.SECONDS)
.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 mediaType = "application/gzip".toMediaTypeOrNull()
val jsonData = json.encodeToString(syncData)
val body = jsonData.toRequestBody(mediaType).gzip()
val uploadRequest = POST(
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" } }
}
}
}
}
@@ -26,7 +26,7 @@ enum class TrackStatus(val int: Int, val res: StringResource) {
fun parseTrackerStatus(trackerManager: TrackerManager, tracker: Long, status: Long): TrackStatus? {
return when (tracker) {
trackerManager.mdList.id -> {
when (FollowStatus.fromInt(status)) {
when (FollowStatus.fromLong(status)) {
FollowStatus.UNFOLLOWED -> null
FollowStatus.READING -> READING
FollowStatus.COMPLETED -> COMPLETED
@@ -41,7 +41,7 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.header("User-Agent", "TachiyomiSY v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
.header("User-Agent", "TachiSY v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
.build()
return chain.proceed(authRequest)
@@ -41,7 +41,7 @@ class MdList(id: Long) : BaseTracker(id, "MDList") {
}
override fun getStatusList(): List<Long> {
return FollowStatus.entries.map { it.int }
return FollowStatus.entries.map { it.long }
}
override fun getStatus(status: Long): StringResource? = when (status) {
@@ -64,13 +64,13 @@ class MdList(id: Long) : BaseTracker(id, "MDList") {
val mdex = mdex ?: throw MangaDexNotFoundException()
val remoteTrack = mdex.fetchTrackingInfo(track.tracking_url)
val followStatus = FollowStatus.fromInt(track.status)
val followStatus = FollowStatus.fromLong(track.status)
// this updates the follow status in the metadata
// allow follow status to update
if (remoteTrack.status != followStatus.int) {
if (remoteTrack.status != followStatus.long) {
if (mdex.updateFollowStatus(MdUtil.getMangaId(track.tracking_url), followStatus)) {
remoteTrack.status = followStatus.int
remoteTrack.status = followStatus.long
} else {
track.status = remoteTrack.status
}
@@ -103,19 +103,19 @@ class MdList(id: Long) : BaseTracker(id, "MDList") {
}
}
override fun getCompletionStatus(): Long = FollowStatus.COMPLETED.int
override fun getCompletionStatus(): Long = FollowStatus.COMPLETED.long
override fun getReadingStatus(): Long = FollowStatus.READING.int
override fun getReadingStatus(): Long = FollowStatus.READING.long
override fun getRereadingStatus(): Long = FollowStatus.RE_READING.int
override fun getRereadingStatus(): Long = FollowStatus.RE_READING.long
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track = update(
refresh(track).also {
if (it.status == FollowStatus.UNFOLLOWED.int) {
if (it.status == FollowStatus.UNFOLLOWED.long) {
it.status = if (hasReadChapters) {
FollowStatus.READING.int
FollowStatus.READING.long
} else {
FollowStatus.PLAN_TO_READ.int
FollowStatus.PLAN_TO_READ.long
}
}
},
@@ -136,7 +136,7 @@ class MdList(id: Long) : BaseTracker(id, "MDList") {
fun createInitialTracker(dbManga: Manga, mdManga: Manga = dbManga): Track {
return Track.create(id).apply {
manga_id = dbManga.id
status = FollowStatus.UNFOLLOWED.int
status = FollowStatus.UNFOLLOWED.long
tracking_url = MdUtil.baseUrl + mdManga.url
title = mdManga.title
}
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
@@ -32,7 +31,8 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor
// Add the authorization header to the original request
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.header("User-Agent", "TachiyomiSY v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
// TODO(antsy): Add back custom user agent when they stop blocking us for no apparent reason
// .header("User-Agent", "TachiyomiSY v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
.build()
return chain.proceed(authRequest)
@@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.data.saver.ImageSaver
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.network.JavaScriptEngine
@@ -185,5 +186,7 @@ class AppModule(val app: Application) : InjektModule {
get<GetCustomMangaInfo>()
// SY <--
}
addSingletonFactory { GoogleDriveService(app) }
}
}
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.di
import android.app.Application
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.core.security.SecurityPreferences
@@ -66,5 +67,9 @@ class PreferenceModule(val app: Application) : InjektModule {
addSingletonFactory {
BasePreferences(app, get())
}
addSingletonFactory {
SyncPreferences(get())
}
}
}
@@ -26,6 +26,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
@@ -53,8 +54,10 @@ class ExtensionManager(
private val trustExtension: TrustExtension = Injekt.get(),
) {
var isInitialized = false
private set
// SY -->
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
// SY <--
/**
* API where all the available extensions can be found.
@@ -135,9 +138,9 @@ class ExtensionManager(
.map { it.extension }
// SY -->
.filterNotBlacklisted()
// SY <--
isInitialized = true
_isInitialized.value = true
// SY <--
}
// EXH -->
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.extension.installer
import android.app.Service
import android.content.pm.PackageManager
import android.os.Build
import android.os.Process
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.getUriSize
import eu.kanade.tachiyomi.util.system.toast
@@ -51,7 +52,8 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
val size = service.getUriSize(entry.uri) ?: throw IllegalStateException()
service.contentResolver.openInputStream(entry.uri)!!.use {
val createCommand = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
"pm install-create --user current -r -i ${service.packageName} -S $size"
val userId = Process.myUserHandle().hashCode()
"pm install-create --user $userId -r -i ${service.packageName} -S $size"
} else {
"pm install-create -r -i ${service.packageName} -S $size"
}
@@ -6,7 +6,6 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.pm.PackageInfoCompat
import dalvik.system.PathClassLoader
import eu.kanade.domain.extension.interactor.TrustExtension
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.model.Extension
@@ -16,6 +15,7 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.util.lang.Hash
import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
import eu.kanade.tachiyomi.util.system.ChildFirstPathClassLoader
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
@@ -272,7 +272,7 @@ internal object ExtensionLoader {
}
val classLoader = try {
PathClassLoader(appInfo.sourceDir, null, context.classLoader)
ChildFirstPathClassLoader(appInfo.sourceDir, null, context.classLoader)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($pkgName)" }
return LoadResult.Error
@@ -29,6 +29,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
@@ -103,6 +105,7 @@ class AndroidSourceManager(
}
}
sourcesMapFlow.value = mutableMap
_isInitialized.value = true
}
}
@@ -186,6 +189,9 @@ class AndroidSourceManager(
}
// SY -->
private val _isInitialized = MutableStateFlow(false)
override val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
override fun getVisibleOnlineSources() = sourcesMapFlow.value.values
.filterIsInstance<HttpSource>()
.filter {
@@ -89,6 +89,8 @@ import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.net.URLEncoder
import java.time.ZoneOffset
import java.time.ZonedDateTime
// TODO Consider gallery updating when doing tabbed browsing
class EHentai(
@@ -271,8 +273,9 @@ class EHentai(
private fun getDateTag(element: Element?): Long? {
val text = element?.text()?.nullIfBlank()
return if (text != null) {
val date = MetadataUtil.EX_DATE_FORMAT.parse(text)
date?.time
println(text)
val date = ZonedDateTime.parse(text, MetadataUtil.EX_DATE_FORMAT.withZone(ZoneOffset.UTC))
date?.toInstant()?.toEpochMilli()
} else {
null
}
@@ -376,11 +379,12 @@ class EHentai(
url = EHentaiSearchMetadata.normalizeUrl(location),
name = "v1: " + doc.selectFirst("#gn")!!.text(),
chapter_number = 1f,
date_upload = MetadataUtil.EX_DATE_FORMAT.parse(
date_upload = ZonedDateTime.parse(
doc.select("#gdd .gdt1").find { el ->
el.text().lowercase() == "posted:"
}!!.nextElementSibling()!!.text(),
)!!.time,
MetadataUtil.EX_DATE_FORMAT.withZone(ZoneOffset.UTC)
)!!.toInstant().toEpochMilli(),
scanlator = EHentaiSearchMetadata.galleryId(location),
)
// Build and append the rest of the galleries
@@ -395,7 +399,10 @@ class EHentai(
url = EHentaiSearchMetadata.normalizeUrl(link),
name = "v${index + 2}: $name",
chapter_number = index + 2f,
date_upload = MetadataUtil.EX_DATE_FORMAT.parse(posted)!!.time,
date_upload = ZonedDateTime.parse(
posted,
MetadataUtil.EX_DATE_FORMAT.withZone(ZoneOffset.UTC)
).toInstant().toEpochMilli(),
scanlator = EHentaiSearchMetadata.galleryId(link),
)
}.reversed() + self
@@ -706,7 +713,10 @@ class EHentai(
if (left != null && right != null) {
ignore {
when (left.removeSuffix(":").lowercase()) {
"posted" -> datePosted = MetadataUtil.EX_DATE_FORMAT.parse(right)!!.time
"posted" -> datePosted = ZonedDateTime.parse(
right,
MetadataUtil.EX_DATE_FORMAT.withZone(ZoneOffset.UTC)
).toInstant().toEpochMilli()
// Example gallery with parent: https://e-hentai.org/g/1390451/7f181c2426/
// Example JP gallery: https://exhentai.org/g/1375385/03519d541b/
// Parent is older variation of the gallery
@@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.ui.base.delegate
import android.app.Activity
import android.content.Intent
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
@@ -149,7 +151,12 @@ class SecureActivityDelegateImpl : SecureActivityDelegate, DefaultLifecycleObser
if (activity.isAuthenticationSupported()) {
if (!SecureActivityDelegate.requireUnlock) return
activity.startActivity(Intent(activity, UnlockActivity::class.java))
activity.overridePendingTransition(0, 0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
activity.overrideActivityTransition(Activity.OVERRIDE_TRANSITION_OPEN, 0, 0)
} else {
@Suppress("DEPRECATION")
activity.overridePendingTransition(0, 0)
}
} else {
securityPreferences.useAuthenticator().set(false)
}
@@ -12,51 +12,10 @@ interface ThemingDelegate {
companion object {
fun getThemeResIds(appTheme: AppTheme, isAmoled: Boolean): List<Int> {
val resIds = mutableListOf<Int>()
when (appTheme) {
AppTheme.MONET -> {
resIds += R.style.Theme_Tachiyomi_Monet
}
AppTheme.GREEN_APPLE -> {
resIds += R.style.Theme_Tachiyomi_GreenApple
}
AppTheme.LAVENDER -> {
resIds += R.style.Theme_Tachiyomi_Lavender
}
AppTheme.MIDNIGHT_DUSK -> {
resIds += R.style.Theme_Tachiyomi_MidnightDusk
}
AppTheme.NORD -> {
resIds += R.style.Theme_Tachiyomi_Nord
}
AppTheme.STRAWBERRY_DAIQUIRI -> {
resIds += R.style.Theme_Tachiyomi_StrawberryDaiquiri
}
AppTheme.TAKO -> {
resIds += R.style.Theme_Tachiyomi_Tako
}
AppTheme.TEALTURQUOISE -> {
resIds += R.style.Theme_Tachiyomi_TealTurquoise
}
AppTheme.YINYANG -> {
resIds += R.style.Theme_Tachiyomi_YinYang
}
AppTheme.YOTSUBA -> {
resIds += R.style.Theme_Tachiyomi_Yotsuba
}
AppTheme.TIDAL_WAVE -> {
resIds += R.style.Theme_Tachiyomi_TidalWave
}
else -> {
resIds += R.style.Theme_Tachiyomi
}
return buildList(2) {
add(themeResources.getOrDefault(appTheme, R.style.Theme_Tachiyomi))
if (isAmoled) add(R.style.ThemeOverlay_Tachiyomi_Amoled)
}
if (isAmoled) {
resIds += R.style.ThemeOverlay_Tachiyomi_Amoled
}
return resIds
}
}
}
@@ -68,3 +27,17 @@ class ThemingDelegateImpl : ThemingDelegate {
.forEach(activity::setTheme)
}
}
private val themeResources: Map<AppTheme, Int> = mapOf(
AppTheme.MONET to R.style.Theme_Tachiyomi_Monet,
AppTheme.GREEN_APPLE to R.style.Theme_Tachiyomi_GreenApple,
AppTheme.LAVENDER to R.style.Theme_Tachiyomi_Lavender,
AppTheme.MIDNIGHT_DUSK to R.style.Theme_Tachiyomi_MidnightDusk,
AppTheme.NORD to R.style.Theme_Tachiyomi_Nord,
AppTheme.STRAWBERRY_DAIQUIRI to R.style.Theme_Tachiyomi_StrawberryDaiquiri,
AppTheme.TAKO to R.style.Theme_Tachiyomi_Tako,
AppTheme.TEALTURQUOISE to R.style.Theme_Tachiyomi_TealTurquoise,
AppTheme.YINYANG to R.style.Theme_Tachiyomi_YinYang,
AppTheme.YOTSUBA to R.style.Theme_Tachiyomi_Yotsuba,
AppTheme.TIDAL_WAVE to R.style.Theme_Tachiyomi_TidalWave,
)
@@ -39,8 +39,10 @@ import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.sourcePreferences
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
import exh.source.EnhancedHttpSource
import exh.ui.ifSourcesLoaded
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.screens.LoadingScreen
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@@ -48,6 +50,11 @@ class SourcePreferencesScreen(val sourceId: Long) : Screen() {
@Composable
override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
@@ -130,7 +137,7 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
// SY -->
val source = Injekt.get<SourceManager>()
.getOrStub(sourceId)
?.let { source ->
.let { source ->
if (source is EnhancedHttpSource) {
if (source.enhancedSource is ConfigurableSource) {
source.source()
@@ -141,7 +148,6 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
source
}
}
?: throw NullPointerException("source = null, SOURCE_ID = $SOURCE_ID")
// SY <--
val sourceScreen = preferenceManager.createPreferenceScreen(requireContext())
@@ -22,10 +22,12 @@ import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterDialog
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
import exh.ui.ifSourcesLoaded
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.core.common.Constants
import tachiyomi.domain.manga.model.Manga
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.source.local.LocalSource
data class SourceSearchScreen(
@@ -36,6 +38,11 @@ data class SourceSearchScreen(
@Composable
override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val uriHandler = LocalUriHandler.current
val navigator = LocalNavigator.currentOrThrow
@@ -56,6 +56,7 @@ import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
import eu.kanade.tachiyomi.util.system.toast
import exh.md.follows.MangaDexFollowsScreen
import exh.ui.ifSourcesLoaded
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
@@ -66,6 +67,7 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.source.local.LocalSource
data class BrowseSourceScreen(
@@ -84,6 +86,11 @@ data class BrowseSourceScreen(
@Composable
override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val screenModel = rememberScreenModel {
BrowseSourceScreenModel(
sourceId = sourceId,
@@ -19,15 +19,22 @@ import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterDialog
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.util.system.toast
import exh.md.follows.MangaDexFollowsScreen
import exh.ui.ifSourcesLoaded
import exh.util.nullIfBlank
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.interactor.GetRemoteManga
import tachiyomi.domain.source.model.SavedSearch
import tachiyomi.presentation.core.screens.LoadingScreen
class SourceFeedScreen(val sourceId: Long) : Screen() {
@Composable
override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val screenModel = rememberScreenModel { SourceFeedScreenModel(sourceId) }
val state by screenModel.state.collectAsState()
val navigator = LocalNavigator.currentOrThrow
@@ -14,6 +14,7 @@ import eu.kanade.presentation.browse.GlobalSearchScreen
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import exh.ui.ifSourcesLoaded
import tachiyomi.presentation.core.screens.LoadingScreen
class GlobalSearchScreen(
@@ -23,6 +24,11 @@ class GlobalSearchScreen(
@Composable
override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel {
@@ -5,7 +5,7 @@ import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.core.util.insertSeparators
import eu.kanade.presentation.history.HistoryUiModel
import eu.kanade.tachiyomi.util.lang.toDateKey
import eu.kanade.tachiyomi.util.lang.toLocalDate
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -30,7 +30,6 @@ import tachiyomi.domain.history.interactor.RemoveHistory
import tachiyomi.domain.history.model.HistoryWithRelations
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
class HistoryScreenModel(
private val getHistory: GetHistory = Injekt.get(),
@@ -62,10 +61,10 @@ class HistoryScreenModel(
private fun List<HistoryWithRelations>.toHistoryUiModels(): List<HistoryUiModel> {
return map { HistoryUiModel.Item(it) }
.insertSeparators { before, after ->
val beforeDate = before?.item?.readAt?.time?.toDateKey() ?: Date(0)
val afterDate = after?.item?.readAt?.time?.toDateKey() ?: Date(0)
val beforeDate = before?.item?.readAt?.time?.toLocalDate()
val afterDate = after?.item?.readAt?.time?.toLocalDate()
when {
beforeDate.time != afterDate.time && afterDate.time != 0L -> HistoryUiModel.Header(afterDate)
beforeDate != afterDate && afterDate != null -> HistoryUiModel.Header(afterDate)
// Return null to avoid adding a separator between two items.
else -> null
}
@@ -750,6 +750,24 @@ class LibraryScreenModel(
clearSelection()
}
}
fun resetInfo() {
state.value.selection.fastForEach { (manga) ->
val mangaInfo = CustomMangaInfo(
id = manga.id,
title = null,
author = null,
artist = null,
thumbnailUrl = null,
description = null,
genre = null,
status = null,
)
setCustomMangaInfo.set(mangaInfo)
}
clearSelection()
}
// SY <--
/**
@@ -1336,6 +1354,18 @@ class LibraryScreenModel(
val showAddToMangadex: Boolean by lazy {
selection.any { it.manga.source in mangaDexSourceIds }
}
val showResetInfo: Boolean by lazy {
selection.fastAny { (manga) ->
manga.title != manga.ogTitle ||
manga.author != manga.ogAuthor ||
manga.artist != manga.ogArtist ||
manga.thumbnailUrl != manga.ogThumbnailUrl ||
manga.description != manga.ogDescription ||
manga.genre != manga.ogGenre ||
manga.status != manga.ogStatus
}
}
// SY <--
fun getLibraryItemsByCategoryId(categoryId: Long): List<LibraryItem>? {
@@ -41,6 +41,7 @@ import eu.kanade.presentation.more.onboarding.GETTING_STARTED_URL
import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen
import eu.kanade.tachiyomi.ui.category.CategoryScreen
@@ -162,6 +163,13 @@ object LibraryTab : Tab {
}
}
},
onClickSyncNow = {
if (!SyncDataJob.isAnyJobRunning(context)) {
SyncDataJob.startNow(context)
} else {
context.toast(MR.strings.sync_in_progress)
}
},
// SY -->
onClickSyncExh = screenModel::openFavoritesSyncDialog.takeIf { state.showSyncExh },
// SY <--
@@ -197,6 +205,7 @@ object LibraryTab : Tab {
}
},
onClickAddToMangaDex = screenModel::syncMangaToDex.takeIf { state.showAddToMangadex },
onClickResetInfo = screenModel::resetInfo.takeIf { state.showResetInfo },
// SY <--
)
},
@@ -20,8 +20,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.view.children
import coil.load
import coil.transform.RoundedCornersTransformation
import coil3.load
import coil3.request.transformations
import coil3.transform.RoundedCornersTransformation
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -200,6 +201,7 @@ private fun onViewCreated(manga: Manga, context: Context, binding: EditMangaDial
binding.mangaGenresTags.clearFocus()
binding.resetTags.setOnClickListener { resetTags(manga, binding, scope) }
binding.resetInfo.setOnClickListener { resetInfo(manga, binding, scope) }
}
private fun resetTags(manga: Manga, binding: EditMangaDialogBinding, scope: CoroutineScope) {
@@ -216,6 +218,15 @@ private fun loadCover(manga: Manga, context: Context, binding: EditMangaDialogBi
}
}
private fun resetInfo(manga: Manga, binding: EditMangaDialogBinding, scope: CoroutineScope) {
binding.title.setText("")
binding.mangaAuthor.setText("")
binding.mangaArtist.setText("")
binding.thumbnailUrl.setText("")
binding.mangaDescription.setText("")
resetTags(manga, binding, scope)
}
private fun ChipGroup.setChips(items: List<String>, scope: CoroutineScope) {
removeAllViews()

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