Compare commits

..

43 Commits

Author SHA1 Message Date
Jobobby04 fb1649125c Actually fix animated images 2024-03-18 09:43:14 -04:00
Jobobby04 0767526f18 Revert "Re-Add Animated Image Decoders to Coil"
This reverts commit 5d1b1408eb.
2024-03-18 09:42:22 -04:00
Jobobby04 5d1b1408eb Re-Add Animated Image Decoders to Coil 2024-03-17 23:17:27 -04:00
Jobobby04 2f54f00bf7 Revert "Minor fix for history url"
This reverts commit 28edaca869.
2024-03-17 20:08:03 -04:00
ɴᴇᴋᴏ 87feb58055 Add files via upload (#1120) 2024-03-17 19:57:33 -04:00
Jobobby04 28edaca869 Minor fix for history url 2024-03-17 19:56:06 -04:00
Jobobby04 d14f012bbb Update firebase 2024-03-17 19:53:23 -04:00
Jobobby04 adc6bbf54f Minor doc fix 2024-03-17 19:53:12 -04:00
Jobobby04 2b064baca1 Update baseline 2024-03-17 19:52:59 -04:00
Jobobby04 983a80ba42 History url is not globally unique 2024-03-17 19:52:38 -04:00
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
146 changed files with 5174 additions and 898 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
uses: gradle/actions/setup-gradle@v3
- name: Build app
run: ./gradlew assembleDevDebug
run: ./gradlew detekt assembleDevDebug
- name: Upload APK
uses: actions/upload-artifact@v3
+8 -1
View File
@@ -40,10 +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
run: ./gradlew 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.67.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
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).
+5 -2
View File
@@ -26,7 +26,7 @@ android {
defaultConfig {
applicationId = "eu.kanade.tachiyomi.sy"
versionCode = 65
versionCode = 66
versionName = "1.10.5"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
@@ -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)
+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
}
@@ -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,
)
@@ -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) {
@@ -24,6 +24,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
@@ -85,13 +86,12 @@ private fun NotDownloadedIndicator(
modifier: Modifier = Modifier,
onClick: (ChapterDownloadAction) -> Unit,
) {
val hapticFeedback = LocalHapticFeedback.current
Box(
modifier = modifier
.size(IconButtonTokens.StateLayerSize)
.commonClickable(
enabled = enabled,
hapticFeedback = hapticFeedback,
hapticFeedback = LocalHapticFeedback.current,
onLongClick = { onClick(ChapterDownloadAction.START_NOW) },
onClick = { onClick(ChapterDownloadAction.START) },
)
@@ -115,14 +115,13 @@ private fun DownloadingIndicator(
onClick: (ChapterDownloadAction) -> Unit,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
var isMenuExpanded by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(IconButtonTokens.StateLayerSize)
.commonClickable(
enabled = enabled,
hapticFeedback = hapticFeedback,
hapticFeedback = LocalHapticFeedback.current,
onLongClick = { onClick(ChapterDownloadAction.CANCEL) },
onClick = { isMenuExpanded = true },
),
@@ -139,6 +138,8 @@ private fun DownloadingIndicator(
modifier = IndicatorModifier,
color = strokeColor,
strokeWidth = IndicatorStrokeWidth,
trackColor = Color.Transparent,
strokeCap = StrokeCap.Butt,
)
} else {
val animatedProgress by animateFloatAsState(
@@ -155,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 }) {
@@ -188,14 +192,13 @@ private fun DownloadedIndicator(
modifier: Modifier = Modifier,
onClick: (ChapterDownloadAction) -> Unit,
) {
val hapticFeedback = LocalHapticFeedback.current
var isMenuExpanded by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(IconButtonTokens.StateLayerSize)
.commonClickable(
enabled = enabled,
hapticFeedback = hapticFeedback,
hapticFeedback = LocalHapticFeedback.current,
onLongClick = { isMenuExpanded = true },
onClick = { isMenuExpanded = true },
),
@@ -225,13 +228,12 @@ private fun ErrorIndicator(
modifier: Modifier = Modifier,
onClick: (ChapterDownloadAction) -> Unit,
) {
val hapticFeedback = LocalHapticFeedback.current
Box(
modifier = modifier
.size(IconButtonTokens.StateLayerSize)
.commonClickable(
enabled = enabled,
hapticFeedback = hapticFeedback,
hapticFeedback = LocalHapticFeedback.current,
onLongClick = { onClick(ChapterDownloadAction.START) },
onClick = { onClick(ChapterDownloadAction.START) },
),
@@ -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(
@@ -30,13 +30,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.platform.LocalDensity
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 tachiyomi.domain.library.service.LibraryPreferences
@@ -143,13 +143,8 @@ fun MangaChapterListItem(
)
}
Row {
ProvideTextStyle(
value = MaterialTheme.typography.bodyMedium.copy(
fontSize = 12.sp,
color = LocalContentColor.current.copy(alpha = textSubtitleAlpha),
),
) {
Row(modifier = Modifier.alpha(textSubtitleAlpha)) {
ProvideTextStyle(value = MaterialTheme.typography.bodySmall) {
if (date != null) {
Text(
text = date,
@@ -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
@@ -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,11 +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(MR.strings.pref_mark_read_dupe_chapters),
subtitle = stringResource(MR.strings.pref_mark_read_dupe_chapters_summary),
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),
@@ -570,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(),
),
),
)
@@ -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(),
)
}
@@ -35,6 +35,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
@@ -46,6 +47,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
@@ -166,6 +168,13 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
/*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(context: Context): ImageLoader {
@@ -197,6 +206,13 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
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) {
@@ -230,6 +246,12 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
} 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
@@ -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 }
@@ -13,7 +13,6 @@ import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.storage.CbzCrypto.addFilesToZip
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
import eu.kanade.tachiyomi.util.storage.saveTo
@@ -46,6 +45,7 @@ import logcat.LogPriority
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
@@ -572,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" }
@@ -21,11 +21,13 @@ 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.model.SManga
@@ -801,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
@@ -814,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
}
@@ -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" } }
}
}
}
}
@@ -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)
@@ -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"
}
@@ -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 {
@@ -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 {
@@ -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 <--
)
},
@@ -201,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) {
@@ -217,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()
@@ -64,6 +64,7 @@ import exh.recs.RecommendsScreen
import exh.source.MERGED_SOURCE_ID
import exh.source.getMainSource
import exh.source.isMdBasedSource
import exh.ui.ifSourcesLoaded
import exh.ui.metadata.MetadataViewScreen
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.launchIn
@@ -98,6 +99,11 @@ class MangaScreen(
@Composable
override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
@@ -427,6 +427,7 @@ private data class TrackDateSelectorScreen(
private val start: Boolean,
) : Screen() {
@Transient
private val selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
val dateToCheck = Instant.ofEpochMilli(utcTimeMillis)
@@ -91,6 +91,7 @@ import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.setComposeContent
import exh.source.isEhBasedSource
import exh.ui.ifSourcesLoaded
import exh.util.defaultReaderType
import exh.util.mangaType
import kotlinx.collections.immutable.persistentSetOf
@@ -391,6 +392,10 @@ class ReaderActivity : BaseActivity() {
)
}
if (!ifSourcesLoaded()) {
return@setComposeContent
}
val isHttpSource = viewModel.getSource() is HttpSource
val isFullscreen by readerPreferences.fullscreen().collectAsState()
val flashOnPageChange by readerPreferences.flashOnPageChange().collectAsState()
@@ -14,6 +14,7 @@ import eu.kanade.domain.chapter.model.toDbChapter
import eu.kanade.domain.manga.interactor.SetMangaViewerFlags
import eu.kanade.domain.manga.model.readerOrientation
import eu.kanade.domain.manga.model.readingMode
import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.domain.track.interactor.TrackChapter
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.domain.ui.UiPreferences
@@ -24,6 +25,7 @@ import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.saver.Image
import eu.kanade.tachiyomi.data.saver.ImageSaver
import eu.kanade.tachiyomi.data.saver.Location
import eu.kanade.tachiyomi.data.sync.SyncDataJob
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
@@ -63,6 +65,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
@@ -124,6 +127,7 @@ class ReaderViewModel @JvmOverloads constructor(
private val upsertHistory: UpsertHistory = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(),
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
private val syncPreferences: SyncPreferences = Injekt.get(),
// SY -->
private val uiPreferences: UiPreferences = Injekt.get(),
private val getFlatMetadataById: GetFlatMetadataById = Injekt.get(),
@@ -336,6 +340,7 @@ class ReaderViewModel @JvmOverloads constructor(
val manga = getManga.await(mangaId)
if (manga != null) {
// SY -->
sourceManager.isInitialized.first { it }
val source = sourceManager.getOrStub(manga.source)
val metadataSource = source.getMainSource<MetadataSource<*, *>>()
val metadata = if (metadataSource != null) {
@@ -384,7 +389,6 @@ class ReaderViewModel @JvmOverloads constructor(
context = context,
downloadManager = downloadManager,
downloadProvider = downloadProvider,
tempFileManager = tempFileManager,
manga = manga,
source = source, /* SY --> */
sourceManager = sourceManager,
@@ -675,6 +679,8 @@ class ReaderViewModel @JvmOverloads constructor(
hasExtraPage: Boolean, /* SY <-- */
) {
val pageIndex = page.index
val syncTriggerOpt = syncPreferences.getSyncTriggerOptions()
val isSyncEnabled = syncPreferences.isSyncEnabled()
mutableState.update {
it.copy(currentPage = pageIndex + 1)
@@ -719,6 +725,11 @@ class ReaderViewModel @JvmOverloads constructor(
// SY <--
updateTrackChapterRead(readerChapter)
deleteChapterIfNeeded(readerChapter)
// Check if syncing is enabled for chapter read:
if (isSyncEnabled && syncTriggerOpt.syncOnChapterRead) {
SyncDataJob.startNow(Injekt.get<Application>())
}
}
updateChapter.await(
@@ -728,6 +739,11 @@ class ReaderViewModel @JvmOverloads constructor(
lastPageRead = readerChapter.chapter.last_page_read.toLong(),
),
)
// Check if syncing is enabled for chapter open:
if (isSyncEnabled && syncTriggerOpt.syncOnChapterOpen && readerChapter.chapter.last_page_read == 0) {
SyncDataJob.startNow(Injekt.get<Application>())
}
}
}
@@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.manga.model.Manga
@@ -28,7 +27,6 @@ class ChapterLoader(
private val context: Context,
private val downloadManager: DownloadManager,
private val downloadProvider: DownloadProvider,
private val tempFileManager: UniFileTempFileManager,
private val manga: Manga,
private val source: Source,
// SY -->
@@ -121,36 +119,39 @@ class ChapterLoader(
source = source,
downloadManager = downloadManager,
downloadProvider = downloadProvider,
tempFileManager = tempFileManager,
)
source is HttpSource -> HttpPageLoader(chapter, source)
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Zip -> ZipPageLoader(tempFileManager.createTempFile(format.file))
is Format.Zip -> ZipPageLoader(format.file, context)
is Format.Rar -> try {
RarPageLoader(tempFileManager.createTempFile(format.file))
RarPageLoader(format.file)
} catch (e: UnsupportedRarV5Exception) {
error(context.stringResource(MR.strings.loader_rar5_error))
}
is Format.Epub -> EpubPageLoader(tempFileManager.createTempFile(format.file))
is Format.Epub -> EpubPageLoader(format.file, context)
}
}
else -> error(context.stringResource(MR.strings.loader_not_implemented_error))
}
}
// SY <--
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider, tempFileManager)
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider)
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Zip -> ZipPageLoader(tempFileManager.createTempFile(format.file))
// SY -->
is Format.Zip -> ZipPageLoader(format.file, context)
is Format.Rar -> try {
RarPageLoader(tempFileManager.createTempFile(format.file))
RarPageLoader(format.file)
// SY <--
} catch (e: UnsupportedRarV5Exception) {
error(context.stringResource(MR.strings.loader_rar5_error))
}
is Format.Epub -> EpubPageLoader(tempFileManager.createTempFile(format.file))
// SY -->
is Format.Epub -> EpubPageLoader(format.file, context)
// SY <--
}
}
source is HttpSource -> HttpPageLoader(chapter, source)
@@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.injectLazy
@@ -23,7 +22,6 @@ internal class DownloadPageLoader(
private val source: Source,
private val downloadManager: DownloadManager,
private val downloadProvider: DownloadProvider,
private val tempFileManager: UniFileTempFileManager,
) : PageLoader() {
private val context: Application by injectLazy()
@@ -48,7 +46,9 @@ internal class DownloadPageLoader(
}
private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> {
val loader = ZipPageLoader(tempFileManager.createTempFile(file)).also { zipPageLoader = it }
// SY -->
val loader = ZipPageLoader(file, context).also { zipPageLoader = it }
// SY <--
return loader.getPages()
}
@@ -1,16 +1,19 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.content.Context
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.storage.EpubFile
import java.io.File
/**
* Loader used to load a chapter from a .epub file.
*/
internal class EpubPageLoader(file: File) : PageLoader() {
// SY -->
internal class EpubPageLoader(file: UniFile, context: Context) : PageLoader() {
private val epub = EpubFile(file)
private val epub = EpubFile(file, context)
// SY <--
override var isLocal: Boolean = true
@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import android.os.Build
import com.github.junrar.Archive
import com.github.junrar.rarfile.FileHeader
import com.hippo.unifile.UniFile
@@ -8,6 +9,14 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.core.common.util.system.ImageUtil
import uy.kohesive.injekt.injectLazy
import java.io.File
@@ -19,11 +28,17 @@ import java.util.concurrent.Executors
/**
* Loader used to load a chapter from a .rar or .cbr file.
*/
internal class RarPageLoader(file: File) : PageLoader() {
private val rar = Archive(file)
internal class RarPageLoader(file: UniFile) : PageLoader() {
// SY -->
private val tempFileManager: UniFileTempFileManager by injectLazy()
private val rar = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
Archive(tempFileManager.createTempFile(file))
} else {
Archive(file.openInputStream())
}
private val context: Application by injectLazy()
private val readerPreferences: ReaderPreferences by injectLazy()
private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also {
@@ -31,20 +46,21 @@ internal class RarPageLoader(file: File) : PageLoader() {
}
init {
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) {
tmpDir.mkdirs()
Archive(file).use { rar ->
rar.fileHeaders.asSequence()
.filterNot { it.isDirectory }
.forEach { header ->
val pageOutputStream = File(tmpDir, header.fileName.substringAfterLast("/"))
.also { it.createNewFile() }
.outputStream()
getStream(header).use {
it.copyTo(pageOutputStream)
rar.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.forEach { header ->
File(tmpDir, header.fileName.substringAfterLast("/"))
.also { it.createNewFile() }
.outputStream()
.use { output ->
rar.getInputStream(header).use { input ->
input.copyTo(output)
}
}
}
}
}
}
}
// SY <--
@@ -58,16 +74,37 @@ internal class RarPageLoader(file: File) : PageLoader() {
override suspend fun getPages(): List<ReaderPage> {
// SY -->
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) {
return DirectoryPageLoader(UniFile.fromFile(tmpDir)!!).getPages()
}
val mutex = Mutex()
// SY <--
return rar.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, header ->
// SY -->
val imageBytesDeferred: Deferred<ByteArray>? =
when (readerPreferences.archiveReaderMode().get()) {
ReaderPreferences.ArchiveReaderMode.LOAD_INTO_MEMORY -> {
CoroutineScope(Dispatchers.IO).async {
mutex.withLock {
getStream(header).buffered().use { stream ->
stream.readBytes()
}
}
}
}
else -> null
}
val imageBytes by lazy { runBlocking { imageBytesDeferred?.await() } }
// SY <--
ReaderPage(i).apply {
stream = { getStream(header) }
// SY -->
stream = { imageBytes?.copyOf()?.inputStream() ?: getStream(header) }
// SY <--
status = Page.State.READY
}
}
@@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import android.content.Context
import android.os.Build
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.Page
@@ -8,107 +8,155 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.apache.commons.compress.archivers.zip.ZipFile
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.core.common.storage.isEncryptedZip
import tachiyomi.core.common.storage.openReadOnlyChannel
import tachiyomi.core.common.storage.testCbzPassword
import tachiyomi.core.common.storage.unzip
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.i18n.sy.SYMR
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile
import java.nio.channels.SeekableByteChannel
import net.lingala.zip4j.ZipFile as Zip4jFile
/**
* Loader used to load a chapter from a .zip or .cbz file.
*/
internal class ZipPageLoader(file: File) : PageLoader() {
internal class ZipPageLoader(file: UniFile, context: Context) : PageLoader() {
// SY -->
private val context: Application by injectLazy()
private val channel: SeekableByteChannel = file.openReadOnlyChannel(context)
private val tempFileManager: UniFileTempFileManager by injectLazy()
private val readerPreferences: ReaderPreferences by injectLazy()
private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also {
it.deleteRecursively()
}
private val zip4j: Zip4jFile = Zip4jFile(file)
private val zip: ZipFile? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (!zip4j.isEncrypted) ZipFile(file, StandardCharsets.ISO_8859_1) else null
private val apacheZip: ZipFile? = if (!file.isEncryptedZip() && Build.VERSION.SDK_INT > Build.VERSION_CODES.N) {
ZipFile(channel)
} else {
null
}
private val tmpFile =
if (
apacheZip == null &&
readerPreferences.archiveReaderMode().get() != ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK
) {
tempFileManager.createTempFile(file)
} else {
if (!zip4j.isEncrypted) ZipFile(file) else null
null
}
private val zip4j =
if (apacheZip == null && tmpFile != null) {
Zip4jFile(tmpFile)
} else {
null
}
init {
Zip4jFile(file).use { zip ->
if (zip.isEncrypted) {
if (!CbzCrypto.checkCbzPassword(zip, CbzCrypto.getDecryptedPasswordCbz())) {
this.recycle()
throw IllegalStateException(context.stringResource(SYMR.strings.wrong_cbz_archive_password))
}
zip4j.setPassword(CbzCrypto.getDecryptedPasswordCbz())
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
unzip()
}
} else {
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
unzip()
}
if (file.isEncryptedZip()) {
if (!file.testCbzPassword()) {
this.recycle()
throw IllegalStateException(context.stringResource(SYMR.strings.wrong_cbz_archive_password))
}
zip4j?.setPassword(CbzCrypto.getDecryptedPasswordCbz())
}
if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) {
file.unzip(tmpDir, onlyCopyImages = true)
}
}
// SY <--
override fun recycle() {
super.recycle()
zip?.close()
apacheZip?.close()
// SY -->
zip4j.close()
zip4j?.close()
tmpDir.deleteRecursively()
}
private fun unzip() {
tmpDir.mkdirs()
zip4j.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip4j.getInputStream(it) } }
.forEach { entry ->
zip4j.extractFile(entry, tmpDir.absolutePath)
}
}
override var isLocal: Boolean = true
override suspend fun getPages(): List<ReaderPage> {
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) {
return DirectoryPageLoader(UniFile.fromFile(tmpDir)!!).getPages()
}
if (zip == null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
zip4j.charset = StandardCharsets.ISO_8859_1
}
return zip4j.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip4j.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, entry ->
ReaderPage(i).apply {
stream = { zip4j.getInputStream(entry) }
status = Page.State.READY
zip4jFile = zip4j
zip4jEntry = entry
}
}.toList()
return if (apacheZip == null) {
loadZip4j()
} else {
// SY <--
return zip.entries().asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->
ReaderPage(i).apply {
stream = { zip.getInputStream(entry) }
status = Page.State.READY
}
}.toList()
loadApacheZip(apacheZip)
}
}
private fun loadZip4j(): List<ReaderPage> {
val mutex = Mutex()
return zip4j!!.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip4j.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, entry ->
val imageBytesDeferred: Deferred<ByteArray>? =
when (readerPreferences.archiveReaderMode().get()) {
ReaderPreferences.ArchiveReaderMode.LOAD_INTO_MEMORY -> {
CoroutineScope(Dispatchers.IO).async {
mutex.withLock {
zip4j.getInputStream(entry).buffered().use { stream ->
stream.readBytes()
}
}
}
}
else -> null
}
val imageBytes by lazy { runBlocking { imageBytesDeferred?.await() } }
ReaderPage(i).apply {
stream = { imageBytes?.copyOf()?.inputStream() ?: zip4j.getInputStream(entry) }
status = Page.State.READY
}
}.toList()
}
private fun loadApacheZip(zip: ZipFile): List<ReaderPage> {
val mutex = Mutex()
return zip.entries.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->
val imageBytesDeferred: Deferred<ByteArray>? =
when (readerPreferences.archiveReaderMode().get()) {
ReaderPreferences.ArchiveReaderMode.LOAD_INTO_MEMORY -> {
CoroutineScope(Dispatchers.IO).async {
mutex.withLock {
zip.getInputStream(entry).buffered().use { stream ->
stream.readBytes()
}
}
}
}
else -> null
}
val imageBytes by lazy { runBlocking { imageBytesDeferred?.await() } }
ReaderPage(i).apply {
stream = { imageBytes?.copyOf()?.inputStream() ?: zip.getInputStream(entry) }
status = Page.State.READY
}
}.toList()
}
// SY <--
/**
* No additional action required to load the page
*/
@@ -1,8 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.model
import eu.kanade.tachiyomi.source.model.Page
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
import java.io.InputStream
open class ReaderPage(
@@ -10,9 +8,6 @@ open class ReaderPage(
url: String = "",
imageUrl: String? = null,
// SY -->
/** zip4j inputStreams do not support mark() and release(), so they must be passed to ImageUtil */
var zip4jFile: ZipFile? = null,
var zip4jEntry: FileHeader? = null,
/** Value to check if this page is used to as if it was too wide */
var shiftedPage: Boolean = false,
/** Value to check if a page is can be doubled up, but can't because the next page is too wide */
@@ -180,9 +180,9 @@ class ReaderPreferences(
fun centerMarginType() = preferenceStore.getInt("center_margin_type", PagerConfig.CenterMarginType.NONE)
fun cacheArchiveMangaOnDisk() = preferenceStore.getBoolean("cache_archive_manga_on_disk", false)
fun archiveReaderMode() = preferenceStore.getInt("archive_reader_mode", ArchiveReaderMode.LOAD_FROM_FILE)
fun markReadDupe() = preferenceStore.getBoolean("mark_read_dupe", false)
fun markReadDupe() = preferenceStore.getBoolean("mark_read_dupe", false)
// SY <--
enum class TappingInvertMode(
@@ -203,6 +203,12 @@ class ReaderPreferences(
LOWEST(47),
}
object ArchiveReaderMode {
const val LOAD_FROM_FILE = 0
const val LOAD_INTO_MEMORY = 1
const val CACHE_TO_DISK = 2
}
companion object {
const val WEBTOON_PADDING_MIN = 0
const val WEBTOON_PADDING_MAX = 25
@@ -264,6 +270,12 @@ class ReaderPreferences(
SYMR.strings.center_margin_wide_page,
SYMR.strings.center_margin_double_and_wide_page,
)
val archiveModeTypes = listOf(
SYMR.strings.archive_mode_load_from_file,
SYMR.strings.archive_mode_load_into_memory,
SYMR.strings.archive_mode_cache_to_disk
)
// SY <--
}
}
@@ -343,8 +343,9 @@ open class ReaderPageImageView @JvmOverloads constructor(
.diskCachePolicy(CachePolicy.DISABLED)
.target(
onSuccess = { result ->
setImageDrawable(result.asDrawable(context.resources))
(result as? Animatable)?.start()
val drawable = result.asDrawable(context.resources)
setImageDrawable(drawable)
(drawable as? Animatable)?.start()
isVisible = true
this@ReaderPageImageView.onImageLoaded()
},
@@ -230,13 +230,7 @@ class PagerPageHolder(
return splitInHalf(imageStream)
}
val isDoublePage = ImageUtil.isWideImage(
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
)
val isDoublePage = ImageUtil.isWideImage(imageStream)
if (!isDoublePage) {
return imageStream
}
@@ -247,13 +241,7 @@ class PagerPageHolder(
}
private fun rotateDualPage(imageStream: BufferedInputStream): InputStream {
val isDoublePage = ImageUtil.isWideImage(
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
)
val isDoublePage = ImageUtil.isWideImage(imageStream)
return if (isDoublePage) {
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageStream, rotation)
@@ -267,13 +255,7 @@ class PagerPageHolder(
if (imageStream2 == null) {
return if (imageStream is BufferedInputStream &&
!ImageUtil.isAnimatedAndSupported(imageStream) &&
ImageUtil.isWideImage(
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
) &&
ImageUtil.isWideImage(imageStream) &&
viewer.config.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN > 0 &&
!viewer.config.imageCropBorders
) {
@@ -224,13 +224,7 @@ class WebtoonPageHolder(
}
if (viewer.config.dualPageSplit) {
val isDoublePage = ImageUtil.isWideImage(
imageStream,
// SY -->
page?.zip4jFile,
page?.zip4jEntry,
// SY <--
)
val isDoublePage = ImageUtil.isWideImage(imageStream)
if (isDoublePage) {
val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
return ImageUtil.splitAndMerge(imageStream, upperSide)
@@ -241,13 +235,7 @@ class WebtoonPageHolder(
}
private fun rotateDualPage(imageStream: BufferedInputStream): InputStream {
val isDoublePage = ImageUtil.isWideImage(
imageStream,
// SY -->
page?.zip4jFile,
page?.zip4jEntry,
// SY <--
)
val isDoublePage = ImageUtil.isWideImage(imageStream)
return if (isDoublePage) {
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageStream, rotation)
@@ -0,0 +1,54 @@
package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class GoogleDriveLoginActivity : BaseOAuthLoginActivity() {
private val googleDriveService = Injekt.get<GoogleDriveService>()
override fun handleResult(data: Uri?) {
val code = data?.getQueryParameter("code")
val error = data?.getQueryParameter("error")
if (code != null) {
lifecycleScope.launchIO {
googleDriveService.handleAuthorizationCode(
code,
this@GoogleDriveLoginActivity,
onSuccess = {
Toast.makeText(
this@GoogleDriveLoginActivity,
stringResource(MR.strings.google_drive_login_success),
Toast.LENGTH_LONG,
).show()
returnToSettings()
},
onFailure = { error ->
Toast.makeText(
this@GoogleDriveLoginActivity,
stringResource(MR.strings.google_drive_login_failed, error),
Toast.LENGTH_LONG,
).show()
returnToSettings()
},
)
}
} else if (error != null) {
Toast.makeText(
this@GoogleDriveLoginActivity,
stringResource(MR.strings.google_drive_login_failed, error),
Toast.LENGTH_LONG,
).show()
returnToSettings()
} else {
returnToSettings()
}
}
}
+29
View File
@@ -1,6 +1,7 @@
package exh
import android.content.Context
import android.widget.Toast
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import eu.kanade.domain.base.BasePreferences
@@ -20,6 +21,7 @@ import eu.kanade.tachiyomi.source.online.all.NHentai
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.workManager
import exh.eh.EHentaiUpdateWorker
import exh.log.xLogE
@@ -654,6 +656,33 @@ object EXHMigrations {
remove(Preference.appStateKey("trusted_signatures"))
}
}
if (oldVersion under 66) {
val cacheImagesToDisk = prefs.getBoolean("cache_archive_manga_on_disk", false)
if (cacheImagesToDisk) {
readerPreferences.archiveReaderMode().set(ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK)
}
}
if (oldVersion under 66) {
if (prefs.getBoolean(Preference.privateKey("encrypt_database"), false)) {
context.toast(
"Restart the app to load your encrypted library",
Toast.LENGTH_LONG
)
}
val appStatePrefsToReplace = listOf(
"__PRIVATE_sql_password",
"__PRIVATE_encrypt_database",
"__PRIVATE_cbz_password",
)
replacePreferences(
preferenceStore = preferenceStore,
filterPredicate = { it.key in appStatePrefsToReplace },
newKey = { Preference.appStateKey(it.replace("__PRIVATE_", "").trim()) },
)
}
// if (oldVersion under 1) { } (1 is current release version)
// do stuff here when releasing changed crap
@@ -254,6 +254,7 @@ class EHentaiUpdateHelper(context: Context) {
scanlator = null,
sourceOrder = -1,
lastModifiedAt = 0,
version = 0,
)
}
}
@@ -23,15 +23,22 @@ import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel
import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import exh.ui.ifSourcesLoaded
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
class MangaDexFollowsScreen(private val sourceId: Long) : Screen() {
@Composable
override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
@@ -15,15 +15,22 @@ import eu.kanade.presentation.browse.BrowseSourceContent
import eu.kanade.presentation.browse.components.BrowseSourceSimpleToolbar
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import exh.ui.ifSourcesLoaded
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
class MangaDexSimilarScreen(val mangaId: Long, val sourceId: Long) : Screen() {
@Composable
override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val screenModel = rememberScreenModel { MangaDexSimilarScreenModel(mangaId, sourceId) }
val state by screenModel.state.collectAsState()
val navigator = LocalNavigator.currentOrThrow
@@ -16,13 +16,20 @@ import eu.kanade.presentation.browse.BrowseSourceContent
import eu.kanade.presentation.browse.components.BrowseSourceSimpleToolbar
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen
import exh.ui.ifSourcesLoaded
import tachiyomi.domain.manga.model.Manga
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.screens.LoadingScreen
class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() {
@Composable
override fun Content() {
if (!ifSourcesLoaded()) {
LoadingScreen()
return
}
val screenModel = rememberScreenModel { RecommendsScreenModel(mangaId, sourceId) }
val state by screenModel.state.collectAsState()
val navigator = LocalNavigator.currentOrThrow
+13
View File
@@ -0,0 +1,13 @@
package exh.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@Composable
fun ifSourcesLoaded(): Boolean {
return remember { Injekt.get<SourceManager>().isInitialized }.collectAsState().value
}
@@ -28,19 +28,22 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.view.setComposeContent
import exh.GalleryAddEvent
import exh.GalleryAdder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import tachiyomi.core.common.Constants
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.Scaffold
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class InterceptActivity : BaseActivity() {
private var statusJob: Job? = null
@@ -108,7 +111,11 @@ class InterceptActivity : BaseActivity() {
private fun processLink() {
if (Intent.ACTION_VIEW == intent.action) {
loadGallery(intent.dataString!!)
lifecycleScope.launchIO {
// wait for sources to load
Injekt.get<SourceManager>().isInitialized.first { it }
loadGallery(intent.dataString!!)
}
}
}
@@ -167,8 +174,7 @@ class InterceptActivity : BaseActivity() {
private val galleryAdder = GalleryAdder()
@Synchronized
fun loadGallery(gallery: String) {
suspend fun loadGallery(gallery: String) {
// Do not load gallery if already loading
if (status.value is InterceptResult.Idle) {
status.value = InterceptResult.Loading
@@ -178,7 +184,9 @@ class InterceptActivity : BaseActivity() {
.setTitle(MR.strings.label_sources.getString(this))
.setSingleChoiceItems(sources.map { it.toString() }.toTypedArray(), 0) { dialog, index ->
dialog.dismiss()
loadGalleryEnd(gallery, sources[index])
lifecycleScope.launchIO {
loadGalleryEnd(gallery, sources[index])
}
}
.show()
} else {
@@ -187,15 +195,12 @@ class InterceptActivity : BaseActivity() {
}
}
private fun loadGalleryEnd(gallery: String, source: UrlImportableSource? = null) {
// Load gallery async
lifecycleScope.launch(Dispatchers.IO) {
val result = galleryAdder.addGallery(this@InterceptActivity, gallery, forceSource = source)
private suspend fun loadGalleryEnd(gallery: String, source: UrlImportableSource? = null) {
val result = galleryAdder.addGallery(this@InterceptActivity, gallery, forceSource = source)
status.value = when (result) {
is GalleryAddEvent.Success -> InterceptResult.Success(result.manga.id, result.manga, result.chapter)
is GalleryAddEvent.Fail -> InterceptResult.Failure(result.logMessage)
}
status.value = when (result) {
is GalleryAddEvent.Success -> InterceptResult.Success(result.manga.id, result.manga, result.chapter)
is GalleryAddEvent.Fail -> InterceptResult.Failure(result.logMessage)
}
}
@@ -139,6 +139,16 @@
android:text="@string/reset_tags"
android:textAllCaps="false" />
<Button
android:id="@+id/reset_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
android:text="@string/reset_info"
android:textAllCaps="false" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
@@ -29,6 +29,18 @@ detekt {
baseline = file(baselineFile)
}
val detektProjectBaseline by tasks.registering(DetektCreateBaselineTask::class) {
description = "Overrides current baseline."
buildUponDefaultConfig.set(true)
ignoreFailures.set(true)
parallel.set(true)
setSource(files(rootDir))
config.setFrom(configFile)
baseline = file(baselineFile)
include(kotlinFiles)
exclude(resourceFiles, buildFiles, generatedFiles, scriptsFiles)
}
tasks.withType<Detekt>().configureEach {
include(kotlinFiles)
exclude(resourceFiles, buildFiles, generatedFiles, scriptsFiles)
+1171 -218
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -36,6 +36,7 @@ dependencies {
implementation(libs.image.decoder)
implementation(libs.unifile)
implementation(libs.bundles.archive)
api(kotlinx.coroutines.core)
api(kotlinx.serialization.json)
@@ -24,9 +24,9 @@ class SecurityPreferences(
fun authenticatorDays() = this.preferenceStore.getInt("biometric_days", 0x7F)
fun encryptDatabase() = this.preferenceStore.getBoolean(Preference.privateKey("encrypt_database"), false)
fun encryptDatabase() = this.preferenceStore.getBoolean(Preference.appStateKey("encrypt_database"), false)
fun sqlPassword() = this.preferenceStore.getString(Preference.privateKey("sql_password"), "")
fun sqlPassword() = this.preferenceStore.getString(Preference.appStateKey("sql_password"), "")
fun passwordProtectDownloads() = preferenceStore.getBoolean(
Preference.privateKey("password_protect_downloads"),
@@ -35,7 +35,7 @@ class SecurityPreferences(
fun encryptionType() = this.preferenceStore.getEnum("encryption_type", EncryptionType.AES_256)
fun cbzPassword() = this.preferenceStore.getString(Preference.privateKey("cbz_password"), "")
fun cbzPassword() = this.preferenceStore.getString(Preference.appStateKey("cbz_password"), "")
// SY <--
/**
@@ -19,7 +19,7 @@ class NetworkPreferences(
fun defaultUserAgent(): Preference<String> {
return preferenceStore.getString(
"default_user_agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
)
}
}
@@ -63,6 +63,19 @@ fun PUT(
.cacheControl(cache)
.build()
}
fun PATCH(
url: String,
headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
.patch(body)
.headers(headers)
.cacheControl(cache)
.build()
}
fun DELETE(
url: String,
@@ -3,25 +3,15 @@ package eu.kanade.tachiyomi.util.storage
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.exception.ZipException
import net.lingala.zip4j.io.inputstream.ZipInputStream
import net.lingala.zip4j.io.outputstream.ZipOutputStream
import net.lingala.zip4j.model.LocalFileHeader
import net.lingala.zip4j.model.ZipParameters
import net.lingala.zip4j.model.enums.AesKeyStrength
import net.lingala.zip4j.model.enums.EncryptionMethod
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
@@ -136,12 +126,15 @@ object CbzCrypto {
}
fun getDecryptedPasswordCbz(): CharArray {
return decrypt(securityPreferences.cbzPassword().get(), ALIAS_CBZ).toCharArray()
val encryptedPassword = securityPreferences.cbzPassword().get()
if (encryptedPassword.isBlank()) error("This archive is encrypted please set a password")
return decrypt(encryptedPassword, ALIAS_CBZ).toCharArray()
}
private fun generateAndEncryptSqlPw() {
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
val password = (1..32).map {
val password = (1..SQL_PASSWORD_LENGTH).map {
charPool[SecureRandom().nextInt(charPool.size)]
}.joinToString("", transform = { it.toString() })
securityPreferences.sqlPassword().set(encrypt(password, encryptionCipherSql))
@@ -152,27 +145,6 @@ object CbzCrypto {
return decrypt(securityPreferences.sqlPassword().get(), ALIAS_SQL).toByteArray()
}
/**
* Function that returns true when the supplied password
* can Successfully decrypt the supplied zip archive
* not very elegant but this is the solution recommended by the maintainer for checking passwords
* a real password check will likely be implemented in the future though
*/
fun checkCbzPassword(zip4j: ZipFile, password: CharArray): Boolean {
try {
zip4j.setPassword(password)
zip4j.use { zip ->
zip.getInputStream(zip.fileHeaders.firstOrNull())
}
return true
} catch (e: Exception) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${zip4j.file.name} in: ${zip4j.file.parentFile?.name}"
}
}
return false
}
fun isPasswordSet(): Boolean {
return securityPreferences.cbzPassword().get().isNotEmpty()
}
@@ -228,133 +200,6 @@ object CbzCrypto {
}
return String(bytes).contains(DEFAULT_COVER_NAME, ignoreCase = true)
}
fun UniFile.isEncryptedZip(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.nextEntry
stream.close()
false
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
true
} else throw zipException
}
}
fun UniFile.testCbzPassword(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.setPassword(getDecryptedPasswordCbz())
stream.nextEntry
stream.close()
true
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
false
} else throw zipException
}
}
fun UniFile.addStreamToZip(inputStream: InputStream, filename: String, password: CharArray? = null) {
val zipOutputStream =
if (password != null) ZipOutputStream(this.openOutputStream(), password)
else ZipOutputStream(this.openOutputStream())
val zipParameters = ZipParameters()
zipParameters.fileNameInZip = filename
if (password != null) setZipParametersEncrypted(zipParameters)
zipOutputStream.putNextEntry(zipParameters)
zipOutputStream.use { output ->
inputStream.use { input ->
input.copyTo(output)
}
}
}
fun UniFile.addFilesToZip(files: List<UniFile>, password: CharArray? = null) {
val zipOutputStream =
if (password != null) ZipOutputStream(this.openOutputStream(), password)
else ZipOutputStream(this.openOutputStream())
files.forEach {
val zipParameters = ZipParameters()
if (password != null) setZipParametersEncrypted(zipParameters)
zipParameters.fileNameInZip = it.name
zipOutputStream.putNextEntry(zipParameters)
it.openInputStream().use { input ->
input.copyTo(zipOutputStream)
}
zipOutputStream.closeEntry()
}
zipOutputStream.close()
}
fun UniFile.getZipInputStream(filename: String): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
if (this.isEncryptedZip()) zipInputStream.setPassword(getDecryptedPasswordCbz())
try {
while (run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}) {
if (fileHeader?.fileName == filename) return zipInputStream
}
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
} else throw zipException
}
return null
}
fun UniFile.getCoverStreamFromZip(): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
val fileHeaderList: MutableList<LocalFileHeader?> = mutableListOf()
if (this.isEncryptedZip()) zipInputStream.setPassword(getDecryptedPasswordCbz())
try {
while (run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}) {
fileHeaderList.add(fileHeader)
}
var coverHeader = fileHeaderList
.mapNotNull { it }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) }
val coverStream = coverHeader?.fileName?.let { this.getZipInputStream(it) }
if (coverStream != null) {
if (!ImageUtil.isImage(coverHeader?.fileName) { coverStream }) coverHeader = null
}
return coverHeader?.fileName?.let { getZipInputStream(it) }
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
return null
} else throw zipException
}
}
}
private const val BUFFER_SIZE = 2048
@@ -369,4 +214,6 @@ private const val CRYPTO_SETTINGS = "$ALGORITHM/$BLOCK_MODE/$PADDING"
private const val KEYSTORE = "AndroidKeyStore"
private const val ALIAS_CBZ = "cbzPw"
private const val ALIAS_SQL = "sqlPw"
private const val SQL_PASSWORD_LENGTH = 32
// SY <--
@@ -1,22 +1,36 @@
package eu.kanade.tachiyomi.util.storage
import android.content.Context
import android.os.Build
import com.hippo.unifile.UniFile
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipFile
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.core.common.storage.openReadOnlyChannel
import uy.kohesive.injekt.injectLazy
import java.io.Closeable
import java.io.File
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Wrapper over ZipFile to load files in epub format.
*/
class EpubFile(file: File) : Closeable {
// SY -->
class EpubFile(file: UniFile, context: Context) : Closeable {
private val tempFileManager: UniFileTempFileManager by injectLazy()
/**
* Zip file of this epub.
*/
private val zip = ZipFile(file)
private val zip = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
ZipFile(tempFileManager.createTempFile(file))
} else {
ZipFile(file.openReadOnlyChannel(context))
}
// SY <--
/**
* Path separator used by this epub.
@@ -33,14 +47,14 @@ class EpubFile(file: File) : Closeable {
/**
* Returns an input stream for reading the contents of the specified zip file entry.
*/
fun getInputStream(entry: ZipEntry): InputStream {
fun getInputStream(entry: ZipArchiveEntry): InputStream {
return zip.getInputStream(entry)
}
/**
* Returns the zip file entry for the specified name, or null if not found.
*/
fun getEntry(name: String): ZipEntry? {
fun getEntry(name: String): ZipArchiveEntry? {
return zip.getEntry(name)
}
@@ -1,6 +1,21 @@
package tachiyomi.core.common.storage
import android.content.Context
import android.os.ParcelFileDescriptor
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import logcat.LogPriority
import net.lingala.zip4j.exception.ZipException
import net.lingala.zip4j.io.inputstream.ZipInputStream
import net.lingala.zip4j.io.outputstream.ZipOutputStream
import net.lingala.zip4j.model.LocalFileHeader
import net.lingala.zip4j.model.ZipParameters
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import java.io.File
import java.io.InputStream
import java.nio.channels.FileChannel
val UniFile.extension: String?
get() = name?.substringAfterLast('.')
@@ -10,3 +25,201 @@ val UniFile.nameWithoutExtension: String?
val UniFile.displayablePath: String
get() = filePath ?: uri.toString()
fun UniFile.openReadOnlyChannel(context: Context): FileChannel {
return ParcelFileDescriptor.AutoCloseInputStream(context.contentResolver.openFileDescriptor(uri, "r")).channel
// SY -->
}
fun UniFile.isEncryptedZip(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.nextEntry
stream.close()
false
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
true
} else {
throw zipException
}
}
}
fun UniFile.testCbzPassword(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
stream.nextEntry
stream.close()
true
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
false
} else {
throw zipException
}
}
}
fun UniFile.addStreamToZip(inputStream: InputStream, filename: String, password: CharArray? = null) {
val zipOutputStream =
if (password != null) {
ZipOutputStream(this.openOutputStream(), password)
} else {
ZipOutputStream(this.openOutputStream())
}
val zipParameters = ZipParameters()
zipParameters.fileNameInZip = filename
if (password != null) CbzCrypto.setZipParametersEncrypted(zipParameters)
zipOutputStream.putNextEntry(zipParameters)
zipOutputStream.use { output ->
inputStream.use { input ->
input.copyTo(output)
}
}
}
/**
* Unzips encrypted or unencrypted zip files using zip4j.
* The caller is responsible to ensure, that the file this is called from is a zip archive
*/
fun UniFile.unzip(destination: File, onlyCopyImages: Boolean = false) {
destination.mkdirs()
if (!destination.isDirectory) return
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
if (this.isEncryptedZip()) {
zipInputStream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
}
try {
while (
run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}
) {
val tmpFile = File("${destination.absolutePath}/${fileHeader!!.fileName}")
if (onlyCopyImages) {
if (!fileHeader!!.isDirectory && ImageUtil.isImage(fileHeader!!.fileName)) {
tmpFile.createNewFile()
tmpFile.outputStream().buffered().use { tmpOut ->
zipInputStream.buffered().copyTo(tmpOut)
}
}
} else {
if (!fileHeader!!.isDirectory && ImageUtil.isImage(fileHeader!!.fileName)) {
tmpFile.createNewFile()
tmpFile
.outputStream()
.buffered()
.use { zipInputStream.buffered().copyTo(it) }
}
}
}
zipInputStream.close()
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
} else {
throw zipException
}
}
}
fun UniFile.addFilesToZip(files: List<UniFile>, password: CharArray? = null) {
val zipOutputStream =
if (password != null) {
ZipOutputStream(this.openOutputStream(), password)
} else {
ZipOutputStream(this.openOutputStream())
}
files.forEach {
val zipParameters = ZipParameters()
if (password != null) CbzCrypto.setZipParametersEncrypted(zipParameters)
zipParameters.fileNameInZip = it.name
zipOutputStream.putNextEntry(zipParameters)
it.openInputStream().use { input ->
input.copyTo(zipOutputStream)
}
zipOutputStream.closeEntry()
}
zipOutputStream.close()
}
fun UniFile.getZipInputStream(filename: String): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
if (this.isEncryptedZip()) zipInputStream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
try {
while (
run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}
) {
if (fileHeader?.fileName == filename) return zipInputStream
}
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
} else {
throw zipException
}
}
return null
}
fun UniFile.getCoverStreamFromZip(): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
val fileHeaderList: MutableList<LocalFileHeader?> = mutableListOf()
if (this.isEncryptedZip()) zipInputStream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
try {
while (
run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}
) {
fileHeaderList.add(fileHeader)
}
var coverHeader = fileHeaderList
.mapNotNull { it }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) }
val coverStream = coverHeader?.fileName?.let { this.getZipInputStream(it) }
if (coverStream != null) {
if (!ImageUtil.isImage(coverHeader?.fileName) { coverStream }) coverHeader = null
}
return coverHeader?.fileName?.let { getZipInputStream(it) }
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
return null
} else {
throw zipException
}
}
}
// SY <--
@@ -26,8 +26,6 @@ import androidx.core.graphics.red
import androidx.exifinterface.media.ExifInterface
import com.hippo.unifile.UniFile
import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
import tachiyomi.decoder.Format
import tachiyomi.decoder.ImageDecoder
import java.io.BufferedInputStream
@@ -93,6 +91,9 @@ object ImageUtil {
// Coil supports animated WebP on Android 9.0+
// https://coil-kt.github.io/coil/getting_started/#supported-image-formats
Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
// Coil supports animated Heif on Android 11+
// https://coil-kt.github.io/coil/getting_started/#supported-image-formats
Format.Heif -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
else -> false
}
} catch (e: Exception) {
@@ -133,20 +134,8 @@ object ImageUtil {
*
* @return true if the width is greater than the height
*/
fun isWideImage(
imageStream: BufferedInputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
fun isWideImage(imageStream: BufferedInputStream): Boolean {
val options = extractImageOptions(imageStream)
return options.outWidth > options.outHeight
}
@@ -271,19 +260,9 @@ object ImageUtil {
*
* @return true if the height:width ratio is greater than 3.
*/
private fun isTallImage(
imageStream: InputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
private fun isTallImage(imageStream: InputStream): Boolean {
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false,
)
@@ -297,18 +276,9 @@ object ImageUtil {
tmpDir: UniFile,
imageFile: UniFile,
filenamePrefix: String,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
if (isAnimatedAndSupported(imageFile.openInputStream()) ||
!isTallImage(imageFile.openInputStream())
) {
return true
}
@@ -321,10 +291,6 @@ object ImageUtil {
val options = extractImageOptions(
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false,
).apply {
inJustDecodeBounds = false
@@ -641,18 +607,9 @@ object ImageUtil {
*/
private fun extractImageOptions(
imageStream: InputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
resetAfterExtraction: Boolean = true,
): BitmapFactory.Options {
// SY -->
// zip4j does currently not support mark() and reset()
if (zip4jFile != null && zip4jEntry != null) return extractImageOptionsZip4j(zip4jFile, zip4jEntry)
// SY <--
imageStream.mark(imageStream.available() + 1)
imageStream.mark(Int.MAX_VALUE)
val imageBytes = imageStream.readBytes()
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
@@ -661,16 +618,6 @@ object ImageUtil {
return options
}
// SY -->
private fun extractImageOptionsZip4j(zip4jFile: ZipFile?, zip4jEntry: FileHeader?): BitmapFactory.Options {
zip4jFile?.getInputStream(zip4jEntry).use { imageStream ->
val imageBytes = imageStream?.readBytes()
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
imageBytes?.size?.let { BitmapFactory.decodeByteArray(imageBytes, 0, it, options) }
return options
}
}
/**
* Creates random exif metadata used as padding to make
* the size of files inside CBZ archives unique
@@ -7,12 +7,22 @@ import logcat.logcat
inline fun Any.logcat(
priority: LogPriority = LogPriority.DEBUG,
throwable: Throwable? = null,
tag: String? = null,
message: () -> String = { "" },
) = logcat(priority = priority) {
var msg = message()
if (throwable != null) {
if (msg.isNotBlank()) msg += "\n"
msg += throwable.asLog()
val logMessage = StringBuilder()
if (!tag.isNullOrEmpty()) {
logMessage.append("[$tag] ")
}
msg
val msg = message()
logMessage.append(msg)
if (throwable != null) {
if (msg.isNotBlank()) logMessage.append("\n")
logMessage.append(throwable.asLog())
}
logMessage.toString()
}
@@ -32,13 +32,15 @@ private val mapper = { cursor: SqlCursor ->
calculate_interval = cursor.getLong(20)!!,
last_modified_at = cursor.getLong(21)!!,
favorite_modified_at = cursor.getLong(22),
totalCount = cursor.getLong(23)!!,
readCount = cursor.getDouble(24)!!,
latestUpload = cursor.getLong(25)!!,
chapterFetchedAt = cursor.getLong(26)!!,
lastRead = cursor.getLong(27)!!,
bookmarkCount = cursor.getDouble(28)!!,
category = cursor.getLong(29)!!,
version = cursor.getLong(23)!!,
is_syncing = cursor.getLong(24)!!,
totalCount = cursor.getLong(25)!!,
readCount = cursor.getDouble(26)!!,
latestUpload = cursor.getLong(27)!!,
chapterFetchedAt = cursor.getLong(28)!!,
lastRead = cursor.getLong(29)!!,
bookmarkCount = cursor.getDouble(30)!!,
category = cursor.getLong(31)!!,
)
}
@@ -17,6 +17,9 @@ object ChapterMapper {
dateFetch: Long,
dateUpload: Long,
lastModifiedAt: Long,
version: Long,
@Suppress("UNUSED_PARAMETER")
isSyncing: Long,
): Chapter = Chapter(
id = id,
mangaId = mangaId,
@@ -31,5 +34,6 @@ object ChapterMapper {
chapterNumber = chapterNumber,
scanlator = scanlator,
lastModifiedAt = lastModifiedAt,
version = version
)
}
@@ -29,6 +29,7 @@ class ChapterRepositoryImpl(
chapter.sourceOrder,
chapter.dateFetch,
chapter.dateUpload,
chapter.version,
)
val lastInsertId = chaptersQueries.selectLastInsertedRowId().executeAsOne()
chapter.copy(id = lastInsertId)
@@ -64,6 +65,8 @@ class ChapterRepositoryImpl(
dateFetch = chapterUpdate.dateFetch,
dateUpload = chapterUpdate.dateUpload,
chapterId = chapterUpdate.id,
version = chapterUpdate.version,
isSyncing = 0,
)
}
}
@@ -6,6 +6,7 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.view.LibraryView
object MangaMapper {
@Suppress("LongParameterList")
fun mapManga(
id: Long,
source: Long,
@@ -33,6 +34,9 @@ object MangaMapper {
calculateInterval: Long,
lastModifiedAt: Long,
favoriteModifiedAt: Long?,
version: Long,
@Suppress("UNUSED_PARAMETER")
isSyncing: Long,
): Manga = Manga(
id = id,
source = source,
@@ -58,8 +62,10 @@ object MangaMapper {
initialized = initialized,
lastModifiedAt = lastModifiedAt,
favoriteModifiedAt = favoriteModifiedAt,
version = version,
)
@Suppress("LongParameterList")
fun mapLibraryManga(
id: Long,
source: Long,
@@ -87,6 +93,8 @@ object MangaMapper {
calculateInterval: Long,
lastModifiedAt: Long,
favoriteModifiedAt: Long?,
version: Long,
isSyncing: Long,
totalCount: Long,
readCount: Double,
latestUpload: Long,
@@ -121,6 +129,8 @@ object MangaMapper {
calculateInterval,
lastModifiedAt,
favoriteModifiedAt,
version,
isSyncing,
),
category = category,
totalChapters = totalCount,
@@ -156,6 +166,7 @@ object MangaMapper {
fetchInterval = libraryView.calculate_interval.toInt(),
lastModifiedAt = libraryView.last_modified_at,
favoriteModifiedAt = libraryView.favorite_modified_at,
version = libraryView.version,
),
category = libraryView.category,
totalChapters = libraryView.totalCount,
@@ -119,6 +119,7 @@ class MangaRepositoryImpl(
coverLastModified = manga.coverLastModified,
dateAdded = manga.dateAdded,
updateStrategy = manga.updateStrategy,
version = manga.version,
)
mangasQueries.selectLastInsertedRowId()
}
@@ -168,6 +169,8 @@ class MangaRepositoryImpl(
dateAdded = value.dateAdded,
mangaId = value.id,
updateStrategy = value.updateStrategy?.let(UpdateStrategyColumnAdapter::encode),
version = value.version,
isSyncing = 0,
)
}
}
@@ -14,6 +14,8 @@ CREATE TABLE chapters(
date_fetch INTEGER NOT NULL,
date_upload INTEGER NOT NULL,
last_modified_at INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 0,
is_syncing INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE
);
@@ -30,6 +32,22 @@ BEGIN
WHERE _id = new._id;
END;
CREATE TRIGGER update_chapter_and_manga_version AFTER UPDATE ON chapters
WHEN new.is_syncing = 0 AND (
new.read != old.read OR
new.bookmark != old.bookmark OR
new.last_page_read != old.last_page_read
)
BEGIN
-- Update the chapter version
UPDATE chapters SET version = version + 1
WHERE _id = new._id;
-- Update the manga version
UPDATE mangas SET version = version + 1
WHERE _id = new.manga_id AND (SELECT is_syncing FROM mangas WHERE _id = new.manga_id) = 0;
END;
getChapterById:
SELECT *
FROM chapters
@@ -97,9 +115,14 @@ removeChaptersWithIds:
DELETE FROM chapters
WHERE _id IN :chapterIds;
resetIsSyncing:
UPDATE chapters
SET is_syncing = 0
WHERE is_syncing = 1;
insert:
INSERT INTO chapters(manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch, date_upload, last_modified_at)
VALUES (:mangaId, :url, :name, :scanlator, :read, :bookmark, :lastPageRead, :chapterNumber, :sourceOrder, :dateFetch, :dateUpload, 0);
INSERT INTO chapters(manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch, date_upload, last_modified_at, version, is_syncing)
VALUES (:mangaId, :url, :name, :scanlator, :read, :bookmark, :lastPageRead, :chapterNumber, :sourceOrder, :dateFetch, :dateUpload, 0, :version, 0);
update:
UPDATE chapters
@@ -113,7 +136,9 @@ SET manga_id = coalesce(:mangaId, manga_id),
chapter_number = coalesce(:chapterNumber, chapter_number),
source_order = coalesce(:sourceOrder, source_order),
date_fetch = coalesce(:dateFetch, date_fetch),
date_upload = coalesce(:dateUpload, date_upload)
date_upload = coalesce(:dateUpload, date_upload),
version = coalesce(:version, version),
is_syncing = coalesce(:isSyncing, is_syncing)
WHERE _id = :chapterId;
selectLastInsertedRowId:
@@ -31,7 +31,7 @@ H.time_read
FROM history H
JOIN chapters C
ON H.chapter_id = C._id
WHERE C.url = :chapterUrl AND C._id = H.chapter_id;
WHERE C.manga_id = :mangaId AND C.url = :chapterUrl AND C._id = H.chapter_id;
resetHistoryById:
UPDATE history
@@ -26,7 +26,9 @@ CREATE TABLE mangas(
update_strategy INTEGER AS UpdateStrategy NOT NULL DEFAULT 0,
calculate_interval INTEGER DEFAULT 0 NOT NULL,
last_modified_at INTEGER NOT NULL DEFAULT 0,
favorite_modified_at INTEGER
favorite_modified_at INTEGER,
version INTEGER NOT NULL DEFAULT 0,
is_syncing INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX library_favorite_index ON mangas(favorite) WHERE favorite = 1;
@@ -49,6 +51,16 @@ BEGIN
WHERE _id = new._id;
END;
CREATE TRIGGER update_manga_version AFTER UPDATE ON mangas
BEGIN
UPDATE mangas SET version = version + 1
WHERE _id = new._id AND new.is_syncing = 0 AND (
new.url != old.url OR
new.description != old.description OR
new.favorite != old.favorite
);
END;
getMangaById:
SELECT *
FROM mangas
@@ -112,6 +124,11 @@ resetViewerFlags:
UPDATE mangas
SET viewer = 0;
resetIsSyncing:
UPDATE mangas
SET is_syncing = 0
WHERE is_syncing = 1;
getSourceIdsWithNonLibraryManga:
SELECT source, COUNT(*) AS manga_count
FROM mangas
@@ -135,8 +152,8 @@ WHERE favorite = 0 AND source IN :sourceIdsAND AND _id NOT IN (
);
insert:
INSERT INTO mangas(source, url, artist, author, description, genre, title, status, thumbnail_url, favorite, last_update, next_update, initialized, viewer, chapter_flags, cover_last_modified, date_added, update_strategy, calculate_interval, last_modified_at)
VALUES (:source, :url, :artist, :author, :description, :genre, :title, :status, :thumbnailUrl, :favorite, :lastUpdate, :nextUpdate, :initialized, :viewerFlags, :chapterFlags, :coverLastModified, :dateAdded, :updateStrategy, :calculateInterval, 0);
INSERT INTO mangas(source, url, artist, author, description, genre, title, status, thumbnail_url, favorite, last_update, next_update, initialized, viewer, chapter_flags, cover_last_modified, date_added, update_strategy, calculate_interval, last_modified_at, version)
VALUES (:source, :url, :artist, :author, :description, :genre, :title, :status, :thumbnailUrl, :favorite, :lastUpdate, :nextUpdate, :initialized, :viewerFlags, :chapterFlags, :coverLastModified, :dateAdded, :updateStrategy, :calculateInterval, 0, :version);
update:
UPDATE mangas SET
@@ -158,7 +175,9 @@ UPDATE mangas SET
cover_last_modified = coalesce(:coverLastModified, cover_last_modified),
date_added = coalesce(:dateAdded, date_added),
update_strategy = coalesce(:updateStrategy, update_strategy),
calculate_interval = coalesce(:calculateInterval, calculate_interval)
calculate_interval = coalesce(:calculateInterval, calculate_interval),
version = coalesce(:version, version),
is_syncing = coalesce(:isSyncing, is_syncing)
WHERE _id = :mangaId;
selectLastInsertedRowId:
@@ -2,25 +2,22 @@ CREATE TABLE mangas_categories(
_id INTEGER NOT NULL PRIMARY KEY,
manga_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
last_modified_at INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(category_id) REFERENCES categories (_id)
ON DELETE CASCADE,
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE
);
CREATE TRIGGER update_last_modified_at_mangas_categories
AFTER UPDATE ON mangas_categories
FOR EACH ROW
CREATE TRIGGER insert_manga_category_update_version AFTER INSERT ON mangas_categories
BEGIN
UPDATE mangas_categories
SET last_modified_at = strftime('%s', 'now')
WHERE _id = new._id;
UPDATE mangas
SET version = version + 1
WHERE _id = new.manga_id AND (SELECT is_syncing FROM mangas WHERE _id = new.manga_id) = 0;
END;
insert:
INSERT INTO mangas_categories(manga_id, category_id, last_modified_at)
VALUES (:mangaId, :categoryId, 0);
INSERT INTO mangas_categories(manga_id, category_id)
VALUES (:mangaId, :categoryId);
deleteMangaCategoryByMangaId:
DELETE FROM mangas_categories
@@ -0,0 +1,46 @@
-- Mangas table
ALTER TABLE mangas ADD COLUMN version INTEGER NOT NULL DEFAULT 0;
ALTER TABLE mangas ADD COLUMN is_syncing INTEGER NOT NULL DEFAULT 0;
-- Chapters table
ALTER TABLE chapters ADD COLUMN version INTEGER NOT NULL DEFAULT 0;
ALTER TABLE chapters ADD COLUMN is_syncing INTEGER NOT NULL DEFAULT 0;
-- Mangas triggers
DROP TRIGGER IF EXISTS update_manga_version;
CREATE TRIGGER update_manga_version AFTER UPDATE ON mangas
BEGIN
UPDATE mangas SET version = version + 1
WHERE _id = new._id AND new.is_syncing = 0 AND (
new.url != old.url OR
new.description != old.description OR
new.favorite != old.favorite
);
END;
-- Chapters triggers
DROP TRIGGER IF EXISTS update_chapter_and_manga_version;
CREATE TRIGGER update_chapter_and_manga_version AFTER UPDATE ON chapters
WHEN new.is_syncing = 0 AND (
new.read != old.read OR
new.bookmark != old.bookmark OR
new.last_page_read != old.last_page_read
)
BEGIN
-- Update the chapter version
UPDATE chapters SET version = version + 1
WHERE _id = new._id;
-- Update the manga version
UPDATE mangas SET version = version + 1
WHERE _id = new.manga_id AND (SELECT is_syncing FROM mangas WHERE _id = new.manga_id) = 0;
END;
-- manga_categories table
DROP TRIGGER IF EXISTS insert_manga_category_update_version;
CREATE TRIGGER insert_manga_category_update_version AFTER INSERT ON mangas_categories
BEGIN
UPDATE mangas
SET version = version + 1
WHERE _id = new.manga_id AND (SELECT is_syncing FROM mangas WHERE _id = new.manga_id) = 0;
END;

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