Compare commits
100 Commits
1.10.4
...
preview-131
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d1b1408eb | |||
| 2f54f00bf7 | |||
| 87feb58055 | |||
| 28edaca869 | |||
| d14f012bbb | |||
| adc6bbf54f | |||
| 2b064baca1 | |||
| 983a80ba42 | |||
| 911e959fcf | |||
| a425cae73b | |||
| d12a9d329b | |||
| 9018757496 | |||
| b0d91fa83f | |||
| 1caa929aa0 | |||
| 04e5be12e1 | |||
| 1136644a57 | |||
| d70258b956 | |||
| 54cb379a50 | |||
| 0e959c4594 | |||
| 6719f22eff | |||
| 45711cd394 | |||
| 334e9fb680 | |||
| 6e0bc981a6 | |||
| b7e55bc9f8 | |||
| a069e577ba | |||
| 0eb622643b | |||
| d93d0eea89 | |||
| 82846205b2 | |||
| 4a4fecb1e8 | |||
| ee6bc20f27 | |||
| 446a5cd5b3 | |||
| cdb07c893b | |||
| a4d88515fb | |||
| 345d0821c6 | |||
| a9fd1f8811 | |||
| 31e5ba4caf | |||
| 202900edf0 | |||
| f79959c7bc | |||
| 237d8d6b33 | |||
| 117e0d5792 | |||
| 64bbe941a4 | |||
| dcd44c42ed | |||
| 6c563d7619 | |||
| 97f22c500b | |||
| 9cbeccfa15 | |||
| 86722a31d0 | |||
| 589b33a673 | |||
| dae0348710 | |||
| a7f6155627 | |||
| 7f5bc4a3e5 | |||
| ec32278f1a | |||
| c02c5aa915 | |||
| f267f2ad5b | |||
| 03bc09c1aa | |||
| c4df418081 | |||
| a9c79d5fb3 | |||
| 529100a947 | |||
| 062f6d5aa0 | |||
| deb0a95985 | |||
| ca70f80900 | |||
| 23e3ec20b6 | |||
| 32d97ed194 | |||
| 167a4e9820 | |||
| aef0b50663 | |||
| 5b5e6c8f44 | |||
| 5dc96384bd | |||
| affdea3ec2 | |||
| 1436d86c7e | |||
| b7c9eaa981 | |||
| db99ab526a | |||
| 133c34dee2 | |||
| 775cf258ba | |||
| ed34807a58 | |||
| 31acbbdcdc | |||
| ef7708e324 | |||
| de353c3334 | |||
| b47a317c48 | |||
| 2b163c91a9 | |||
| d380a078a2 | |||
| 556afacd13 | |||
| 598d622d0b | |||
| 3a1d0d65bf | |||
| cc7b8a9b69 | |||
| 6c6f09ac5a | |||
| 1fe309f363 | |||
| 3417fdb1a4 | |||
| a6394672e7 | |||
| 1fc97e4b7a | |||
| fe853aa1c5 | |||
| 9c3f805eab | |||
| 410eda6d6c | |||
| 719c24fb38 | |||
| a7cb182bbe | |||
| dbb970d7b5 | |||
| 887a27cf3e | |||
| eed8ffb9d4 | |||
| dd412e33ad | |||
| 94e5c33785 | |||
| 6f3f109723 | |||
| d8082de1db |
@@ -53,7 +53,7 @@ body:
|
|||||||
label: TachiyomiSY version
|
label: TachiyomiSY version
|
||||||
description: You can find your TachiyomiSY version in **More → About**.
|
description: You can find your TachiyomiSY version in **More → About**.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Example: "1.10.4"
|
Example: "1.10.5"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https:/mihon.app/docs/guides/troubleshooting/).
|
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https:/mihon.app/docs/guides/troubleshooting/).
|
||||||
required: true
|
required: true
|
||||||
- label: I have updated the app to version **[1.10.4](https://github.com/jobobby04/tachiyomisy/releases/latest)**.
|
- label: I have updated the app to version **[1.10.5](https://github.com/jobobby04/tachiyomisy/releases/latest)**.
|
||||||
required: true
|
required: true
|
||||||
- label: I have updated all installed extensions.
|
- label: I have updated all installed extensions.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I have written a short but informative title.
|
- label: I have written a short but informative title.
|
||||||
required: true
|
required: true
|
||||||
- label: I have updated the app to version **[1.10.4](https://github.com/jobobby04/tachiyomisy/releases/latest)**.
|
- label: I have updated the app to version **[1.10.5](https://github.com/jobobby04/tachiyomisy/releases/latest)**.
|
||||||
required: true
|
required: true
|
||||||
- label: I will fill out all of the requested information in this form.
|
- label: I will fill out all of the requested information in this form.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ jobs:
|
|||||||
java-version: 17
|
java-version: 17
|
||||||
distribution: adopt
|
distribution: adopt
|
||||||
|
|
||||||
|
- name: Set up gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v3
|
||||||
|
|
||||||
- name: Build app
|
- name: Build app
|
||||||
uses: gradle/gradle-command-action@v2
|
run: ./gradlew detekt assembleDevDebug
|
||||||
with:
|
|
||||||
arguments: assembleDevDebug
|
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
|
|||||||
@@ -40,10 +40,17 @@ jobs:
|
|||||||
path: app/google-services.json
|
path: app/google-services.json
|
||||||
contents: ${{ secrets.GOOGLE_SERVICES_TEXT }}
|
contents: ${{ secrets.GOOGLE_SERVICES_TEXT }}
|
||||||
write-mode: overwrite
|
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 -->
|
# SY -->
|
||||||
|
|
||||||
- name: Build app and run unit tests
|
- name: Build app and run unit tests
|
||||||
run: ./gradlew assembleStandardRelease testStandardReleaseUnitTest --stacktrace
|
run: ./gradlew detekt assembleStandardRelease testStandardReleaseUnitTest --stacktrace
|
||||||
|
|
||||||
- name: Sign APK
|
- name: Sign APK
|
||||||
uses: r0adkll/sign-android-release@v1
|
uses: r0adkll/sign-android-release@v1
|
||||||
|
|||||||
@@ -18,13 +18,10 @@ jobs:
|
|||||||
- name: Validate Gradle Wrapper
|
- name: Validate Gradle Wrapper
|
||||||
uses: gradle/wrapper-validation-action@v1
|
uses: gradle/wrapper-validation-action@v1
|
||||||
|
|
||||||
- name: TAG - Bump version and push tag
|
- name: Create Tag
|
||||||
uses: anothrNick/github-tag-action@1.67.0
|
run: |
|
||||||
env:
|
git tag "preview-${{ github.run_number }}"
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
git push origin "preview-${{ github.run_number }}"
|
||||||
WITH_V: true
|
|
||||||
RELEASE_BRANCHES: master
|
|
||||||
DEFAULT_BUMP: patch
|
|
||||||
|
|
||||||
- name: PING - Dispatch initiating repository event
|
- name: PING - Dispatch initiating repository event
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ TODO.md
|
|||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
/captures
|
/captures
|
||||||
build.sh
|
build.sh
|
||||||
|
/app/src/main/assets/client_secrets.json
|
||||||
|
|||||||
@@ -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:
|
- 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 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
|
- 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/`
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
| Preview Builds | Release Builds | Tachiyomi Support Server |
|
| Preview Builds | Release Builds | Mihon Support Server |
|
||||||
|-------|----------|----------|
|
|-------|----------|----------|
|
||||||
| [](https://github.com/jobobby04/TachiyomiSYPreview/releases) | [](https://github.com/jobobby04/tachiyomisy/releases/latest) | [](https://discord.gg/mihon) |
|
| [](https://github.com/jobobby04/TachiyomiSYPreview/releases) | [](https://github.com/jobobby04/tachiyomisy/releases/latest) | [](https://discord.gg/mihon) |
|
||||||
|
|
||||||
|
|
||||||
# TachiyomiSY
|
# 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.
|
||||||
|
|
||||||

|

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